#!/usr/bin/env bash # End-to-end regression script for multi-profile / multi-organization login. # It uses an isolated DWS_CONFIG_DIR and DWS_KEYCHAIN_DIR, seeds post-login # token results through the production auth storage API, then verifies the real # dws CLI command surface. # # Usage: # bash scripts/dev/test-multi-profile-e2e.sh # bash scripts/dev/test-multi-profile-e2e.sh --skip-go-tests --verbose set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" RUN_GO_TESTS=1 VERBOSE=0 KEEP_WORKDIR=0 E2E_VERSION="${DWS_PACKAGE_VERSION:-v1.0.53-beta.3}" GO_TEST_TIMEOUT="${MULTI_PROFILE_GO_TEST_TIMEOUT:-10m}" while [[ $# -gt 0 ]]; do case "$1" in --skip-go-tests) RUN_GO_TESTS=0 shift ;; --verbose) VERBOSE=1 shift ;; --keep-workdir) KEEP_WORKDIR=1 shift ;; -h|--help) sed -n '1,12p' "$0" exit 0 ;; *) echo "unknown option: $1" >&2 exit 2 ;; esac done mkdir -p "$ROOT/.tmp-bin" WORKDIR="$(mktemp -d "$ROOT/.tmp-bin/multi-profile-e2e.XXXXXX")" BIN="$WORKDIR/bin/dws" HELPER_DIR="$WORKDIR/helper" CONFIG_DIR="$WORKDIR/config" KEYCHAIN_DIR="$WORKDIR/keychain" CACHE_DIR="$WORKDIR/cache" OUT_DIR="$WORKDIR/out" cleanup() { if [[ "$KEEP_WORKDIR" -eq 1 ]]; then echo "[INFO] kept workdir: $WORKDIR" else rm -rf "$WORKDIR" fi } trap cleanup EXIT export DWS_CONFIG_DIR="$CONFIG_DIR" export DWS_KEYCHAIN_DIR="$KEYCHAIN_DIR" export DWS_DISABLE_KEYCHAIN=1 export DWS_CACHE_DIR="$CACHE_DIR" export DWS_PERF_REPORT= export DWS_PERF_DEBUG= mkdir -p "$HELPER_DIR" "$CONFIG_DIR" "$KEYCHAIN_DIR" "$CACHE_DIR" "$OUT_DIR" "$(dirname "$BIN")" log() { printf '\n==> %s\n' "$*" } fail() { echo "[FAIL] $*" >&2 exit 1 } run() { if [[ "$VERBOSE" -eq 1 ]]; then "$@" else "$@" >/dev/null fi } capture() { local file="$1" shift if [[ "$VERBOSE" -eq 1 ]]; then echo "+ $*" >&2 fi "$@" >"$file" 2>"$file.stderr" } expect_contains() { local file="$1" local needle="$2" if ! grep -F -- "$needle" "$file" >/dev/null; then echo "----- $file -----" >&2 cat "$file" >&2 fail "expected $file to contain: $needle" fi } expect_not_contains() { local file="$1" local needle="$2" if grep -F -- "$needle" "$file" >/dev/null; then echo "----- $file -----" >&2 cat "$file" >&2 fail "did not expect $file to contain: $needle" fi } expect_not_contains_line_command() { local file="$1" local command="$2" if grep -E "^[[:space:]]+$command([[:space:]]|$)" "$file" >/dev/null; then echo "----- $file -----" >&2 cat "$file" >&2 fail "did not expect command '$command' in $file" fi } expect_fail() { local needle="$1" shift local output set +e output="$("$@" 2>&1)" local code=$? set -e if [[ "$code" -eq 0 ]]; then echo "$output" >&2 fail "expected command to fail: $*" fi if ! grep -F -- "$needle" <<<"$output" >/dev/null; then echo "$output" >&2 fail "expected failure output to contain: $needle" fi } cat >"$HELPER_DIR/main.go" <<'GOEOF' package main import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "time" auth "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" ) type profileListResponse struct { Success bool `json:"success"` PrimaryProfile string `json:"primaryProfile"` CurrentProfile string `json:"currentProfile"` PreviousProfile string `json:"previousProfile"` Profiles []profileView `json:"profiles"` } type profileUseResponse struct { Success bool `json:"success"` Profile profileView `json:"profile"` } type profileView struct { Profile string `json:"profile"` CorpID string `json:"corpId"` CorpName string `json:"corpName"` UserID string `json:"userId"` UserName string `json:"userName"` Status string `json:"status"` ExpiresAt string `json:"expiresAt"` RefreshExpAt string `json:"refreshExpAt"` IsPrimary bool `json:"isPrimary"` IsCurrent bool `json:"isCurrent"` IsOrgCurrent bool `json:"isOrgCurrent"` } type authStatusResponse struct { Success bool `json:"success"` Authenticated bool `json:"authenticated"` TokenValid bool `json:"token_valid"` RefreshTokenValid bool `json:"refresh_token_valid"` CorpID string `json:"corp_id"` CorpName string `json:"corp_name"` UserID string `json:"user_id"` UserName string `json:"user_name"` } type multiProfileResponse struct { Success bool `json:"success"` MultiProfile bool `json:"multiProfile"` Summary multiProfileSummary `json:"summary"` Profiles []multiProfileResult `json:"profiles"` } type multiProfileSummary struct { Total int `json:"total"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` } type multiProfileResult struct { Selector string `json:"selector"` Profile string `json:"profile"` CorpID string `json:"corpId"` CorpName string `json:"corpName"` OK bool `json:"ok"` Result map[string]any `json:"result"` } func main() { if len(os.Args) < 2 { die("missing helper command") } configDir := os.Getenv("DWS_CONFIG_DIR") if strings.TrimSpace(configDir) == "" { die("DWS_CONFIG_DIR is required") } switch os.Args[1] { case "seed": needArgs(7) data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6]) must(auth.SaveTokenData(configDir, data)) case "seed-orphan": needArgs(7) data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6]) must(auth.SaveTokenDataKeychainForIdentity(data.CorpID, data.UserID, data)) case "seed-expired": needArgs(7) data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6]) data.ExpiresAt = time.Now().Add(-time.Hour) must(auth.SaveTokenData(configDir, data)) case "seed-legacy": needArgs(7) data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6]) must(auth.SaveTokenDataKeychain(data)) must(auth.WriteTokenMarker(configDir)) case "write-app-config": needArgs(4) must(auth.SaveAppConfig(configDir, &auth.AppConfig{ ClientID: os.Args[2], ClientSecret: auth.PlainSecret(os.Args[3]), })) case "assert-app-config": needArgs(3) cfg, err := auth.LoadAppConfig(configDir) must(err) switch os.Args[2] { case "exists": if cfg == nil || strings.TrimSpace(cfg.ClientID) == "" { die("expected app config to exist") } case "absent": if cfg != nil { die("expected app config to be absent, got clientID=%q", cfg.ClientID) } default: die("unknown app config expectation %q", os.Args[2]) } case "assert-profiles": needArgs(6) cfg, err := auth.LoadProfiles(configDir) must(err) wantCount := atoi(os.Args[2]) if len(cfg.Profiles) != wantCount { die("profiles len=%d, want %d: %#v", len(cfg.Profiles), wantCount, cfg.Profiles) } if wantCount > 0 && cfg.Version != 2 { die("profiles version=%d, want 2", cfg.Version) } assertEqual("primaryProfile", cfg.PrimaryProfile, emptySentinel(os.Args[3])) assertEqual("currentProfile", cfg.CurrentProfile, emptySentinel(os.Args[4])) assertEqual("previousProfile", cfg.PreviousProfile, emptySentinel(os.Args[5])) assertNoSecrets(configDir) assertProfileMetadata(cfg) case "assert-org-current": needArgs(4) cfg, err := auth.LoadProfiles(configDir) must(err) assertEqual("orgCurrentProfiles["+os.Args[2]+"]", cfg.OrgCurrentProfiles[os.Args[2]], emptySentinel(os.Args[3])) case "assert-list-json": needArgs(7) var resp profileListResponse raw := readJSON(os.Args[2], &resp) if strings.Contains(string(raw), `"name"`) { die("profile list JSON must not expose local name: %s", string(raw)) } if !resp.Success { die("profile list success=false") } wantCount := atoi(os.Args[3]) if len(resp.Profiles) != wantCount { die("list profiles len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles) } assertEqual("list primaryProfile", resp.PrimaryProfile, emptySentinel(os.Args[4])) assertEqual("list currentProfile", resp.CurrentProfile, emptySentinel(os.Args[5])) assertEqual("list previousProfile", resp.PreviousProfile, emptySentinel(os.Args[6])) primaryCount := 0 currentCount := 0 for _, p := range resp.Profiles { if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" { die("profile list item missing corp identity: %#v", p) } if strings.TrimSpace(p.UserID) != "" && p.Profile != p.CorpID+":"+p.UserID { die("profile selector=%q, want %s:%s", p.Profile, p.CorpID, p.UserID) } if p.IsPrimary { primaryCount++ } if p.IsCurrent { currentCount++ } } if resp.PrimaryProfile != "" && primaryCount != 1 { die("primary marker count=%d, want 1", primaryCount) } if resp.CurrentProfile != "" && currentCount != 1 { die("current marker count=%d, want 1", currentCount) } orgCurrentCounts := map[string]int{} for _, p := range resp.Profiles { if p.IsOrgCurrent { orgCurrentCounts[p.CorpID]++ } } for corpID, count := range orgCurrentCounts { if count != 1 { die("organization %s has %d org-current accounts, want 1", corpID, count) } } case "assert-list-account": needArgs(7) var resp profileListResponse readJSON(os.Args[2], &resp) for _, p := range resp.Profiles { if p.Profile != os.Args[3] { continue } assertEqual("list account status", p.Status, os.Args[4]) assertEqual("list account isCurrent", fmt.Sprint(p.IsCurrent), os.Args[5]) assertEqual("list account isOrgCurrent", fmt.Sprint(p.IsOrgCurrent), os.Args[6]) if strings.TrimSpace(p.ExpiresAt) == "" || strings.TrimSpace(p.RefreshExpAt) == "" { die("list account missing live token expiry: %#v", p) } return } die("list account %q not found", os.Args[3]) case "assert-list-order": needArgs(4) var resp profileListResponse readJSON(os.Args[2], &resp) want := strings.Split(os.Args[3], ",") if len(resp.Profiles) != len(want) { die("list order profile count=%d, want %d", len(resp.Profiles), len(want)) } for i := range want { assertEqual(fmt.Sprintf("list order[%d]", i), resp.Profiles[i].Profile, strings.TrimSpace(want[i])) } case "assert-switch-json": needArgs(5) var resp profileUseResponse readJSON(os.Args[2], &resp) if !resp.Success { die("switch JSON success=false") } assertEqual("switch corpId", resp.Profile.CorpID, os.Args[3]) assertEqual("switch corpName", resp.Profile.CorpName, os.Args[4]) if !resp.Profile.IsCurrent { die("switch profile isCurrent=false") } case "assert-status-json": needArgs(6) var resp authStatusResponse readJSON(os.Args[2], &resp) if !resp.Success || !resp.Authenticated || !resp.TokenValid || !resp.RefreshTokenValid { die("bad auth status response: %#v", resp) } assertEqual("status corpId", resp.CorpID, os.Args[3]) assertEqual("status corpName", resp.CorpName, os.Args[4]) assertEqual("status userId", resp.UserID, os.Args[5]) case "assert-multi-profile-json": needArgs(5) var resp multiProfileResponse readJSON(os.Args[2], &resp) if !resp.Success || !resp.MultiProfile { die("bad multi-profile response: %#v", resp) } wantCount := atoi(os.Args[3]) if len(resp.Profiles) != wantCount { die("multi-profile len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles) } if resp.Summary.Total != wantCount || resp.Summary.Succeeded != wantCount || resp.Summary.Failed != 0 { die("bad multi-profile summary: %#v", resp.Summary) } wantCorpIDs := strings.Split(os.Args[4], ",") if len(wantCorpIDs) != wantCount { die("want corpId count=%d, want %d", len(wantCorpIDs), wantCount) } for i, want := range wantCorpIDs { want = strings.TrimSpace(want) got := resp.Profiles[i] if !got.OK { die("profile %d ok=false: %#v", i, got) } assertEqual(fmt.Sprintf("multi-profile corpId[%d]", i), got.CorpID, want) if got.Result["_mock"] != true { die("profile %s result is not mock payload: %#v", got.CorpID, got.Result) } } case "assert-token": needArgs(5) data, err := loadToken(configDir, os.Args[2]) must(err) assertEqual("token corpId", data.CorpID, os.Args[3]) assertEqual("token access", data.AccessToken, os.Args[4]) case "assert-token-missing": needArgs(3) if data, err := loadToken(configDir, os.Args[2]); err == nil { die("token %q still exists: %#v", os.Args[2], data) } case "delete": needArgs(3) must(auth.DeleteTokenDataForProfile(configDir, os.Args[2])) case "assert-empty-auth": needArgs(2) cfg, err := auth.LoadProfiles(configDir) must(err) if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 { die("expected empty profiles after reset, got %#v", cfg) } if auth.TokenDataExistsKeychain() { die("legacy auth-token still exists") } case "assert-duplicate-name-fallback": needArgs(4) cfg, err := auth.LoadProfiles(configDir) must(err) p := findProfile(cfg, os.Args[2]) if p == nil { die("profile %q not found", os.Args[2]) } if p.CorpName != os.Args[3] { die("profile %s corpName=%q, want %q", p.CorpID, p.CorpName, os.Args[3]) } if p.Name == os.Args[3] || !strings.HasPrefix(p.Name, os.Args[3]+"-") { die("profile %s name=%q, want stable fallback prefix %q", p.CorpID, p.Name, os.Args[3]+"-") } default: die("unknown helper command %q", os.Args[1]) } } func token(corpID, corpName, userID, userName, access string) *auth.TokenData { return &auth.TokenData{ AccessToken: access, RefreshToken: "refresh-" + corpID, PersistentCode: "persistent-" + corpID, ExpiresAt: time.Now().Add(2 * time.Hour), RefreshExpAt: time.Now().Add(720 * time.Hour), CorpID: corpID, CorpName: corpName, UserID: userID, UserName: userName, ClientID: "client-" + corpID, Source: "multi-profile-e2e", } } func needArgs(n int) { if len(os.Args) != n { die("%s: got %d args, want %d", os.Args[1], len(os.Args)-2, n-2) } } func loadToken(configDir, selector string) (*auth.TokenData, error) { if selector == "default" { return auth.LoadTokenData(configDir) } return auth.LoadTokenDataForProfile(configDir, selector) } func readJSON(path string, dst any) []byte { data, err := os.ReadFile(path) must(err) if err := json.Unmarshal(data, dst); err != nil { die("parse %s: %v\n%s", path, err, string(data)) } return data } func assertProfileMetadata(cfg *auth.ProfilesConfig) { names := map[string]string{} for _, p := range cfg.Profiles { if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" { die("profile missing corp metadata: %#v", p) } if prev, ok := names[p.Name]; ok { die("duplicate profile local name %q for %s and %s", p.Name, prev, p.CorpID) } if p.ExpiresAt != "" || p.RefreshExpAt != "" { die("profile must not persist derived token expiry: %#v", p) } for field, value := range map[string]string{ "lastLoginAt": p.LastLoginAt, "lastUsedAt": p.LastUsedAt, "updatedAt": p.UpdatedAt, } { if _, err := time.Parse(time.RFC3339, value); err != nil { die("profile %s %s=%q, want RFC3339 timestamp: %v", p.CorpID, field, value, err) } } names[p.Name] = p.CorpID } } func assertNoSecrets(configDir string) { data, err := os.ReadFile(filepath.Join(configDir, "profiles.json")) if err != nil { if os.IsNotExist(err) { return } must(err) } for _, forbidden := range []string{"access_token", "refresh_token", "persistent_code", "client_secret"} { if strings.Contains(string(data), forbidden) { die("profiles.json contains secret field %q", forbidden) } } } func findProfile(cfg *auth.ProfilesConfig, corpID string) *auth.Profile { for i := range cfg.Profiles { if cfg.Profiles[i].CorpID == corpID { return &cfg.Profiles[i] } } return nil } func atoi(raw string) int { var n int if _, err := fmt.Sscanf(raw, "%d", &n); err != nil { die("invalid integer %q", raw) } return n } func emptySentinel(s string) string { if s == "_" { return "" } return s } func assertEqual(label, got, want string) { if got != want { die("%s=%q, want %q", label, got, want) } } func must(err error) { if err != nil { die("%v", err) } } func die(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) os.Exit(1) } GOEOF cd "$ROOT" if [[ "$RUN_GO_TESTS" -eq 1 ]]; then log "running multi-profile Go regressions" go test -timeout "$GO_TEST_TIMEOUT" -count=1 ./internal/auth ./internal/app ./test/cli fi log "building dws" run go build -ldflags="-X github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app.version=$E2E_VERSION" -o "$BIN" ./cmd helper() { go run "$HELPER_DIR" "$@" } log "checking command surface" capture "$OUT_DIR/version.txt" "$BIN" version expect_contains "$OUT_DIR/version.txt" "$E2E_VERSION" capture "$OUT_DIR/root-help.txt" "$BIN" --help expect_contains "$OUT_DIR/root-help.txt" "--profile" expect_contains "$OUT_DIR/root-help.txt" "--yes" expect_contains "$OUT_DIR/root-help.txt" "--dry-run" expect_contains "$OUT_DIR/root-help.txt" "profile" capture "$OUT_DIR/profile-help.txt" "$BIN" profile --help expect_contains "$OUT_DIR/profile-help.txt" "list" expect_contains "$OUT_DIR/profile-help.txt" "switch" expect_contains "$OUT_DIR/profile-help.txt" "use" expect_contains "$OUT_DIR/profile-help.txt" "--profile" capture "$OUT_DIR/auth-login-help.txt" "$BIN" auth login --help expect_contains "$OUT_DIR/auth-login-help.txt" "--device" expect_contains "$OUT_DIR/auth-login-help.txt" "--token" expect_contains "$OUT_DIR/auth-login-help.txt" "--recommend" expect_contains "$OUT_DIR/auth-login-help.txt" "--yes" capture "$OUT_DIR/skill-setup-help.txt" "$BIN" skill setup --help expect_contains "$OUT_DIR/skill-setup-help.txt" "--mode" expect_contains "$OUT_DIR/skill-setup-help.txt" "--target" expect_contains "$OUT_DIR/skill-setup-help.txt" "--yes" expect_contains "$OUT_DIR/skill-setup-help.txt" "--skill" expect_contains "$OUT_DIR/skill-setup-help.txt" "--exclude" capture "$OUT_DIR/upgrade-help.txt" "$BIN" upgrade --help expect_contains "$OUT_DIR/upgrade-help.txt" "--dry-run" expect_contains "$OUT_DIR/upgrade-help.txt" "--yes" capture "$OUT_DIR/dev-connect-help.txt" "$BIN" dev connect --help expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-id" expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-secret" expect_contains "$OUT_DIR/dev-connect-help.txt" "--unified-app-id" expect_contains "$OUT_DIR/dev-connect-help.txt" "--agent-cmd" expect_contains "$OUT_DIR/dev-connect-help.txt" "--daemon" capture "$OUT_DIR/doc-delete-help.txt" "$BIN" doc delete --help expect_contains "$OUT_DIR/doc-delete-help.txt" "--yes" capture "$OUT_DIR/aitable-base-delete-help.txt" "$BIN" aitable base delete --help expect_contains "$OUT_DIR/aitable-base-delete-help.txt" "--yes" capture "$OUT_DIR/auth-help.txt" "$BIN" auth --help expect_not_contains_line_command "$OUT_DIR/auth-help.txt" "switch" log "checking embedded mono and multi skills" MONO_HOME="$WORKDIR/home-mono" MULTI_HOME="$WORKDIR/home-multi" mkdir -p "$MONO_HOME" "$MULTI_HOME" capture "$OUT_DIR/skill-mono-setup.txt" env HOME="$MONO_HOME" "$BIN" skill setup --mode mono --target codex --source "$ROOT" --yes expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "corpId:userId" expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" expect_contains "$MONO_HOME/.agents/skills/dws/references/global-reference.md" "userId/userName" capture "$OUT_DIR/skill-multi-setup.txt" env HOME="$MULTI_HOME" "$BIN" skill setup --mode multi --target codex --source "$ROOT" --yes expect_contains "$MULTI_HOME/.agents/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "corpId:userId" expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "isOrgCurrent" log "verifying empty profile list" capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json helper assert-list-json "$OUT_DIR/list-empty.json" 0 _ _ _ log "seeding first organization profile" helper seed corp_alpha "Alpha Org" user_alpha "Alice Alpha" access-alpha-v1 capture "$OUT_DIR/list-alpha.json" "$BIN" profile list --format json helper assert-list-json "$OUT_DIR/list-alpha.json" 1 _ corp_alpha:user_alpha _ helper assert-list-account "$OUT_DIR/list-alpha.json" corp_alpha:user_alpha active true true helper assert-profiles 1 _ corp_alpha:user_alpha _ helper assert-org-current corp_alpha corp_alpha:user_alpha helper assert-token default corp_alpha access-alpha-v1 helper assert-token corp_alpha corp_alpha access-alpha-v1 capture "$OUT_DIR/status-alpha-default.json" "$BIN" auth status --format json helper assert-status-json "$OUT_DIR/status-alpha-default.json" corp_alpha "Alpha Org" user_alpha log "seeding second organization profile" helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v1 capture "$OUT_DIR/list-alpha-beta.json" "$BIN" profile list --format json helper assert-list-json "$OUT_DIR/list-alpha-beta.json" 2 _ corp_beta:user_beta corp_alpha:user_alpha helper assert-profiles 2 _ corp_beta:user_beta corp_alpha:user_alpha helper assert-org-current corp_alpha corp_alpha:user_alpha helper assert-org-current corp_beta corp_beta:user_beta helper assert-token default corp_beta access-beta-v1 helper assert-token corp_alpha corp_alpha access-alpha-v1 helper assert-token corp_beta corp_beta access-beta-v1 log "refreshing existing organization without duplicating profile" helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v2 capture "$OUT_DIR/list-beta-refresh.json" "$BIN" profile list --format json helper assert-list-json "$OUT_DIR/list-beta-refresh.json" 2 _ corp_beta:user_beta corp_alpha:user_alpha helper assert-profiles 2 _ corp_beta:user_beta corp_alpha:user_alpha helper assert-token corp_beta corp_beta access-beta-v2 log "seeding duplicate organization name and checking stable fallback" helper seed corp_gamma "Beta Org" user_gamma "Gina Gamma" access-gamma-v1 capture "$OUT_DIR/list-duplicate-name.json" "$BIN" profile list --format json helper assert-list-json "$OUT_DIR/list-duplicate-name.json" 3 _ corp_gamma:user_gamma corp_beta:user_beta helper assert-profiles 3 _ corp_gamma:user_gamma corp_beta:user_beta helper assert-org-current corp_gamma corp_gamma:user_gamma helper assert-duplicate-name-fallback corp_gamma "Beta Org" expect_fail "corp_beta" "$BIN" auth status --profile "Beta Org" --format json log "checking all friendly account selector combinations" capture "$OUT_DIR/status-alpha-id-id.json" "$BIN" auth status --profile "corp_alpha:user_alpha" --format json helper assert-status-json "$OUT_DIR/status-alpha-id-id.json" corp_alpha "Alpha Org" user_alpha capture "$OUT_DIR/status-alpha-id-name.json" "$BIN" auth status --profile "corp_alpha:Alice Alpha" --format json helper assert-status-json "$OUT_DIR/status-alpha-id-name.json" corp_alpha "Alpha Org" user_alpha capture "$OUT_DIR/status-alpha-name-id.json" "$BIN" auth status --profile "Alpha Org:user_alpha" --format json helper assert-status-json "$OUT_DIR/status-alpha-name-id.json" corp_alpha "Alpha Org" user_alpha capture "$OUT_DIR/status-alpha-name-name.json" "$BIN" auth status --profile "Alpha Org:Alice Alpha" --format json helper assert-status-json "$OUT_DIR/status-alpha-name-name.json" corp_alpha "Alpha Org" user_alpha helper assert-profiles 3 _ corp_gamma:user_gamma corp_beta:user_beta log "switching profiles and verifying legacy mirror" capture "$OUT_DIR/switch-alpha.json" "$BIN" profile switch corp_alpha --format json helper assert-switch-json "$OUT_DIR/switch-alpha.json" corp_alpha "Alpha Org" helper assert-profiles 3 _ corp_alpha:user_alpha corp_gamma:user_gamma helper assert-token default corp_alpha access-alpha-v1 capture "$OUT_DIR/switch-beta.txt" "$BIN" profile switch corp_beta --format table expect_contains "$OUT_DIR/switch-beta.txt" "Beta Org" expect_contains "$OUT_DIR/switch-beta.txt" "corp_beta" helper assert-profiles 3 _ corp_beta:user_beta corp_alpha:user_alpha helper assert-token default corp_beta access-beta-v2 capture "$OUT_DIR/switch-previous.json" "$BIN" profile switch - --format json helper assert-switch-json "$OUT_DIR/switch-previous.json" corp_alpha "Alpha Org" helper assert-profiles 3 _ corp_alpha:user_alpha corp_beta:user_beta capture "$OUT_DIR/use-gamma.json" "$BIN" profile use corp_gamma --format json helper assert-switch-json "$OUT_DIR/use-gamma.json" corp_gamma "Beta Org" helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha log "checking profile switch validation" expect_fail "profile selector required" "$BIN" profile switch expect_fail "只能指定一个组织选择器" "$BIN" profile switch corp_alpha --corpId corp_beta expect_fail "missing_org" "$BIN" profile switch missing_org log "checking one-shot profile override without changing current profile" capture "$OUT_DIR/status-root-profile-alpha.json" "$BIN" --profile corp_alpha auth status --format json helper assert-status-json "$OUT_DIR/status-root-profile-alpha.json" corp_alpha "Alpha Org" user_alpha helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha capture "$OUT_DIR/status-local-profile-beta.json" "$BIN" auth status --profile corp_beta --format json helper assert-status-json "$OUT_DIR/status-local-profile-beta.json" corp_beta "Beta Org" user_beta helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha capture "$OUT_DIR/status-current-gamma.json" "$BIN" auth status --format json helper assert-status-json "$OUT_DIR/status-current-gamma.json" corp_gamma "Beta Org" user_gamma capture "$OUT_DIR/contact-multi-profile.json" "$BIN" --mock --profile corp_alpha, corp_beta contact user get-self --format json helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile.json" 2 corp_alpha,corp_beta helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha capture "$OUT_DIR/contact-multi-profile-leaf-profile.json" "$BIN" --mock contact user get-self --profile corp_alpha, corp_beta --format json helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile-leaf-profile.json" 2 corp_alpha,corp_beta helper assert-profiles 3 _ corp_gamma:user_gamma corp_alpha:user_alpha log "checking multiple accounts in the same organization" helper seed corp_gamma "Beta Org" user_gamma_alt "Grace Gamma" access-gamma-alt-v1 capture "$OUT_DIR/list-same-corp-accounts.json" "$BIN" profile list --format json helper assert-list-json "$OUT_DIR/list-same-corp-accounts.json" 4 _ corp_gamma:user_gamma_alt corp_gamma:user_gamma helper assert-profiles 4 _ corp_gamma:user_gamma_alt corp_gamma:user_gamma helper assert-org-current corp_gamma corp_gamma:user_gamma_alt helper assert-list-order "$OUT_DIR/list-same-corp-accounts.json" "corp_alpha:user_alpha,corp_beta:user_beta,corp_gamma:user_gamma,corp_gamma:user_gamma_alt" expect_contains "$OUT_DIR/list-same-corp-accounts.json" '"profile": "corp_gamma:user_gamma"' expect_contains "$OUT_DIR/list-same-corp-accounts.json" '"profile": "corp_gamma:user_gamma_alt"' helper assert-token corp_gamma:user_gamma corp_gamma access-gamma-v1 helper assert-token corp_gamma:user_gamma_alt corp_gamma access-gamma-alt-v1 helper assert-token corp_gamma corp_gamma access-gamma-alt-v1 capture "$OUT_DIR/switch-gamma-exact.json" "$BIN" profile switch corp_gamma:user_gamma --format json helper assert-switch-json "$OUT_DIR/switch-gamma-exact.json" corp_gamma "Beta Org" helper assert-profiles 4 _ corp_gamma:user_gamma corp_gamma:user_gamma_alt helper assert-org-current corp_gamma corp_gamma:user_gamma helper assert-token default corp_gamma access-gamma-v1 helper assert-token corp_gamma corp_gamma access-gamma-v1 capture "$OUT_DIR/contact-same-corp-multi.json" "$BIN" --mock --profile corp_gamma,corp_gamma:user_gamma,corp_gamma:user_gamma_alt contact user get-self --format json helper assert-multi-profile-json "$OUT_DIR/contact-same-corp-multi.json" 2 corp_gamma,corp_gamma expect_contains "$OUT_DIR/contact-same-corp-multi.json" '"profile": "corp_gamma:user_gamma"' expect_contains "$OUT_DIR/contact-same-corp-multi.json" '"profile": "corp_gamma:user_gamma_alt"' log "checking duplicate user names and missing organization default" helper seed corp_gamma "Beta Org" user_gamma_dup "Grace Gamma" access-gamma-dup-v1 expect_fail "corp_gamma:user_gamma_alt" "$BIN" auth status --profile "corp_gamma:Grace Gamma" --format json helper delete corp_gamma:user_gamma_dup helper assert-profiles 4 _ corp_gamma:user_gamma _ helper assert-org-current corp_gamma _ expect_fail "corp_gamma:user_gamma" "$BIN" auth status --profile corp_gamma --format json capture "$OUT_DIR/status-gamma-exact-after-clear.json" "$BIN" auth status --profile corp_gamma:user_gamma --format json helper assert-status-json "$OUT_DIR/status-gamma-exact-after-clear.json" corp_gamma "Beta Org" user_gamma log "checking profile list uses identity token truth without refresh" helper seed-expired corp_expired "Expired Org" user_expired "Eve Expired" access-expired-v1 capture "$OUT_DIR/list-expired.json" "$BIN" profile list --format json helper assert-list-account "$OUT_DIR/list-expired.json" corp_expired:user_expired expired true true capture "$OUT_DIR/list-table.txt" "$BIN" profile list --format table expect_not_contains "$OUT_DIR/list-table.txt" "PRI" log "checking exact-account, organization, and all-account logout" run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile corp_gamma:user_gamma_alt helper assert-token-missing corp_gamma:user_gamma_alt helper assert-profiles 4 _ corp_expired:user_expired corp_gamma:user_gamma helper assert-org-current corp_gamma _ run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile corp_gamma helper assert-token-missing corp_gamma:user_gamma helper assert-token-missing corp_gamma helper assert-profiles 3 _ corp_expired:user_expired _ run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile "corp_expired:Eve Expired" helper assert-token-missing corp_expired:user_expired helper assert-token-missing default helper assert-profiles 2 _ _ _ run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout --profile corp_beta helper assert-token-missing corp_beta:user_beta helper assert-profiles 1 _ corp_alpha:user_alpha _ run env HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 ALL_PROXY= NO_PROXY= "$BIN" auth logout helper assert-empty-auth log "checking auth reset cleanup" helper seed corp_reset "Reset Org" user_reset "Rita Reset" access-reset-v1 helper seed-orphan corp_orphan "Orphan Org" user_orphan "Oscar Orphan" access-orphan-v1 helper write-app-config client-reset secret-reset helper assert-app-config exists capture "$OUT_DIR/auth-reset.txt" "$BIN" auth reset expect_contains "$OUT_DIR/auth-reset.txt" "[OK]" helper assert-empty-auth helper assert-token-missing corp_reset:user_reset helper assert-token-missing corp_orphan:user_orphan helper assert-app-config absent log "checking legacy single-slot migration" helper seed-legacy corp_legacy "Legacy Org" user_legacy "Lena Legacy" access-legacy-v1 helper assert-profiles 0 _ _ _ capture "$OUT_DIR/list-legacy-migrated.json" "$BIN" profile list --format json helper assert-list-json "$OUT_DIR/list-legacy-migrated.json" 1 _ corp_legacy:user_legacy _ helper assert-profiles 1 _ corp_legacy:user_legacy _ helper assert-org-current corp_legacy corp_legacy:user_legacy helper assert-token default corp_legacy access-legacy-v1 helper assert-token corp_legacy corp_legacy access-legacy-v1 log "multi-profile e2e passed" echo "[PASS] isolated multi-profile chain completed"