From ccb69f93b898fe8cbd583eb387c504875ecaf72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E5=AE=98=E7=8E=84?= Date: Thu, 23 Jul 2026 00:01:09 +0800 Subject: [PATCH 1/6] fix(auth): preserve legacy login state across token backends --- internal/app/auth_command.go | 380 ++++++- .../app/auth_command_full_coverage_test.go | 537 +++++++++- .../app/auth_legacy_platform_coverage_test.go | 307 ++++++ .../blank_profile_selector_regression_test.go | 144 +++ internal/app/event_personal_command.go | 40 +- internal/app/event_stdin_gate_test.go | 55 + internal/app/multi_profile_runner_test.go | 39 + internal/app/profile_command.go | 63 +- internal/app/runner.go | 12 +- .../blank_profile_selector_regression_test.go | 346 +++++++ internal/auth/coverage_edges_test.go | 10 +- internal/auth/keychain_store.go | 18 +- .../auth/legacy_compat_coverage_gaps_test.go | 288 ++++++ .../auth/legacy_version_migration_test.go | 879 ++++++++++++++++ internal/auth/oauth_helpers.go | 20 +- internal/auth/oauth_provider.go | 27 +- internal/auth/oauth_provider_coverage_test.go | 5 +- internal/auth/profiles.go | 512 +++++++-- internal/auth/refresh_failure_test.go | 92 ++ internal/auth/token.go | 136 ++- internal/auth/token_preflight_test.go | 39 + internal/auth/token_test.go | 977 +++++++++++++++++- test/smoke/auth_legacy_slots_smoke_test.go | 451 ++++++++ 23 files changed, 5172 insertions(+), 205 deletions(-) create mode 100644 internal/app/auth_legacy_platform_coverage_test.go create mode 100644 internal/app/blank_profile_selector_regression_test.go create mode 100644 internal/auth/blank_profile_selector_regression_test.go create mode 100644 internal/auth/legacy_compat_coverage_gaps_test.go create mode 100644 internal/auth/legacy_version_migration_test.go create mode 100644 test/smoke/auth_legacy_slots_smoke_test.go diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 777aa195..fc2f948b 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -40,12 +40,14 @@ import ( ) type authLoginConfig struct { - Token string - Force bool - Device bool - Recommend bool - Yes bool - TargetCorpID string + Token string + Force bool + Device bool + Recommend bool + Yes bool + TargetCorpID string + HistoryProfileSelector string + HistoryProfileSelectorExplicit bool } type authLoginGuideAction string @@ -148,7 +150,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { provider.Output = cmd.ErrOrStderr() provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error { - return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data) + return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{ + Selector: cfg.HistoryProfileSelector, + Explicit: cfg.HistoryProfileSelectorExplicit, + }) } tokenData, err = authDeviceLogin(provider, loginCtx) if err != nil { @@ -163,7 +168,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") provider.TargetCorpID = cfg.TargetCorpID provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error { - return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data) + return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{ + Selector: cfg.HistoryProfileSelector, + Explicit: cfg.HistoryProfileSelectorExplicit, + }) } configureOAuthProviderCompatibility(provider, configDir) tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg)) @@ -175,11 +183,23 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { ResetRuntimeTokenCache() clearCompatCache() w := cmd.OutOrStdout() + postLoginSelector := authpkg.TokenProfileSelector(tokenData) + if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" { + if profiles, loadErr := authLoadProfiles(configDir); loadErr == nil && profiles != nil { + for i := range profiles.Profiles { + profile := profiles.Profiles[i] + if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(tokenData.CorpID) && strings.TrimSpace(profile.UserID) == "" { + postLoginSelector = profileCLISelector(profile, profiles) + break + } + } + } + } runPostLoginAuthorization := func() error { if !recommendAuthMode { return nil } - restoreProfile := replaceRuntimeProfile(authpkg.TokenProfileSelector(tokenData)) + restoreProfile := replaceRuntimeProfile(postLoginSelector) defer restoreProfile() recommendScopeMode := pat.LoginRecommendScopeRecommended var initialPlan *pat.LoginRecommendPlan @@ -494,7 +514,9 @@ func newAuthStatusCommand() *cobra.Command { if selected == nil { return apperrors.NewValidation(fmt.Sprintf("profile %q not found", profileSelector)) } - profileSelector = authpkg.ProfileSelector(*selected) + if strings.TrimSpace(selected.UserID) != "" { + profileSelector = authpkg.ProfileSelector(*selected) + } } restoreProfile := pushRuntimeProfile(profileSelector) defer restoreProfile() @@ -522,7 +544,11 @@ func newAuthStatusCommand() *cobra.Command { _ = authDeleteTokenData(configDir) } else if tokenData != nil { refreshFailure = refreshErr - _ = authMarkProfileStatus(configDir, authpkg.TokenProfileSelector(tokenData), authpkg.ProfileStatusExpired) + markSelector := profileSelector + if markSelector == "" { + markSelector = authpkg.StableTokenProfileSelector(configDir, tokenData) + } + _ = authMarkProfileStatus(configDir, markSelector, authpkg.ProfileStatusExpired) } } if refreshFailure == nil && authStatusAuthenticated(tokenData) { @@ -652,7 +678,14 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector } stableSelector := selected.CorpID if exact { - stableSelector = authpkg.ProfileSelector(*selected) + if strings.TrimSpace(selected.UserID) == "" { + // A blank historical profile can coexist with exact accounts in the + // same organization. Preserve the exact local-name selector; reducing + // it to corpId would log out the entire organization. + stableSelector = strings.TrimSpace(selector) + } else { + stableSelector = authpkg.ProfileSelector(*selected) + } if data, loadErr := authLoadTokenForProfile(configDir, stableSelector); loadErr == nil { _ = authRevokeTokenForData(ctx, data) } @@ -661,7 +694,7 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector if profile.CorpID != selected.CorpID { continue } - if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil { + if data, tokenErr := authLoadTokenForProfile(configDir, profileCLISelector(profile, cfg)); tokenErr == nil { _ = authRevokeTokenForData(ctx, data) } } @@ -687,7 +720,7 @@ func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) _ = authRevokeToken(ctx) } else { for _, profile := range cfg.Profiles { - if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil { + if data, tokenErr := authLoadTokenForProfile(configDir, profileCLISelector(profile, cfg)); tokenErr == nil { _ = authRevokeTokenForData(ctx, data) } } @@ -1206,7 +1239,7 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { yes, _ = cmd.Root().PersistentFlags().GetBool("yes") profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile") } - targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector) + targetCorpID, historyProfileSelector, historyProfileSelectorExplicit, err := resolveAuthLoginTarget(defaultConfigDir(), profileSelector) if err != nil { return authLoginConfig{}, err } @@ -1221,15 +1254,18 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { "flow", flow, "profile_selector", strings.TrimSpace(profileSelector), "target_corp_id", targetCorpID, + "history_profile_selector", historyProfileSelector, "recommend", recommend, ) return authLoginConfig{ - Token: strings.TrimSpace(token), - Force: force, - Device: device, - Recommend: recommend, - Yes: yes, - TargetCorpID: targetCorpID, + Token: strings.TrimSpace(token), + Force: force, + Device: device, + Recommend: recommend, + Yes: yes, + TargetCorpID: targetCorpID, + HistoryProfileSelector: historyProfileSelector, + HistoryProfileSelectorExplicit: historyProfileSelectorExplicit, }, nil } @@ -1238,17 +1274,57 @@ func authLoginForcesAuthorization(_ authLoginConfig) bool { } func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) { + targetCorpID, _, _, err := resolveAuthLoginTarget(configDir, selector) + return targetCorpID, err +} + +// resolveAuthLoginTarget keeps the authorization target separate from the +// local identity hint used only when contact cannot resolve the logged-in +// account. An implicit current profile must never constrain a fresh OAuth +// authorization to that profile's organization. +func resolveAuthLoginTarget(configDir, selector string) (targetCorpID, historySelector string, explicit bool, err error) { selector = strings.TrimSpace(selector) if selector == "" { - return "", nil + if profile, resolveErr := authResolveProfile(configDir, ""); resolveErr == nil && profile != nil { + return "", authLoginHistorySelector(configDir, profile), false, nil + } + return "", "", false, nil } if profile, err := authResolveProfile(configDir, selector); err == nil && profile != nil { - return strings.TrimSpace(profile.CorpID), nil + historySelector := authLoginHistorySelector(configDir, profile) + _, _, identityExact := authpkg.ParseIdentitySelector(selector) + if selector != strings.TrimSpace(profile.CorpID) && selector != strings.TrimSpace(profile.CorpName) { + identityExact = true + } + return strings.TrimSpace(profile.CorpID), historySelector, identityExact, nil + } + if _, _, exact := authpkg.ParseIdentitySelector(selector); exact || strings.Contains(selector, ":") { + return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector)) } if strings.HasPrefix(selector, "ding") { - return selector, nil + // A known organization that failed resolution is ambiguous (for + // example, two local accounts without an org-current pointer), not a + // request to invent a new corpId. + if cfg, loadErr := authLoadProfiles(configDir); loadErr == nil && cfg != nil { + for i := range cfg.Profiles { + if strings.TrimSpace(cfg.Profiles[i].CorpID) == selector { + return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q is ambiguous; use an exact corpId:userId selector", selector)) + } + } + } + return selector, "", false, nil } - return "", apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector)) + return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector)) +} + +func authLoginHistorySelector(configDir string, profile *authpkg.Profile) string { + if profile == nil { + return "" + } + if cfg, err := authLoadProfiles(configDir); err == nil && cfg != nil { + return authpkg.ProfileSelectionSelector(*profile, cfg) + } + return authpkg.ProfileSelector(*profile) } type contactProfileIdentity struct { @@ -1258,12 +1334,23 @@ type contactProfileIdentity struct { UserName string } +type authLoginHistoryHint struct { + Selector string + Explicit bool +} + type tokenOverrideToolCaller interface { CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error) } -func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edition.ToolCaller, data *authpkg.TokenData) error { - if caller == nil || data == nil { +func enrichAuthLoginProfileFromContact( + ctx context.Context, + configDir string, + caller edition.ToolCaller, + data *authpkg.TokenData, + hints ...authLoginHistoryHint, +) error { + if data == nil { return nil } corpID := strings.TrimSpace(data.CorpID) @@ -1288,6 +1375,27 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi ) return nil } + hint := authLoginHistoryHint{} + if len(hints) > 0 { + hint = hints[0] + } + tryHistory := func() bool { + reused, historyErr := enrichAuthLoginProfileFromHistory(configDir, data, hint) + if historyErr != nil { + logging.AuthDebug( + "auth.login.identity.history.error", + "corp_id", corpID, + "error", historyErr, + ) + } + return reused + } + if caller == nil { + if strings.TrimSpace(data.UserID) == "" { + tryHistory() + } + return nil + } var ( result *edition.ToolResult @@ -1297,7 +1405,7 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi result, err = tokenCaller.CallToolWithToken(ctx, data.AccessToken, "contact", "get_current_user_profile", nil) } else { if strings.TrimSpace(data.UserID) == "" { - return fmt.Errorf("login identity lookup requires an in-memory token override") + tryHistory() } return nil } @@ -1311,11 +1419,15 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi if strings.TrimSpace(data.UserID) != "" { return nil } - return err + tryHistory() + return nil } - identity, ok := contactProfileIdentityFromToolResult(result) + identity, ok := contactProfileIdentityFromToolResult(result, corpID) if !ok { logging.AuthDebug("auth.login.identity.lookup.empty", "corp_id", corpID) + if strings.TrimSpace(data.UserID) == "" { + tryHistory() + } return nil } logging.AuthDebug( @@ -1327,19 +1439,42 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi "corp_name", strings.TrimSpace(identity.CorpName), ) if identity.CorpID != "" && identity.CorpID != corpID { - return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID) + logging.AuthDebug( + "auth.login.identity.lookup.mismatch", + "login_corp_id", corpID, + "contact_corp_id", strings.TrimSpace(identity.CorpID), + ) + if strings.TrimSpace(data.UserID) == "" { + tryHistory() + } + return nil } updated := *data + exchangeUserID := strings.TrimSpace(data.UserID) if identity.CorpName != "" { updated.CorpName = identity.CorpName } - if identity.UserID != "" { + if exchangeUserID == "" && identity.UserID != "" { updated.UserID = identity.UserID } - if identity.UserName != "" { + if identity.UserName != "" && (exchangeUserID == "" || identity.UserID == "" || identity.UserID == exchangeUserID) { updated.UserName = identity.UserName } + if strings.TrimSpace(updated.UserID) == "" { + reused, historyErr := enrichAuthLoginProfileFromHistory(configDir, &updated, hint) + if historyErr != nil { + logging.AuthDebug( + "auth.login.identity.history.error", + "corp_id", corpID, + "error", historyErr, + ) + } + if reused { + *data = updated + return nil + } + } if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName { logging.AuthDebug( "auth.login.identity.resolved", @@ -1361,7 +1496,140 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi return nil } -func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) { +// enrichAuthLoginProfileFromHistory recovers identity metadata when the +// contact service cannot describe an external-worker account. Explicit +// profile selection wins; otherwise only a sole same-corp profile is +// deterministic. +// Historical v1 profiles without userId are still useful: selecting one is a +// successful fallback that preserves org-scoped token storage semantics. +func enrichAuthLoginProfileFromHistory(configDir string, data *authpkg.TokenData, hints ...authLoginHistoryHint) (bool, error) { + if data == nil || strings.TrimSpace(data.UserID) != "" { + return false, nil + } + corpID := strings.TrimSpace(data.CorpID) + if corpID == "" { + return false, nil + } + cfg, err := authLoadProfiles(configDir) + if err != nil { + return false, err + } + if cfg == nil { + return false, nil + } + sameCorp := make([]*authpkg.Profile, 0, len(cfg.Profiles)) + for i := range cfg.Profiles { + profile := &cfg.Profiles[i] + if strings.TrimSpace(profile.CorpID) != corpID { + continue + } + sameCorp = append(sameCorp, profile) + } + if len(sameCorp) == 0 { + return false, nil + } + + hint := authLoginHistoryHint{} + if len(hints) > 0 { + hint = hints[0] + } + var candidate *authpkg.Profile + if hint.Explicit { + candidate = historicalProfileForSelector(corpID, hint.Selector, sameCorp) + if candidate == nil { + // An explicit account is a hard identity boundary. If that exact + // historical hint no longer matches the token's organization, do + // not silently substitute org-current, sole, or global-current. + return false, nil + } + } else if len(sameCorp) > 1 { + // Organization-current is a storage preference, not proof of which user + // completed a fresh authorization. With multiple accounts, only an exact + // user selection may be used when the token/contact response has no UID. + return false, nil + } else { + candidate = sameCorp[0] + } + + updated := *data + updated.UserID = strings.TrimSpace(candidate.UserID) + if updated.UserID == "" && hint.Explicit { + updated.LegacyOrgScopedProfile = strings.TrimSpace(hint.Selector) + } + if strings.TrimSpace(updated.CorpName) == "" { + updated.CorpName = strings.TrimSpace(candidate.CorpName) + } + if strings.TrimSpace(updated.UserName) == "" { + updated.UserName = strings.TrimSpace(candidate.UserName) + } + *data = updated + logging.AuthDebug( + "auth.login.identity.resolved", + "source", "local_profile_history", + "corp_id", corpID, + "user_id", updated.UserID, + "user_name", updated.UserName, + "corp_name", updated.CorpName, + ) + return true, nil +} + +func historicalProfileForSelector(corpID, selector string, profiles []*authpkg.Profile) *authpkg.Profile { + selector = strings.TrimSpace(selector) + if selector == "" { + return nil + } + cfg := &authpkg.ProfilesConfig{Profiles: make([]authpkg.Profile, 0, len(profiles))} + for _, profile := range profiles { + if profile != nil { + cfg.Profiles = append(cfg.Profiles, *profile) + } + } + var stableMatch *authpkg.Profile + for _, profile := range profiles { + if profile == nil || strings.TrimSpace(profile.CorpID) != strings.TrimSpace(corpID) || + authpkg.ProfileSelectionSelector(*profile, cfg) != selector { + continue + } + if stableMatch != nil { + return nil + } + stableMatch = profile + } + if stableMatch != nil { + return stableMatch + } + if selectedCorpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact { + if strings.TrimSpace(selectedCorpID) != strings.TrimSpace(corpID) { + return nil + } + for _, profile := range profiles { + if profile != nil && strings.TrimSpace(profile.UserID) == strings.TrimSpace(userID) { + return profile + } + } + return nil + } + var named *authpkg.Profile + for _, profile := range profiles { + if profile == nil || strings.TrimSpace(profile.Name) != selector { + continue + } + if named != nil { + return nil + } + named = profile + } + if named != nil { + return named + } + if selector == strings.TrimSpace(corpID) && len(profiles) == 1 { + return profiles[0] + } + return nil +} + +func contactProfileIdentityFromToolResult(result *edition.ToolResult, expectedCorpIDs ...string) (contactProfileIdentity, bool) { if result == nil { return contactProfileIdentity{}, false } @@ -1369,14 +1637,14 @@ func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactPr if strings.TrimSpace(block.Text) == "" { continue } - if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok { + if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text), expectedCorpIDs...); ok { return identity, true } } return contactProfileIdentity{}, false } -func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) { +func contactProfileIdentityFromJSON(data []byte, expectedCorpIDs ...string) (contactProfileIdentity, bool) { var payload struct { Result []struct { OrgEmployeeModel struct { @@ -1396,14 +1664,40 @@ func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) if len(payload.Result) == 0 { return contactProfileIdentity{}, false } - org := payload.Result[0].OrgEmployeeModel - identity := contactProfileIdentity{ - CorpID: strings.TrimSpace(org.CorpID), - CorpName: strings.TrimSpace(org.OrgName), - UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID), - UserName: firstNonEmptyString(org.OrgUserName, org.Name), + identities := make([]contactProfileIdentity, 0, len(payload.Result)) + for i := range payload.Result { + org := payload.Result[i].OrgEmployeeModel + identity := contactProfileIdentity{ + CorpID: strings.TrimSpace(org.CorpID), + CorpName: strings.TrimSpace(org.OrgName), + UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID), + UserName: firstNonEmptyString(org.OrgUserName, org.Name), + } + if identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != "" { + identities = append(identities, identity) + } } - return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != "" + if len(identities) == 0 { + return contactProfileIdentity{}, false + } + expectedCorpID := "" + if len(expectedCorpIDs) > 0 { + expectedCorpID = strings.TrimSpace(expectedCorpIDs[0]) + } + if expectedCorpID != "" { + for _, identity := range identities { + if identity.CorpID == expectedCorpID { + return identity, true + } + } + // Older contact responses omit corpId. A single result is still + // unambiguous; multiple organization records without a target match + // must fall back to local history instead of choosing result[0]. + if len(payload.Result) != 1 { + return contactProfileIdentity{}, false + } + } + return identities[0], true } func firstNonEmptyString(values ...string) string { diff --git a/internal/app/auth_command_full_coverage_test.go b/internal/app/auth_command_full_coverage_test.go index ce7dd9ca..e469498c 100644 --- a/internal/app/auth_command_full_coverage_test.go +++ b/internal/app/auth_command_full_coverage_test.go @@ -262,7 +262,10 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true"}); err == nil { t.Fatal("device error should propagate") } - authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) { + authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) { + if provider.IdentityEnricher == nil { + t.Error("device login missing shared identity enricher") + } return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil } if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil { @@ -275,7 +278,10 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { if _, _, err := authCoverageRunLogin(t, nil, "table", true, nil); err == nil { t.Fatal("oauth error should propagate") } - authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) { + authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) { + if provider.IdentityEnricher == nil { + t.Error("OAuth login missing shared identity enricher") + } return &authpkg.TokenData{ AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour), RefreshToken: "r", RefreshExpAt: time.Now().Add(48 * time.Hour), CorpName: "Corp", CorpID: "ding1", UserName: "User", UserID: "u", @@ -356,8 +362,8 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) { if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, complete); err != nil { t.Fatal(err) } - if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err == nil { - t.Fatal("caller error should propagate") + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err != nil { + t.Fatalf("contact failure must remain best effort: %v", err) } if err := enrichAuthLoginProfileFromContact( ctx, @@ -374,8 +380,8 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) { } } mismatch := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other"}}]}`}}}} - if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); err == nil { - t.Fatal("corp mismatch should fail") + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); err != nil { + t.Fatalf("contact corp mismatch must remain best effort: %v", err) } same := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u","name":"User"}}]}`}}}} if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, complete); err != nil { @@ -390,6 +396,25 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) { if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, data); err != nil || data.CorpName != "Corp" || data.UserID != "u" { t.Fatalf("enriched = %#v, %v", data, err) } + known := &authpkg.TokenData{CorpID: "ding", UserID: "exchange-user", AccessToken: "token"} + differentContactUser := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"other-user","name":"Other User"}}]}`}}}} + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", differentContactUser, known); err != nil { + t.Fatal(err) + } + if known.UserID != "exchange-user" || known.UserName != "" || known.CorpName != "Corp" { + t.Fatalf("token-exchange identity was overwritten: %#v", known) + } + multiOrg := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other","userid":"other-user"}},{"orgEmployeeModel":{"corpId":"ding","orgName":"Target Corp","userid":"target-user","name":"Target User"}}]}`}}}} + multiOrgData := &authpkg.TokenData{CorpID: "ding", AccessToken: "token"} + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", multiOrg, multiOrgData); err != nil || multiOrgData.UserID != "target-user" || multiOrgData.CorpName != "Target Corp" { + t.Fatalf("multi-org contact selection = %#v, %v", multiOrgData, err) + } + if _, ok := contactProfileIdentityFromJSON( + []byte(`{"result":[{"orgEmployeeModel":{"corpId":"other-a","userid":"user-a"}},{"orgEmployeeModel":{"corpId":"other-b","userid":"user-b"}}]}`), + "ding", + ); ok { + t.Fatal("multiple nonmatching organizations must not select an arbitrary contact identity") + } if _, ok := contactProfileIdentityFromToolResult(nil); ok { t.Fatal("nil result should not parse") } @@ -398,6 +423,504 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) { } } +func TestCrossPlatformCoverageContactFailureReusesUniqueSameCorpHistoricalIdentity(t *testing.T) { + configDir := t.TempDir() + if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{ + Version: 1, + Profiles: []authpkg.Profile{{ + CorpID: "ding_ecological_worker", + CorpName: "Historical Corp", + UserID: "external-user", + UserName: "Historical Worker", + }}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + for _, tc := range []struct { + name string + caller edition.ToolCaller + wantCorp string + }{ + { + name: "contact business error", + caller: &authCoverageCaller{err: apperrors.NewAPI( + "business error: success=false", + apperrors.WithReason("business_error"), + )}, + wantCorp: "Fresh Corp", + }, + { + name: "contact has no identity", + caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"success":false}`}}}}, + wantCorp: "Fresh Corp", + }, + { + name: "contact identity is missing user id", + caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{ + Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_ecological_worker","orgName":"Contact Corp"}}]}`, + }}}}, + wantCorp: "Contact Corp", + }, + { + name: "ordinary contact error", + caller: &authCoverageCaller{err: errors.New("network failure")}, + wantCorp: "Fresh Corp", + }, + { + name: "other contact business error", + caller: &authCoverageCaller{err: apperrors.NewAPI( + "permission denied", + apperrors.WithReason("business_error"), + )}, + wantCorp: "Fresh Corp", + }, + { + name: "contact caller unavailable", + caller: nil, + wantCorp: "Fresh Corp", + }, + { + name: "contact returns another organization", + caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{ + Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_other","userid":"other-user"}}]}`, + }}}}, + wantCorp: "Fresh Corp", + }, + } { + t.Run(tc.name, func(t *testing.T) { + data := &authpkg.TokenData{ + AccessToken: "new-access", + RefreshToken: "new-refresh", + CorpID: "ding_ecological_worker", + CorpName: "Fresh Corp", + } + if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, tc.caller, data); err != nil { + t.Fatalf("contact failure blocked historical identity recovery: %v", err) + } + if data.UserID != "external-user" || data.UserName != "Historical Worker" { + t.Fatalf("recovered identity = %#v", data) + } + if data.CorpName != tc.wantCorp { + t.Fatalf("corp name = %q, want %q", data.CorpName, tc.wantCorp) + } + if data.AccessToken != "new-access" || data.RefreshToken != "new-refresh" { + t.Fatalf("new token material was changed: %#v", data) + } + }) + } +} + +func TestCrossPlatformCoverageContactFailureDoesNotGuessHistoricalIdentity(t *testing.T) { + businessErr := apperrors.NewAPI( + "business error: success=false", + apperrors.WithReason("business_error"), + ) + for _, tc := range []struct { + name string + corpID string + profiles []authpkg.Profile + callErr error + }{ + { + name: "same corp has two identities", + corpID: "ding_ecological_worker", + profiles: []authpkg.Profile{ + {CorpID: "ding_ecological_worker", UserID: "external-user"}, + {CorpID: "ding_ecological_worker", UserID: "external-user-b"}, + }, + callErr: businessErr, + }, + { + name: "same corp has one identity and one blank profile", + corpID: "ding_ecological_worker", + profiles: []authpkg.Profile{ + {CorpID: "ding_ecological_worker", UserID: "external-user"}, + {CorpID: "ding_ecological_worker"}, + }, + callErr: businessErr, + }, + { + name: "identity belongs to another corp", + corpID: "ding_ecological_worker", + profiles: []authpkg.Profile{ + {CorpID: "ding_other", UserID: "external-user"}, + }, + callErr: businessErr, + }, + { + name: "no historical identity", + corpID: "ding_ecological_worker", + callErr: businessErr, + }, + } { + t.Run(tc.name, func(t *testing.T) { + configDir := t.TempDir() + if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{Version: 2, Profiles: tc.profiles}); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + data := &authpkg.TokenData{AccessToken: "new-access", CorpID: tc.corpID} + err := enrichAuthLoginProfileFromContact( + context.Background(), + configDir, + &authCoverageCaller{err: tc.callErr}, + data, + ) + if err != nil { + t.Fatalf("contact failure must not block unresolved legacy login: %v", err) + } + if data.UserID != "" { + t.Fatalf("ambiguous/cross-corp identity was reused: %#v", data) + } + }) + } +} + +func TestCrossPlatformCoverageContactHistoryFallbackEdges(t *testing.T) { + for _, data := range []*authpkg.TokenData{ + nil, + {UserID: "known"}, + {}, + } { + reused, err := enrichAuthLoginProfileFromHistory(t.TempDir(), data) + if reused || err != nil { + t.Fatalf("ineligible history fallback = %v, %v", reused, err) + } + } + + configDir := t.TempDir() + if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{ + Version: 2, + Profiles: []authpkg.Profile{{ + CorpID: "ding_external", + CorpName: "Historical Corp", + UserID: "external-user", + UserName: "Historical Worker", + }}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + data := &authpkg.TokenData{CorpID: "ding_external"} + reused, err := enrichAuthLoginProfileFromHistory(configDir, data) + if err != nil || !reused { + t.Fatalf("history fallback = %v, %v", reused, err) + } + if data.CorpName != "Historical Corp" || data.UserName != "Historical Worker" || data.UserID != "external-user" { + t.Fatalf("history metadata = %#v", data) + } + + corruptDir := t.TempDir() + if err := os.Mkdir(authpkg.ProfilesPath(corruptDir), 0o700); err != nil { + t.Fatalf("create unreadable profiles path: %v", err) + } + if reused, err := enrichAuthLoginProfileFromHistory(corruptDir, &authpkg.TokenData{CorpID: "ding_external"}); reused || err == nil { + t.Fatalf("corrupt history fallback = %v, %v; want load error", reused, err) + } + + businessErr := apperrors.NewAPI( + "business error: success=false", + apperrors.WithReason("business_error"), + ) + for _, tc := range []struct { + name string + caller *authCoverageCaller + }{ + { + name: "contact business error", + caller: &authCoverageCaller{err: businessErr}, + }, + { + name: "contact has no identity", + caller: &authCoverageCaller{result: &edition.ToolResult{}}, + }, + { + name: "contact identity is missing user id", + caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{ + Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_external"}}]}`, + }}}}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := enrichAuthLoginProfileFromContact( + context.Background(), + corruptDir, + tc.caller, + &authpkg.TokenData{CorpID: "ding_external", AccessToken: "new-access"}, + ) + if err != nil { + t.Fatalf("best-effort contact/history lookup blocked login: %v", err) + } + }) + } +} + +func TestCrossPlatformCoverageAuthLoginConfigPreservesHistoryIdentityHint(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + oldResolve := authResolveProfile + oldLoad := authLoadProfiles + t.Cleanup(func() { + authResolveProfile = oldResolve + authLoadProfiles = oldLoad + }) + + explicit := &authpkg.Profile{CorpID: "ding_same", UserID: "user_2", Name: "second"} + current := &authpkg.Profile{CorpID: "ding_current", UserID: "current_user"} + authResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) { + switch selector { + case "ding_same:user_2": + clone := *explicit + return &clone, nil + case "external-worker": + return &authpkg.Profile{Name: "external-worker", CorpID: "ding_external"}, nil + case "": + clone := *current + return &clone, nil + default: + return nil, errors.New("missing") + } + } + authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { + return &authpkg.ProfilesConfig{}, nil + } + + cmd := newAuthLoginCommand(nil) + root, _, _ := authCoverageRoot(cmd, "table", true) + if err := root.PersistentFlags().Set("profile", "ding_same:user_2"); err != nil { + t.Fatal(err) + } + cfg, err := resolveAuthLoginConfig(cmd) + if err != nil { + t.Fatal(err) + } + if cfg.TargetCorpID != "ding_same" || cfg.HistoryProfileSelector != "ding_same:user_2" || !cfg.HistoryProfileSelectorExplicit { + t.Fatalf("explicit login config = %#v", cfg) + } + if target, hint, exact, err := resolveAuthLoginTarget("cfg", "external-worker"); err != nil || + target != "ding_external" || hint != "ding_external" || !exact { + t.Fatalf("blank-userId profile target = %q/%q/%v, %v", target, hint, exact, err) + } + + if err := root.PersistentFlags().Set("profile", ""); err != nil { + t.Fatal(err) + } + cfg, err = resolveAuthLoginConfig(cmd) + if err != nil { + t.Fatal(err) + } + if cfg.TargetCorpID != "" || cfg.HistoryProfileSelector != "ding_current:current_user" || cfg.HistoryProfileSelectorExplicit { + t.Fatalf("implicit login config constrained authorization target: %#v", cfg) + } + + if _, _, _, err := resolveAuthLoginTarget("cfg", "ding_same:missing"); err == nil { + t.Fatal("missing exact profile must not be reinterpreted as a corpId") + } + if target, hint, explicitHint, err := resolveAuthLoginTarget("cfg", "ding_new"); err != nil || target != "ding_new" || hint != "" || explicitHint { + t.Fatalf("new organization target = %q/%q/%v, %v", target, hint, explicitHint, err) + } + authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { + return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{ + {CorpID: "ding_ambiguous", UserID: "user_1"}, + {CorpID: "ding_ambiguous", UserID: "user_2"}, + }}, nil + } + if _, _, _, err := resolveAuthLoginTarget("cfg", "ding_ambiguous"); err == nil { + t.Fatal("ambiguous known organization must require an exact profile") + } +} + +func TestCrossPlatformCoverageOAuthAndDeviceApplyExactHistoryHint(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + oldResolve := authResolveProfile + oldLoad := authLoadProfiles + oldDevice := authDeviceLogin + oldOAuth := authOAuthLogin + oldInteractive := authLoginInteractiveTerminal + t.Cleanup(func() { + authResolveProfile = oldResolve + authLoadProfiles = oldLoad + authDeviceLogin = oldDevice + authOAuthLogin = oldOAuth + authLoginInteractiveTerminal = oldInteractive + }) + + selected := authpkg.Profile{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"} + authResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) { + if selector != "ding_same:user_2" { + return nil, errors.New("missing") + } + clone := selected + return &clone, nil + } + authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { + return &authpkg.ProfilesConfig{ + CurrentProfile: "ding_same:user_1", + OrgCurrentProfiles: map[string]string{ + "ding_same": "ding_same:user_1", + }, + Profiles: []authpkg.Profile{ + {CorpID: "ding_same", UserID: "user_1", UserName: "First"}, + selected, + }, + }, nil + } + authLoginInteractiveTerminal = func() bool { return false } + caller := &authCoverageCaller{err: errors.New("contact unavailable")} + newToken := func() *authpkg.TokenData { + return &authpkg.TokenData{ + AccessToken: "new-access", + CorpID: "ding_same", + ExpiresAt: time.Now().Add(time.Hour), + } + } + deviceApplied := false + authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) { + data := newToken() + if provider.IdentityEnricher == nil { + return nil, errors.New("missing device identity enricher") + } + if err := provider.IdentityEnricher(ctx, data); err != nil { + return nil, err + } + deviceApplied = data.UserID == "user_2" + return data, nil + } + if _, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{ + "device": "true", + "profile": "ding_same:user_2", + }); err != nil || !deviceApplied { + t.Fatalf("device exact history hint applied=%v err=%v", deviceApplied, err) + } + + oauthApplied := false + authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, _ bool) (*authpkg.TokenData, error) { + if provider.TargetCorpID != "ding_same" { + return nil, fmt.Errorf("OAuth target corp = %q", provider.TargetCorpID) + } + data := newToken() + if provider.IdentityEnricher == nil { + return nil, errors.New("missing OAuth identity enricher") + } + if err := provider.IdentityEnricher(ctx, data); err != nil { + return nil, err + } + oauthApplied = data.UserID == "user_2" + return data, nil + } + if _, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{ + "profile": "ding_same:user_2", + }); err != nil || !oauthApplied { + t.Fatalf("OAuth exact history hint applied=%v err=%v", oauthApplied, err) + } +} + +func TestCrossPlatformCoverageHistoricalIdentityPriorityAndBlankUserID(t *testing.T) { + oldLoad := authLoadProfiles + t.Cleanup(func() { authLoadProfiles = oldLoad }) + authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil } + if reused, err := enrichAuthLoginProfileFromHistory("cfg", &authpkg.TokenData{CorpID: "ding_same"}); reused || err != nil { + t.Fatalf("nil history registry = reused=%v err=%v", reused, err) + } + + cfg := &authpkg.ProfilesConfig{ + CurrentProfile: "ding_same:user_1", + OrgCurrentProfiles: map[string]string{ + "ding_same": "ding_same:user_2", + }, + Profiles: []authpkg.Profile{ + {CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_1", UserName: "First"}, + {CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"}, + }, + } + authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil } + + explicitData := &authpkg.TokenData{CorpID: "ding_same"} + reused, err := enrichAuthLoginProfileFromHistory("cfg", explicitData, authLoginHistoryHint{Selector: "ding_same:user_1", Explicit: true}) + if err != nil || !reused || explicitData.UserID != "user_1" { + t.Fatalf("explicit history selection = %#v, reused=%v err=%v", explicitData, reused, err) + } + mismatchedHintData := &authpkg.TokenData{CorpID: "ding_same"} + reused, err = enrichAuthLoginProfileFromHistory("cfg", mismatchedHintData, authLoginHistoryHint{Selector: "ding_other:user_9", Explicit: true}) + if err != nil || reused || mismatchedHintData.UserID != "" { + t.Fatalf("cross-corp explicit hint reused another identity: %#v, reused=%v err=%v", mismatchedHintData, reused, err) + } + orgCurrentData := &authpkg.TokenData{CorpID: "ding_same"} + reused, err = enrichAuthLoginProfileFromHistory("cfg", orgCurrentData) + if err != nil || reused || orgCurrentData.UserID != "" { + t.Fatalf("implicit multi-account org-current was treated as identity proof: %#v, reused=%v err=%v", orgCurrentData, reused, err) + } + + cfg.Profiles = []authpkg.Profile{cfg.Profiles[1]} + soleData := &authpkg.TokenData{CorpID: "ding_same"} + reused, err = enrichAuthLoginProfileFromHistory("cfg", soleData) + if err != nil || !reused || soleData.UserID != "user_2" { + t.Fatalf("sole history selection = %#v, reused=%v err=%v", soleData, reused, err) + } + + cfg.Profiles = []authpkg.Profile{ + {CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_1", UserName: "First"}, + {CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"}, + } + cfg.OrgCurrentProfiles = nil + currentData := &authpkg.TokenData{CorpID: "ding_same"} + reused, err = enrichAuthLoginProfileFromHistory("cfg", currentData) + if err != nil || reused || currentData.UserID != "" { + t.Fatalf("implicit multi-account current was treated as identity proof: %#v, reused=%v err=%v", currentData, reused, err) + } + + cfg.CurrentProfile = "" + ambiguousData := &authpkg.TokenData{CorpID: "ding_same"} + reused, err = enrichAuthLoginProfileFromHistory("cfg", ambiguousData) + if err != nil || reused || ambiguousData.UserID != "" { + t.Fatalf("ambiguous history selection = %#v, reused=%v err=%v", ambiguousData, reused, err) + } + + cfg.Profiles = []authpkg.Profile{{ + Name: "external-worker", CorpID: "ding_same", CorpName: "Legacy Corp", UserName: "Legacy Worker", + }} + blankData := &authpkg.TokenData{CorpID: "ding_same"} + reused, err = enrichAuthLoginProfileFromHistory("cfg", blankData, authLoginHistoryHint{Selector: "external-worker", Explicit: true}) + if err != nil || !reused || blankData.UserID != "" || blankData.LegacyOrgScopedProfile != "external-worker" || blankData.CorpName != "Legacy Corp" || blankData.UserName != "Legacy Worker" { + t.Fatalf("blank-userId history selection = %#v, reused=%v err=%v", blankData, reused, err) + } + contactBlankData := &authpkg.TokenData{CorpID: "ding_same", AccessToken: "new-token"} + if err := enrichAuthLoginProfileFromContact( + context.Background(), + "cfg", + &authCoverageCaller{err: errors.New("contact unavailable")}, + contactBlankData, + authLoginHistoryHint{Selector: "external-worker", Explicit: true}, + ); err != nil { + t.Fatalf("blank-userId history must keep contact best effort: %v", err) + } + if contactBlankData.LegacyOrgScopedProfile != "external-worker" { + t.Fatalf("blank-userId contact fallback did not authorize the historical organization slot: %#v", contactBlankData) + } + + profiles := []*authpkg.Profile{ + nil, + {Name: "duplicate", CorpID: "ding_same", UserID: "user_1"}, + {Name: "duplicate", CorpID: "ding_same", UserID: "user_2"}, + } + for _, tc := range []struct { + name string + selector string + profiles []*authpkg.Profile + want *authpkg.Profile + }{ + {name: "empty selector", selector: "", profiles: profiles}, + {name: "missing exact identity", selector: "ding_same:missing", profiles: profiles}, + {name: "duplicate name", selector: "duplicate", profiles: profiles}, + {name: "unmatched name", selector: "not-found", profiles: profiles}, + {name: "sole organization selector", selector: "ding_same", profiles: profiles[1:2], want: profiles[1]}, + } { + t.Run("selector "+tc.name, func(t *testing.T) { + if got := historicalProfileForSelector("ding_same", tc.selector, tc.profiles); got != tc.want { + t.Fatalf("historicalProfileForSelector(%q) = %#v, want %#v", tc.selector, got, tc.want) + } + }) + } +} + func TestCrossPlatformCoverageAuthCoverageDefaultSeamClosures(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -748,7 +1271,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T) if err := importCmd.RunE(badForce, nil); err == nil { t.Fatal("invalid force flag should fail") } - _, out, _ = authCoverageRoot(importCmd, "table", false) + _, _, _ = authCoverageRoot(importCmd, "table", false) if err := importCmd.RunE(importCmd, nil); err == nil { t.Fatal("missing input should fail") } diff --git a/internal/app/auth_legacy_platform_coverage_test.go b/internal/app/auth_legacy_platform_coverage_test.go new file mode 100644 index 00000000..8aa8a3f8 --- /dev/null +++ b/internal/app/auth_legacy_platform_coverage_test.go @@ -0,0 +1,307 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "errors" + "io" + "strings" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func TestCrossPlatformCoverageAuthLoginUsesStableBlankProfileForPostLoginAuthorization(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + oldOAuth := authOAuthLogin + oldLoadProfiles := authLoadProfiles + oldRecommend := authRunLoginRecommend + oldInteractive := authLoginInteractiveTerminal + oldResolve := authResolveProfile + t.Cleanup(func() { + authOAuthLogin = oldOAuth + authLoadProfiles = oldLoadProfiles + authRunLoginRecommend = oldRecommend + authLoginInteractiveTerminal = oldInteractive + authResolveProfile = oldResolve + }) + + const corpID = "corp_post_login_blank" + cfg := &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{ + {Name: "Fixture Organization", CorpID: corpID, CorpName: "Fixture Organization"}, + {Name: "Exact Fixture", CorpID: corpID, CorpName: "Fixture Organization", UserID: "identity_exact"}, + }} + wantSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg) + if wantSelector == "" || wantSelector == corpID { + t.Fatalf("blank selector = %q, want a stable account selector", wantSelector) + } + authResolveProfile = func(string, string) (*authpkg.Profile, error) { + return nil, errors.New("no implicit profile") + } + authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil } + authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) { + return &authpkg.TokenData{ + AccessToken: "new-access", + ExpiresAt: time.Now().Add(time.Hour), + CorpID: corpID, + }, nil + } + authLoginInteractiveTerminal = func() bool { return false } + seenSelector := "" + authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error { + seenSelector = authpkg.RuntimeProfile() + return nil + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"recommend": "true"}); err != nil { + t.Fatalf("blank-profile login error = %v", err) + } + if seenSelector != wantSelector { + t.Fatalf("post-login runtime selector = %q, want %q", seenSelector, wantSelector) + } +} + +func TestCrossPlatformCoverageAuthStatusAndLogoutPreserveExactSelectors(t *testing.T) { + t.Run("status canonicalizes a known identity", func(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + const exactSelector = "corp_status_fixture:identity_status_fixture" + if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{ + Version: 2, + Profiles: []authpkg.Profile{{ + Name: "Status Fixture", + CorpID: "corp_status_fixture", + UserID: "identity_status_fixture", + }}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + oldStatus := authOAuthStatus + t.Cleanup(func() { authOAuthStatus = oldStatus }) + seenSelector := "" + authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { + seenSelector = authpkg.RuntimeProfile() + return &authpkg.TokenData{ + AccessToken: "access", + ExpiresAt: time.Now().Add(time.Hour), + CorpID: "corp_status_fixture", + UserID: "identity_status_fixture", + }, nil + } + cmd := newAuthStatusCommand() + _, _, _ = authCoverageRoot(cmd, "table", false) + if err := cmd.Flags().Set("profile", " Status Fixture "); err != nil { + t.Fatal(err) + } + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("auth status error = %v", err) + } + if seenSelector != exactSelector { + t.Fatalf("status runtime selector = %q, want %q", seenSelector, exactSelector) + } + }) + + t.Run("logout keeps a blank local selector", func(t *testing.T) { + oldResolve := authResolveProfileDeletion + oldLoad := authLoadTokenForProfile + oldRevoke := authRevokeTokenForData + oldDelete := authDeleteProfileToken + t.Cleanup(func() { + authResolveProfileDeletion = oldResolve + authLoadTokenForProfile = oldLoad + authRevokeTokenForData = oldRevoke + authDeleteProfileToken = oldDelete + }) + + const selector = "legacy-external-worker" + authResolveProfileDeletion = func(string, string) (*authpkg.Profile, bool, error) { + return &authpkg.Profile{CorpID: "corp_logout_blank"}, true, nil + } + loadedSelector := "" + authLoadTokenForProfile = func(_ string, got string) (*authpkg.TokenData, error) { + loadedSelector = got + return &authpkg.TokenData{CorpID: "corp_logout_blank"}, nil + } + authRevokeTokenForData = func(context.Context, *authpkg.TokenData) error { return nil } + deletedSelector := "" + authDeleteProfileToken = func(_ string, got string) error { + deletedSelector = got + return nil + } + + if err := logoutOneProfile(nil, context.Background(), "cfg", " "+selector+" "); err != nil { + t.Fatalf("logoutOneProfile() error = %v", err) + } + if loadedSelector != selector || deletedSelector != selector { + t.Fatalf("blank logout selectors = load %q delete %q, want %q", loadedSelector, deletedSelector, selector) + } + }) +} + +func TestCrossPlatformCoverageAuthHistorySelectorRemainingBranches(t *testing.T) { + if got := authLoginHistorySelector("cfg", nil); got != "" { + t.Fatalf("nil history selector = %q", got) + } + + oldLoad := authLoadProfiles + t.Cleanup(func() { authLoadProfiles = oldLoad }) + authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { + return nil, errors.New("profiles unavailable") + } + profile := &authpkg.Profile{CorpID: "corp_history", UserID: "identity_history"} + if got := authLoginHistorySelector("cfg", profile); got != "corp_history:identity_history" { + t.Fatalf("history selector fallback = %q", got) + } + + duplicateA := &authpkg.Profile{CorpID: "corp_history", UserID: "duplicate_identity"} + duplicateB := &authpkg.Profile{CorpID: "corp_history", UserID: "duplicate_identity"} + if got := historicalProfileForSelector( + "corp_history", + "corp_history:duplicate_identity", + []*authpkg.Profile{duplicateA, duplicateB}, + ); got != nil { + t.Fatalf("duplicate stable identity selected %#v", got) + } + + // Whitespace keeps the raw selector from matching the stable string while + // ParseIdentitySelector still resolves its components. + exactFallback := &authpkg.Profile{CorpID: "corp_history", UserID: "fallback_identity"} + if got := historicalProfileForSelector( + "corp_history", + "corp_history : fallback_identity", + []*authpkg.Profile{exactFallback}, + ); got != exactFallback { + t.Fatalf("exact history fallback = %#v, want %#v", got, exactFallback) + } +} + +func TestCrossPlatformCoverageProfileSwitchLegacyBlankAndNormalizedIdentityPointers(t *testing.T) { + t.Run("one legacy blank name", func(t *testing.T) { + profiles := []authpkg.Profile{ + {Name: "Fixture Organization", CorpID: "corp_profile_fixture", CorpName: "Fixture Organization"}, + {Name: "Exact Fixture", CorpID: "corp_profile_fixture", CorpName: "Fixture Organization", UserID: "identity_exact"}, + } + cfg := &authpkg.ProfilesConfig{Profiles: profiles} + if got := profileSwitchProfileIndex(profiles, "Fixture Organization", cfg); got != 0 { + t.Fatalf("legacy blank profile index = %d, want 0", got) + } + }) + + t.Run("duplicate legacy names fall through to blank-name compatibility", func(t *testing.T) { + profiles := []authpkg.Profile{ + {Name: "duplicate-legacy", CorpID: "corp_profile_fixture"}, + {Name: "duplicate-legacy", CorpID: "corp_profile_fixture"}, + } + cfg := &authpkg.ProfilesConfig{Profiles: profiles} + if got := profileSwitchProfileIndex(profiles, "duplicate-legacy", cfg); got != 0 { + t.Fatalf("duplicate legacy fallback index = %d, want 0", got) + } + }) + + t.Run("normalized exact identity", func(t *testing.T) { + profiles := []authpkg.Profile{{CorpID: "corp_profile_fixture", UserID: "identity_exact"}} + cfg := &authpkg.ProfilesConfig{Profiles: profiles} + if got := profileSwitchProfileIndex(profiles, "corp_profile_fixture : identity_exact", cfg); got != 0 { + t.Fatalf("normalized exact profile index = %d, want 0", got) + } + if got := profileSwitchProfileIndex(profiles, "corp_profile_fixture : missing", cfg); got != -1 { + t.Fatalf("missing normalized exact profile index = %d, want -1", got) + } + }) +} + +func TestCrossPlatformCoverageRuntimeRunnerPreservesBlankSelectorInSingleAndMultiRuns(t *testing.T) { + exact := authLogoutTestToken("corp_runner_blank") + exact.UserID = "identity_exact_runner" + other := authLogoutTestToken("corp_runner_other") + configDir := setupAuthLogoutProfiles(t, exact, other) + blank := authLogoutTestToken("corp_runner_blank") + blank.AccessToken = "access-unresolved-runner" + blank.RefreshToken = "refresh-unresolved-runner" + blank.UserID = "" + blank.UserName = "" + if err := authpkg.SaveTokenData(configDir, blank); err != nil { + t.Fatalf("SaveTokenData(blank) error = %v", err) + } + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + blankSelector := "" + for _, profile := range cfg.Profiles { + if profile.CorpID == blank.CorpID && profile.UserID == "" { + blankSelector = authpkg.ProfileSelectionSelector(profile, cfg) + break + } + } + if blankSelector == "" || blankSelector == blank.CorpID { + t.Fatalf("blank runner selector = %q, want exact local selector", blankSelector) + } + + runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}} + invocation := executor.Invocation{ + Kind: "helper_invocation", + CanonicalProduct: "contact", + Tool: "get_current_user_profile", + } + authpkg.SetRuntimeProfile(blankSelector) + result, err := runner.Run(context.Background(), invocation) + if err != nil { + t.Fatalf("single blank Run() error = %v", err) + } + content := result.Response["content"].(map[string]any) + if got := content["runtimeProfile"]; got != blankSelector { + t.Fatalf("single blank runtime profile = %#v, want %q", got, blankSelector) + } + if got := authpkg.RuntimeProfile(); got != blankSelector { + t.Fatalf("single blank runtime restoration = %q, want %q", got, blankSelector) + } + + authpkg.SetRuntimeProfile(blankSelector + ",corp_runner_other") + result, err = runner.Run(context.Background(), invocation) + if err != nil { + t.Fatalf("multi blank Run() error = %v", err) + } + entries := result.Response["content"].(map[string]any)["profiles"].([]any) + if len(entries) != 2 { + t.Fatalf("multi blank profiles = %#v, want two", entries) + } + first := entries[0].(map[string]any) + if first["selector"] != blankSelector || first["profile"] != blankSelector || first["userId"] != "" { + t.Fatalf("multi blank first entry = %#v", first) + } +} + +func TestCrossPlatformCoveragePersonalBusSelectorCanonicalFallback(t *testing.T) { + authpkg.SetRuntimeProfile("") + t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) + identity := personal.Identity{ + CorpID: "corp_event_fallback", + UserID: "identity_event_fallback", + SourceID: "open", + } + if got := personalBusProfileSelector(t.TempDir(), identity); got != "corp_event_fallback:identity_event_fallback" { + t.Fatalf("personal bus fallback selector = %q", got) + } + args := personalBusSpawnArgs(identity, "", "", " ") + if got := strings.Join(args, " "); !strings.Contains(got, "--profile corp_event_fallback:identity_event_fallback") { + t.Fatalf("personal bus default profile args = %q", got) + } +} diff --git a/internal/app/blank_profile_selector_regression_test.go b/internal/app/blank_profile_selector_regression_test.go new file mode 100644 index 00000000..632fb696 --- /dev/null +++ b/internal/app/blank_profile_selector_regression_test.go @@ -0,0 +1,144 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" +) + +func blankProfileSelectorAppFixture(blankName, corpName string) *authpkg.ProfilesConfig { + const ( + corpID = "corp_selector_fixture" + exactUserID = "identity_exact_fixture" + ) + exactSelector := corpID + ":" + exactUserID + cfg := &authpkg.ProfilesConfig{ + Version: 2, + PrimaryProfile: exactSelector, + PreviousProfile: exactSelector, + OrgCurrentProfiles: map[string]string{ + corpID: exactSelector, + }, + Profiles: []authpkg.Profile{ + { + Name: "Exact Fixture Account", + CorpID: corpID, + CorpName: corpName, + UserID: exactUserID, + UserName: "Exact Fixture Account", + Status: authpkg.ProfileStatusActive, + }, + { + Name: blankName, + CorpID: corpID, + CorpName: corpName, + Status: authpkg.ProfileStatusActive, + }, + }, + } + cfg.CurrentProfile = authpkg.ProfileSelectionSelector(cfg.Profiles[1], cfg) + return cfg +} + +func captureProfileListSelectors(t *testing.T, cfg *authpkg.ProfilesConfig) ([]string, []profileView) { + t.Helper() + originalLoadToken := profileLoadTokenData + selectors := make([]string, 0, len(cfg.Profiles)) + profileLoadTokenData = func(_ string, selector string) (*authpkg.TokenData, error) { + selectors = append(selectors, selector) + return nil, authpkg.ErrTokenDataNotFound + } + t.Cleanup(func() { profileLoadTokenData = originalLoadToken }) + views := profileViews("unused-config-dir", cfg) + return selectors, views +} + +func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameRoundTripsThroughListAndTUI(t *testing.T) { + cfg := blankProfileSelectorAppFixture("Fixture Organization", "Fixture Organization") + blank := cfg.Profiles[1] + blankSelector := authpkg.ProfileSelectionSelector(blank, cfg) + + if blankSelector == blank.Name || blankSelector == blank.CorpID { + t.Fatalf("unsafe blank selector = %q, want reserved exact selector", blankSelector) + } + if got := profileCLISelector(blank, cfg); got != blankSelector { + t.Errorf("profileCLISelector(blank) = %q, want %q", got, blankSelector) + } + if got := profileSwitchProfileIndex(cfg.Profiles, cfg.CurrentProfile, cfg); got != 1 { + t.Errorf("profileSwitchProfileIndex(blank current) = %d, want 1", got) + } + model := newProfileSwitchTUIModel(cfg, cfg.CurrentProfile) + if model.selected != 1 { + t.Errorf("TUI selected index = %d, want blank profile index 1", model.selected) + } + if got := model.selectedCorpID(); got != blankSelector { + t.Errorf("TUI selected selector = %q, want %q", got, blankSelector) + } + + selectors, views := captureProfileListSelectors(t, cfg) + if len(selectors) != 2 || selectors[0] != cfg.PreviousProfile || selectors[1] != blankSelector { + t.Errorf("profile list token selectors = %#v, want exact then %q", selectors, blankSelector) + } + if len(views) != 2 { + t.Fatalf("profile list views = %#v, want two entries", views) + } + if views[0].IsCurrent { + t.Error("exact account should not be marked current when blank local selector is current") + } + if views[1].Profile != blankSelector || !views[1].IsCurrent { + t.Errorf("blank list view = %#v, want local selector marked current", views[1]) + } +} + +func TestCrossPlatformCoverageBlankProfileNameContainingColonWinsOverIdentityParsingInListAndTUI(t *testing.T) { + cfg := blankProfileSelectorAppFixture("legacy:outsourced", "Fixture Organization") + blank := cfg.Profiles[1] + blankSelector := authpkg.ProfileSelectionSelector(blank, cfg) + + if blankSelector == blank.Name { + t.Fatalf("colon-containing name leaked as selector %q", blankSelector) + } + if _, _, parsedAsIdentity := authpkg.ParseIdentitySelector(blankSelector); parsedAsIdentity { + t.Fatalf("stable blank selector %q was parsed as an identity", blankSelector) + } + if got := profileCLISelector(blank, cfg); got != blankSelector { + t.Errorf("profileCLISelector(colon blank) = %q, want %q", got, blankSelector) + } + if got := profileSwitchProfileIndex(cfg.Profiles, cfg.CurrentProfile, cfg); got != 1 { + t.Errorf("profileSwitchProfileIndex(colon blank current) = %d, want 1", got) + } + model := newProfileSwitchTUIModel(cfg, cfg.CurrentProfile) + if model.selected != 1 { + t.Errorf("TUI selected index = %d, want colon-name blank profile index 1", model.selected) + } + if got := model.selectedCorpID(); got != blankSelector { + t.Errorf("TUI selected selector = %q, want %q", got, blankSelector) + } + + selectors, views := captureProfileListSelectors(t, cfg) + if len(selectors) != 2 || selectors[0] != cfg.PreviousProfile || selectors[1] != blankSelector { + t.Errorf("profile list token selectors = %#v, want exact then %q", selectors, blankSelector) + } + if len(views) != 2 { + t.Fatalf("profile list views = %#v, want two entries", views) + } + if views[0].IsCurrent { + t.Error("exact account should not be marked current when colon-name blank selector is current") + } + if views[1].Profile != blankSelector || !views[1].IsCurrent { + t.Errorf("colon-name blank list view = %#v, want local selector marked current", views[1]) + } +} diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go index 55cc9793..368a82d2 100644 --- a/internal/app/event_personal_command.go +++ b/internal/app/event_personal_command.go @@ -232,6 +232,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro editionName := editionNameOrDefault() workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) + spawnProfileSelector := personalBusProfileSelector(configDir, identity) routes, err := consume.ParseRoutes(opts.Common.RoutesRaw) if err != nil { @@ -247,7 +248,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro WorkDir: workDir, IPCEndpoint: ipcEndpoint, ClientID: identity.ClientID, - SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)), + SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector), Compact: opts.Common.Compact, MaxEvents: opts.Common.MaxEvents, Duration: opts.Common.Duration, @@ -304,7 +305,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro WorkDir: workDir, IPCEndpoint: ipcEndpoint, ClientID: identity.ClientID, - SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL), + SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector), Compact: opts.Common.Compact, MaxEvents: opts.Common.MaxEvents, Duration: opts.Common.Duration, @@ -869,7 +870,31 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio }) } -func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string { +func personalBusProfileSelector(configDir string, identity personal.Identity) string { + // The parent already resolved and loaded this selector. Preserve it before + // consulting identity metadata: personal event discovery can fill an empty + // token userId from runtime defaults, and that inferred value must not turn a + // historical unresolved account into a different exact same-corp account in + // the detached child. + if selector := strings.TrimSpace(authpkg.RuntimeProfile()); selector != "" { + return selector + } + if cfg, err := authpkg.LoadProfiles(configDir); err == nil && cfg != nil { + for i := range cfg.Profiles { + profile := cfg.Profiles[i] + if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID) && + strings.TrimSpace(profile.UserID) == strings.TrimSpace(identity.UserID) { + return authpkg.ProfileSelectionSelector(profile, cfg) + } + } + } + return authpkg.ProfileSelector(authpkg.Profile{ + CorpID: identity.CorpID, + UserID: identity.UserID, + }) +} + +func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string, profileSelectors ...string) []string { args := []string{ "--source-kind", string(dwsevent.SourceKindPersonalStream), "--stream-source-id", identity.SourceID, @@ -878,10 +903,11 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri // credentials as the parent, including when one organization has multiple // logged-in users. if cid := strings.TrimSpace(identity.CorpID); cid != "" { - args = append(args, "--profile", authpkg.ProfileSelector(authpkg.Profile{ - CorpID: identity.CorpID, - UserID: identity.UserID, - })) + profileSelector := authpkg.ProfileSelector(authpkg.Profile{CorpID: identity.CorpID, UserID: identity.UserID}) + if len(profileSelectors) > 0 && strings.TrimSpace(profileSelectors[0]) != "" { + profileSelector = strings.TrimSpace(profileSelectors[0]) + } + args = append(args, "--profile", profileSelector) } if strings.TrimSpace(ticketMode) != "" { args = append(args, "--stream-ticket-mode", ticketMode) diff --git a/internal/app/event_stdin_gate_test.go b/internal/app/event_stdin_gate_test.go index 12b3cb96..64587b52 100644 --- a/internal/app/event_stdin_gate_test.go +++ b/internal/app/event_stdin_gate_test.go @@ -17,6 +17,7 @@ import ( "testing" "time" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" ) @@ -63,3 +64,57 @@ func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) { } } } + +func TestCrossPlatformCoveragePersonalBusSpawnArgsPreservesReservedBlankProfile(t *testing.T) { + configDir := t.TempDir() + cfg := &authpkg.ProfilesConfig{ + Version: 2, + OrgCurrentProfiles: map[string]string{ + "corp_event_fixture": "corp_event_fixture:identity_exact_fixture", + }, + Profiles: []authpkg.Profile{ + { + Name: "Fixture Organization", + CorpID: "corp_event_fixture", + CorpName: "Fixture Organization", + }, + { + Name: "Exact Fixture Account", + CorpID: "corp_event_fixture", + CorpName: "Fixture Organization", + UserID: "identity_exact_fixture", + }, + }, + } + if err := authpkg.SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + identity := personal.Identity{CorpID: "corp_event_fixture", SourceID: "open"} + selector := personalBusProfileSelector(configDir, identity) + want := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg) + if selector != want || selector == identity.CorpID { + t.Fatalf("personalBusProfileSelector(blank) = %q, want reserved %q", selector, want) + } + args := personalBusSpawnArgs(identity, "", "", selector) + found := false + for i := 0; i+1 < len(args); i++ { + if args[i] == "--profile" && args[i+1] == want { + found = true + break + } + } + if !found { + t.Fatalf("spawn args did not preserve reserved blank selector: %v", args) + } + + authpkg.SetRuntimeProfile(want) + t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) + inferredExact := personal.Identity{ + CorpID: "corp_event_fixture", + UserID: "identity_exact_fixture", + SourceID: "open", + } + if got := personalBusProfileSelector(configDir, inferredExact); got != want { + t.Fatalf("runtime blank selector changed after inferred userId: got %q, want %q", got, want) + } +} diff --git a/internal/app/multi_profile_runner_test.go b/internal/app/multi_profile_runner_test.go index cdee5255..c7957c8b 100644 --- a/internal/app/multi_profile_runner_test.go +++ b/internal/app/multi_profile_runner_test.go @@ -107,6 +107,45 @@ func TestRuntimeRunnerDeduplicatesByResolvedIdentityInSameCorp(t *testing.T) { } } +func TestCrossPlatformCoverageRuntimeRunnerDeduplicatesReservedAndOrganizationAliasesForBlankProfile(t *testing.T) { + exact := authLogoutTestToken("corp_blank_alias") + exact.UserID = "identity_exact_alias" + configDir := setupAuthLogoutProfiles(t, exact) + blank := authLogoutTestToken("corp_blank_alias") + blank.AccessToken = "access-unresolved-alias" + blank.RefreshToken = "refresh-unresolved-alias" + blank.UserID = "" + blank.UserName = "" + if err := authpkg.SaveTokenData(configDir, blank); err != nil { + t.Fatalf("SaveTokenData(blank) error = %v", err) + } + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + var reserved string + for _, profile := range cfg.Profiles { + if profile.CorpID == blank.CorpID && profile.UserID == "" { + reserved = authpkg.ProfileSelectionSelector(profile, cfg) + break + } + } + if reserved == "" || reserved == blank.CorpID { + t.Fatalf("blank selector = %q, want reserved selector", reserved) + } + + selections, multi, err := resolveMultiProfileSelections(configDir, reserved+","+blank.CorpID) + if err != nil { + t.Fatalf("resolveMultiProfileSelections() error = %v", err) + } + if !multi || len(selections) != 1 { + t.Fatalf("blank aliases = multi %v selections %#v, want one identity", multi, selections) + } + if selections[0].Selector != reserved || selections[0].Profile.UserID != "" { + t.Fatalf("blank selection = %#v, want first reserved alias preserved", selections[0]) + } +} + func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) { setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b")) authpkg.SetRuntimeProfile("corp_a") diff --git a/internal/app/profile_command.go b/internal/app/profile_command.go index e0315e5a..0ffc01b7 100644 --- a/internal/app/profile_command.go +++ b/internal/app/profile_command.go @@ -266,7 +266,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e } choice := strings.TrimSpace(cfg.CurrentProfile) if choice == "" { - choice = authpkg.ProfileSelector(cfg.Profiles[0]) + choice = authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg) } return profileSwitchTUIRunner(cmd, cfg, choice) } @@ -428,11 +428,36 @@ func (m profileSwitchTUIModel) selectedCorpID() string { if m.selected < 0 || m.selected >= len(m.profiles) { return "" } - return authpkg.ProfileSelector(m.profiles[m.selected]) + selected := m.profiles[m.selected] + return authpkg.ProfileSelectionSelector(selected, &authpkg.ProfilesConfig{Profiles: m.profiles}) } func profileSwitchProfileIndex(profiles []authpkg.Profile, selector string, cfg *authpkg.ProfilesConfig) int { selector = strings.TrimSpace(selector) + for i, profile := range profiles { + if authpkg.ProfileSelectionSelector(profile, cfg) == selector { + return i + } + } + // Accept an old current/previous pointer long enough for the migration path + // to canonicalize it. Only an unresolved profile in a multi-account + // organization qualifies, so ordinary exact account names cannot capture an + // identity selector that contains ':'. + legacyBlank := -1 + for i, profile := range profiles { + if strings.TrimSpace(profile.UserID) != "" || strings.TrimSpace(profile.Name) != selector || + profileCountForCorp(cfg, profile.CorpID) <= 1 { + continue + } + if legacyBlank >= 0 { + legacyBlank = -1 + break + } + legacyBlank = i + } + if legacyBlank >= 0 { + return legacyBlank + } if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact { for i, p := range profiles { if strings.TrimSpace(p.CorpID) == corpID && strings.TrimSpace(p.UserID) == userID { @@ -443,6 +468,9 @@ func profileSwitchProfileIndex(profiles []authpkg.Profile, selector string, cfg } fallback := -1 for i, p := range profiles { + if strings.TrimSpace(p.UserID) == "" && strings.TrimSpace(p.Name) == selector { + return i + } if strings.TrimSpace(p.CorpID) == selector { if fallback < 0 { fallback = i @@ -486,7 +514,7 @@ func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) ( } func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string { - if cfg != nil && profileSelectorSelectsProfile(cfg.CurrentProfile, p, profileIsOrgCurrent(p, cfg), profileCountForCorp(cfg, p.CorpID) <= 1) { + if cfg != nil && profileSelectorSelectsProfile(cfg.CurrentProfile, p, cfg, profileIsOrgCurrent(p, cfg), profileCountForCorp(cfg, p.CorpID) <= 1) { return "当前组织" } return "" @@ -636,13 +664,14 @@ func writeProfileListTable(w io.Writer, configDir string, cfg *authpkg.ProfilesC } fmt.Fprintf(w, "%-3s %-28s %-34s %-10s %s\n", "CUR", "ORG_NAME", "CORP_ID", "STATUS", "USER") for _, p := range cfg.Profiles { + selector := profileCLISelector(p, cfg) view := profileViewFromProfile( p, cfg, cfg.PrimaryProfile, cfg.CurrentProfile, profileCountForCorp(cfg, p.CorpID) == 1, - loadProfileTokenState(configDir, p), + loadProfileTokenState(configDir, p, selector), ) current := "" if view.IsCurrent { @@ -698,13 +727,14 @@ func profileViews(configDir string, cfg *authpkg.ProfilesConfig) []profileView { } views := make([]profileView, 0, len(cfg.Profiles)) for _, p := range cfg.Profiles { + selector := profileCLISelector(p, cfg) views = append(views, profileViewFromProfile( p, cfg, cfg.PrimaryProfile, cfg.CurrentProfile, profileCountForCorp(cfg, p.CorpID) == 1, - loadProfileTokenState(configDir, p), + loadProfileTokenState(configDir, p, selector), )) } return views @@ -719,7 +749,7 @@ func profileViewFromProfile( ) profileView { isOrgCurrent := profileIsOrgCurrent(p, cfg) view := profileView{ - Profile: authpkg.ProfileSelector(p), + Profile: profileCLISelector(p, cfg), CorpID: p.CorpID, CorpName: profileOrgName(p), UserID: p.UserID, @@ -731,8 +761,8 @@ func profileViewFromProfile( RefreshExpAt: p.RefreshExpAt, LastLoginAt: p.LastLoginAt, LastUsedAt: p.LastUsedAt, - IsPrimary: profileSelectorSelectsProfile(primaryProfile, p, isOrgCurrent, onlyAccountInOrg), - IsCurrent: profileSelectorSelectsProfile(currentProfile, p, isOrgCurrent, onlyAccountInOrg), + IsPrimary: profileSelectorSelectsProfile(primaryProfile, p, cfg, isOrgCurrent, onlyAccountInOrg), + IsCurrent: profileSelectorSelectsProfile(currentProfile, p, cfg, isOrgCurrent, onlyAccountInOrg), IsOrgCurrent: isOrgCurrent, } if tokenState != nil { @@ -743,8 +773,12 @@ func profileViewFromProfile( return view } -func loadProfileTokenState(configDir string, profile authpkg.Profile) *profileTokenState { - data, err := profileLoadTokenData(configDir, authpkg.ProfileSelector(profile)) +func loadProfileTokenState(configDir string, profile authpkg.Profile, selectors ...string) *profileTokenState { + selector := authpkg.ProfileSelector(profile) + if len(selectors) > 0 && strings.TrimSpace(selectors[0]) != "" { + selector = strings.TrimSpace(selectors[0]) + } + data, err := profileLoadTokenData(configDir, selector) if errors.Is(err, authpkg.ErrTokenDataNotFound) || (err == nil && data == nil) { return &profileTokenState{Status: authpkg.ProfileStatusRevoked} } @@ -762,6 +796,10 @@ func loadProfileTokenState(configDir string, profile authpkg.Profile) *profileTo } } +func profileCLISelector(profile authpkg.Profile, cfg *authpkg.ProfilesConfig) string { + return authpkg.ProfileSelectionSelector(profile, cfg) +} + func profileTokenTime(value time.Time) string { if value.IsZero() { return "" @@ -769,8 +807,11 @@ func profileTokenTime(value time.Time) string { return value.Format(time.RFC3339) } -func profileSelectorSelectsProfile(selector string, profile authpkg.Profile, isOrgCurrent, onlyAccountInOrg bool) bool { +func profileSelectorSelectsProfile(selector string, profile authpkg.Profile, cfg *authpkg.ProfilesConfig, isOrgCurrent, onlyAccountInOrg bool) bool { selector = strings.TrimSpace(selector) + if selector == authpkg.ProfileSelectionSelector(profile, cfg) { + return true + } if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact { return corpID == strings.TrimSpace(profile.CorpID) && userID == strings.TrimSpace(profile.UserID) } diff --git a/internal/app/runner.go b/internal/app/runner.go index 6aee2a6a..7709f3b3 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -210,7 +210,14 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) if profile == nil { return executor.Result{}, apperrors.NewValidation(fmt.Sprintf("profile %q not found", rawProfile)) } - authpkg.SetRuntimeProfile(authpkg.ProfileSelector(*profile)) + resolvedSelector := authpkg.ProfileSelector(*profile) + if strings.TrimSpace(profile.UserID) == "" { + // Preserve a unique local-name selector for an unresolved account. + // Reducing it to corpId can select a different exact account through + // the organization's current-account pointer. + resolvedSelector = rawProfile + } + authpkg.SetRuntimeProfile(resolvedSelector) defer authpkg.SetRuntimeProfile(rawProfile) } @@ -351,6 +358,9 @@ func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor for _, selection := range selections { resolvedSelector := authpkg.ProfileSelector(selection.Profile) + if strings.TrimSpace(selection.Profile.UserID) == "" { + resolvedSelector = selection.Selector + } authpkg.SetRuntimeProfile(resolvedSelector) result, err := r.runSingle(ctx, cloneInvocation(invocation), false) diff --git a/internal/auth/blank_profile_selector_regression_test.go b/internal/auth/blank_profile_selector_regression_test.go new file mode 100644 index 00000000..695ca06e --- /dev/null +++ b/internal/auth/blank_profile_selector_regression_test.go @@ -0,0 +1,346 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "encoding/json" + "os" + "testing" +) + +type blankProfileSelectorFixture struct { + configDir string + corpID string + blankName string + blankSelector string + exactUserID string + exactSelector string + blankToken *TokenData + exactToken *TokenData +} + +func seedBlankProfileSelectorFixture( + t *testing.T, + blankName string, + corpName string, + blankCurrent bool, +) blankProfileSelectorFixture { + t.Helper() + cleanupKeychain(t) + + configDir := t.TempDir() + corpID := "corp_selector_fixture" + exactUserID := "identity_exact_fixture" + exactSelector := profileSelector(corpID, exactUserID) + currentSelector := exactSelector + previousSelector := blankName + if blankCurrent { + currentSelector = blankName + previousSelector = exactSelector + } + + blankToken := testToken("at_unresolved_fixture", corpID, corpName) + blankToken.UserID = "" + blankToken.UserName = "" + exactToken := testToken("at_exact_fixture", corpID, corpName) + exactToken.UserID = exactUserID + exactToken.UserName = "Exact Fixture Account" + + if err := SaveTokenDataKeychainForCorpID(corpID, blankToken); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID(blank) error = %v", err) + } + if err := SaveTokenDataKeychainForIdentity(corpID, exactUserID, exactToken); err != nil { + t.Fatalf("SaveTokenDataKeychainForIdentity(exact) error = %v", err) + } + if err := SaveTokenDataKeychain(exactToken); err != nil { + t.Fatalf("SaveTokenDataKeychain(exact mirror) error = %v", err) + } + if err := WriteTokenMarker(configDir); err != nil { + t.Fatalf("WriteTokenMarker() error = %v", err) + } + + cfg := &ProfilesConfig{ + Version: profilesVersion, + PrimaryProfile: exactSelector, + CurrentProfile: currentSelector, + PreviousProfile: previousSelector, + OrgCurrentProfiles: map[string]string{ + corpID: exactSelector, + }, + Profiles: []Profile{ + { + Name: blankName, + CorpID: corpID, + CorpName: corpName, + Status: ProfileStatusActive, + }, + { + Name: "Exact Fixture Account", + CorpID: corpID, + CorpName: corpName, + UserID: exactUserID, + UserName: "Exact Fixture Account", + Status: ProfileStatusActive, + }, + }, + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("json.MarshalIndent(profiles) error = %v", err) + } + data = append(data, '\n') + if err := os.WriteFile(ProfilesPath(configDir), data, 0o600); err != nil { + t.Fatalf("os.WriteFile(profiles.json) error = %v", err) + } + + return blankProfileSelectorFixture{ + configDir: configDir, + corpID: corpID, + blankName: blankName, + blankSelector: ProfileSelectionSelector(cfg.Profiles[0], cfg), + exactUserID: exactUserID, + exactSelector: exactSelector, + blankToken: blankToken, + exactToken: exactToken, + } +} + +func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameResolvesCurrentProfileExactly(t *testing.T) { + fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true) + + selected, exact, err := ResolveProfileWithScope(fixture.configDir, "") + if err != nil { + t.Fatalf("ResolveProfileWithScope(current) error = %v", err) + } + if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" { + t.Fatalf("resolved current profile = %#v, want unresolved profile", selected) + } + if !exact { + t.Fatal("current local-name selector should resolve one exact unresolved profile") + } + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != fixture.blankSelector { + t.Fatalf("current profile = %q, want stable unresolved selector %q", cfg.CurrentProfile, fixture.blankSelector) + } +} + +func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameLoadsOrganizationToken(t *testing.T) { + fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false) + + if fixture.blankSelector == fixture.blankName || fixture.blankSelector == fixture.corpID { + t.Fatalf("unsafe blank selector = %q, want reserved exact selector", fixture.blankSelector) + } + loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector) + if err != nil { + t.Fatalf("LoadTokenDataForProfile(blank local name) error = %v", err) + } + if loaded.UserID != "" || loaded.AccessToken != fixture.blankToken.AccessToken { + t.Fatalf("loaded token = %#v, want unresolved organization token", loaded) + } +} + +func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameRoundTripsPreviousProfile(t *testing.T) { + fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false) + + selected, err := UsePreviousProfile(fixture.configDir) + if err != nil { + t.Fatalf("UsePreviousProfile() error = %v", err) + } + if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" { + t.Fatalf("selected previous profile = %#v, want unresolved profile", selected) + } + + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != fixture.blankSelector || cfg.PreviousProfile != fixture.exactSelector { + t.Fatalf( + "profile pointers = current %q previous %q, want %q and %q", + cfg.CurrentProfile, + cfg.PreviousProfile, + fixture.blankSelector, + fixture.exactSelector, + ) + } +} + +func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameDeletesOnlyUnresolvedProfile(t *testing.T) { + fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false) + + if err := DeleteTokenDataForProfile(fixture.configDir, fixture.blankSelector); err != nil { + t.Fatalf("DeleteTokenDataForProfile(blank local name) error = %v", err) + } + + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != fixture.corpID || cfg.Profiles[0].UserID != fixture.exactUserID { + t.Fatalf("profiles after blank deletion = %#v, want only exact account", cfg.Profiles) + } + loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.exactSelector) + if err != nil { + t.Fatalf("LoadTokenDataForProfile(exact after blank deletion) error = %v", err) + } + if loaded.UserID != fixture.exactUserID || loaded.AccessToken != fixture.exactToken.AccessToken { + t.Fatalf("exact token after blank deletion = %#v, want exact account preserved", loaded) + } +} + +func TestCrossPlatformCoverageBlankProfileNameContainingColonWinsOverIdentitySyntax(t *testing.T) { + fixture := seedBlankProfileSelectorFixture(t, "legacy:outsourced", "Fixture Organization", true) + + if fixture.blankSelector == fixture.blankName { + t.Fatalf("colon-containing name leaked as selector %q", fixture.blankSelector) + } + if _, _, parsedAsIdentity := ParseIdentitySelector(fixture.blankSelector); parsedAsIdentity { + t.Fatalf("stable blank selector %q was parsed as an identity", fixture.blankSelector) + } + selected, exact, err := ResolveProfileWithScope(fixture.configDir, "") + if err != nil { + t.Fatalf("ResolveProfileWithScope(colon local name) error = %v", err) + } + if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" { + t.Fatalf("resolved colon-name profile = %#v, want unresolved profile", selected) + } + if !exact { + t.Fatal("colon-containing local name should resolve one exact unresolved profile") + } + + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != fixture.blankSelector { + t.Fatalf("current profile = %q, want migrated colon-name selector %q", cfg.CurrentProfile, fixture.blankSelector) + } + + loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector) + if err != nil { + t.Fatalf("LoadTokenDataForProfile(colon local name) error = %v", err) + } + if loaded.UserID != "" || loaded.AccessToken != fixture.blankToken.AccessToken { + t.Fatalf("loaded colon-name token = %#v, want unresolved organization token", loaded) + } +} + +func TestCrossPlatformCoverageRealExactSelectorWinsOverMatchingBlankLegacyName(t *testing.T) { + const exactSelector = "corp_selector_fixture:identity_exact_fixture" + fixture := seedBlankProfileSelectorFixture(t, exactSelector, "Fixture Organization", true) + + selected, exact, err := ResolveProfileWithScope(fixture.configDir, "") + if err != nil { + t.Fatalf("ResolveProfileWithScope(current exact collision) error = %v", err) + } + if selected == nil || selected.UserID != fixture.exactUserID || !exact { + t.Fatalf("resolved current collision = %#v exact=%v, want real exact identity", selected, exact) + } + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != exactSelector { + t.Fatalf("current collision selector = %q, want real exact %q", cfg.CurrentProfile, exactSelector) + } + + blank, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector) + if err != nil { + t.Fatalf("LoadTokenDataForProfile(reserved blank collision) error = %v", err) + } + if blank.UserID != "" || blank.AccessToken != fixture.blankToken.AccessToken { + t.Fatalf("reserved blank collision token = %#v", blank) + } + if err := DeleteTokenDataForProfile(fixture.configDir, fixture.blankSelector); err != nil { + t.Fatalf("DeleteTokenDataForProfile(reserved blank collision) error = %v", err) + } + exactToken, err := LoadTokenDataForProfile(fixture.configDir, exactSelector) + if err != nil || exactToken.UserID != fixture.exactUserID { + t.Fatalf("exact identity after blank collision delete = %#v, %v", exactToken, err) + } +} + +func TestCrossPlatformCoverageUnrelatedProfileDeletionPreservesBlankOrganizationCurrentMapping(t *testing.T) { + fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true) + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + cfg.Profiles = append(cfg.Profiles, Profile{ + Name: "Unrelated Account", + CorpID: "corp_unrelated_fixture", + UserID: "identity_unrelated_fixture", + Status: ProfileStatusActive, + }) + if err := SaveProfiles(fixture.configDir, cfg); err != nil { + t.Fatalf("SaveProfiles(unrelated) error = %v", err) + } + if _, err := RemoveProfile(fixture.configDir, "corp_unrelated_fixture:identity_unrelated_fixture"); err != nil { + t.Fatalf("RemoveProfile(unrelated) error = %v", err) + } + + cfg, err = LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles(after unrelated delete) error = %v", err) + } + if cfg.CurrentProfile != fixture.blankSelector { + t.Fatalf("blank current after unrelated delete = %q, want %q", cfg.CurrentProfile, fixture.blankSelector) + } + if got := cfg.OrgCurrentProfiles[fixture.corpID]; got != fixture.exactSelector { + t.Fatalf("organization current after unrelated delete = %q, want %q", got, fixture.exactSelector) + } + selected, err := ResolveProfile(fixture.configDir, fixture.corpID) + if err != nil || selected.UserID != fixture.exactUserID { + t.Fatalf("organization selector after unrelated delete = %#v, %v", selected, err) + } +} + +func TestCrossPlatformCoverageSameCorpNonCurrentDeletionPreservesExactOrganizationCurrent(t *testing.T) { + fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true) + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + cfg.Profiles = append(cfg.Profiles, Profile{ + Name: "Another Exact Account", + CorpID: fixture.corpID, + CorpName: "Fixture Organization", + UserID: "identity_noncurrent_fixture", + Status: ProfileStatusActive, + }) + if err := SaveProfiles(fixture.configDir, cfg); err != nil { + t.Fatalf("SaveProfiles(non-current exact) error = %v", err) + } + if _, err := RemoveProfile(fixture.configDir, fixture.corpID+":identity_noncurrent_fixture"); err != nil { + t.Fatalf("RemoveProfile(non-current exact) error = %v", err) + } + + cfg, err = LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles(after same-corp delete) error = %v", err) + } + if cfg.CurrentProfile != fixture.blankSelector { + t.Fatalf("blank current after same-corp delete = %q, want %q", cfg.CurrentProfile, fixture.blankSelector) + } + if got := cfg.OrgCurrentProfiles[fixture.corpID]; got != fixture.exactSelector { + t.Fatalf("organization current after same-corp delete = %q, want %q", got, fixture.exactSelector) + } + selected, err := ResolveProfile(fixture.configDir, fixture.corpID) + if err != nil || selected.UserID != fixture.exactUserID { + t.Fatalf("organization selector after same-corp delete = %#v, %v", selected, err) + } +} diff --git a/internal/auth/coverage_edges_test.go b/internal/auth/coverage_edges_test.go index 7bb6f4b2..d850988a 100644 --- a/internal/auth/coverage_edges_test.go +++ b/internal/auth/coverage_edges_test.go @@ -3159,7 +3159,13 @@ func TestCrossPlatformCoverageMultiAccountSelectorAndIdentityLoadEdges(t *testin if got, err := loadTokenForProfileIdentity(profile); err != nil || got.AccessToken != "mirror" { t.Fatalf("identity repair = %#v %v", got, err) } - if _, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err != nil { - t.Fatalf("organization-only token load = %v", err) + if _, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err == nil { + t.Fatal("unresolved profile loaded organization token owned by an exact identity") + } + profilesLoadCorp = func(string) (*TokenData, error) { + return &TokenData{CorpID: "corp-a", AccessToken: "organization"}, nil + } + if got, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err != nil || got.AccessToken != "organization" { + t.Fatalf("organization-only token load = %#v %v", got, err) } } diff --git a/internal/auth/keychain_store.go b/internal/auth/keychain_store.go index 16f33142..74309c07 100644 --- a/internal/auth/keychain_store.go +++ b/internal/auth/keychain_store.go @@ -219,12 +219,20 @@ func preflightTokenRefreshPersistence(configDir string, data *TokenData) error { return fmt.Errorf("identity token slot %q is unreadable: %w", TokenAccountForIdentity(corpID, userID), err) } } - checkOrganizationMirror := true - if _, _, exact := ParseIdentitySelector(RuntimeProfile()); exact { - checkOrganizationMirror = - exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) == - profileSelector(corpID, userID) + runtimeSelector := strings.TrimSpace(RuntimeProfile()) + persistenceSelector := runtimeSelector + if persistenceSelector == "" && userID == "" { + persistenceSelector = strings.TrimSpace(data.LegacyOrgScopedProfile) } + makeCurrent := persistenceSelector == "" + existingIdentity := profileIndexByIdentity(cfg, corpID, userID) >= 0 + upgradesLegacyProfile := !existingIdentity && userID != "" && + len(profilesForCorpID(cfg, corpID)) == 1 && legacyProfileIndexByCorpID(cfg, corpID) >= 0 + preserveUnresolvedOrg := userID != "" && + unresolvedProfileForCorp(cfg, corpID) != nil && !upgradesLegacyProfile + checkOrganizationMirror := userID == "" || (!preserveUnresolvedOrg && (makeCurrent || + exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) == + profileSelector(corpID, userID))) if checkOrganizationMirror { if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) { return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err) diff --git a/internal/auth/legacy_compat_coverage_gaps_test.go b/internal/auth/legacy_compat_coverage_gaps_test.go new file mode 100644 index 00000000..6cf35ab6 --- /dev/null +++ b/internal/auth/legacy_compat_coverage_gaps_test.go @@ -0,0 +1,288 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "errors" + "testing" +) + +func TestCrossPlatformCoverageLegacySelectorCompatibilityEdges(t *testing.T) { + upgradeDir := t.TempDir() + upgradeCfg := &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{{ + Name: "Legacy Organization", + CorpID: "corp_upgrade_fixture", + }}, + } + if err := upsertProfileFromToken(upgradeDir, upgradeCfg, &TokenData{ + CorpID: "corp_upgrade_fixture", + UserID: "identity_upgrade_fixture", + UserName: "Upgraded Account", + }, false); err != nil { + t.Fatalf("upsertProfileFromToken(upgrade legacy profile) error = %v", err) + } + if len(upgradeCfg.Profiles) != 1 || upgradeCfg.Profiles[0].UserID != "identity_upgrade_fixture" { + t.Fatalf("upgraded profiles = %#v", upgradeCfg.Profiles) + } + + renameDir := t.TempDir() + renameCfg := &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{ + {Name: "duplicate", CorpID: "corp_rename_fixture"}, + {Name: "duplicate", CorpID: "corp_rename_fixture", UserID: "identity_exact_fixture"}, + }, + } + if err := upsertProfileFromToken(renameDir, renameCfg, &TokenData{ + CorpID: "corp_rename_fixture", + CorpName: "Renamed Organization", + }, false); err != nil { + t.Fatalf("upsertProfileFromToken(rename blank profile) error = %v", err) + } + if renameCfg.Profiles[0].Name != "Renamed Organization" { + t.Fatalf("blank profile name = %q, want conflict-free organization name", renameCfg.Profiles[0].Name) + } + + blank := Profile{CorpID: "corp_selector_fixture"} + if got := storedProfileSelector(nil, nil); got != "" { + t.Fatalf("storedProfileSelector(nil profile) = %q", got) + } + if got := storedProfileSelector(nil, &blank); got != blank.CorpID { + t.Fatalf("storedProfileSelector(nil config) = %q", got) + } + if localProfileSelectorIsSafe(nil, &blank, "local") || + localProfileSelectorIsSafe(&ProfilesConfig{}, nil, "local") { + t.Fatal("nil selector inputs were treated as safe") + } + if got := unresolvedProfileSelector(" "); got != "" { + t.Fatalf("unresolvedProfileSelector(blank) = %q", got) + } + if _, ok := parseUnresolvedProfileSelector(unresolvedProfileSelectorPrefix + "!"); ok { + t.Fatal("invalid base64 legacy selector parsed successfully") + } + if _, ok := parseUnresolvedProfileSelector(unresolvedProfileSelectorPrefix + "IA"); ok { + t.Fatal("blank decoded legacy selector parsed successfully") + } + + previousRuntime := RuntimeProfile() + SetRuntimeProfile("") + t.Cleanup(func() { SetRuntimeProfile(previousRuntime) }) + if got := StableTokenProfileSelector(t.TempDir(), nil); got != "" { + t.Fatalf("StableTokenProfileSelector(nil) = %q", got) + } + + ambiguousDir := t.TempDir() + ambiguousCfg := &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: "corp_ambiguous_fixture", + Profiles: []Profile{ + {Name: "First", CorpID: "corp_ambiguous_fixture", UserID: "identity_first_fixture"}, + {Name: "Second", CorpID: "corp_ambiguous_fixture", UserID: "identity_second_fixture"}, + }, + } + if err := SaveProfiles(ambiguousDir, ambiguousCfg); err != nil { + t.Fatalf("SaveProfiles(ambiguous current) error = %v", err) + } + ambiguousToken := &TokenData{CorpID: "corp_ambiguous_fixture", UserID: "identity_first_fixture"} + if got, want := StableTokenProfileSelector(ambiguousDir, ambiguousToken), "corp_ambiguous_fixture:identity_first_fixture"; got != want { + t.Fatalf("StableTokenProfileSelector(ambiguous organization) = %q, want %q", got, want) + } + + reserved := unresolvedProfileSelector("corp_missing_fixture") + emptyCfg := &ProfilesConfig{} + if _, _, err := resolveProfileSelection("", emptyCfg, reserved); err == nil { + t.Fatal("missing reserved profile selection succeeded") + } + if _, _, err := resolveProfileDeletionSelection(emptyCfg, reserved); err == nil { + t.Fatal("missing reserved profile deletion succeeded") + } + if got := canonicalStoredSelector(emptyCfg, reserved); got != "" { + t.Fatalf("canonical missing reserved selector = %q", got) + } + if !selectorTargetsCorp(reserved, "corp_missing_fixture") { + t.Fatal("reserved selector did not target its organization") + } + + localCfg := &ProfilesConfig{Profiles: []Profile{{ + Name: "local-profile-fixture", + CorpID: "corp_local_fixture", + UserID: "identity_local_fixture", + }}} + if got, want := canonicalStoredSelector(localCfg, "local-profile-fixture"), "corp_local_fixture:identity_local_fixture"; got != want { + t.Fatalf("canonical local selector = %q, want %q", got, want) + } + if unresolvedProfileForCorp(nil, "corp") != nil || unresolvedProfileForLocalName(nil, "local") != nil { + t.Fatal("nil profile registry returned an unresolved profile") + } + if unresolvedProfileForLocalName(localCfg, " ") != nil { + t.Fatal("blank local name returned an unresolved profile") + } + duplicateCfg := &ProfilesConfig{Profiles: []Profile{ + {Name: "duplicate-blank", CorpID: "corp_duplicate_one"}, + {Name: "Exact One", CorpID: "corp_duplicate_one", UserID: "identity_one"}, + {Name: "duplicate-blank", CorpID: "corp_duplicate_two"}, + {Name: "Exact Two", CorpID: "corp_duplicate_two", UserID: "identity_two"}, + }} + if unresolvedProfileForLocalName(duplicateCfg, "duplicate-blank") != nil { + t.Fatal("duplicate unresolved local name selected an arbitrary profile") + } + + oldLoadCorp := tokenLoadKeychainForCorpID + t.Cleanup(func() { tokenLoadKeychainForCorpID = oldLoadCorp }) + tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { return nil, nil } + if _, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_nil_token_fixture"}); !errors.Is(err, ErrTokenDataNotFound) { + t.Fatalf("nil organization token error = %v", err) + } +} + +func TestCrossPlatformCoverageLegacyProfileLifecycleErrorEdges(t *testing.T) { + oldEnsure := profilesEnsureMigration + oldLoad := profilesLoad + oldSave := profilesSave + oldLoadCorp := profilesLoadCorp + oldLoadLegacy := profilesLoadLegacy + oldLoadIdentity := profilesLoadIdentity + oldSaveCorp := profilesSaveCorp + oldDeleteCorp := profilesDeleteCorp + oldDeleteLegacy := profilesDeleteLegacy + oldDeleteMarker := profilesDeleteMarker + t.Cleanup(func() { + profilesEnsureMigration = oldEnsure + profilesLoad = oldLoad + profilesSave = oldSave + profilesLoadCorp = oldLoadCorp + profilesLoadLegacy = oldLoadLegacy + profilesLoadIdentity = oldLoadIdentity + profilesSaveCorp = oldSaveCorp + profilesDeleteCorp = oldDeleteCorp + profilesDeleteLegacy = oldDeleteLegacy + profilesDeleteMarker = oldDeleteMarker + }) + + identityFailure := errors.New("identity load failure") + profilesEnsureMigration = func(string) error { return nil } + profilesSave = func(string, *ProfilesConfig) error { return nil } + profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound } + profilesLoadLegacy = func() (*TokenData, error) { return nil, ErrTokenDataNotFound } + profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, identityFailure } + profilesSaveCorp = func(string, *TokenData) error { return nil } + profilesDeleteCorp = func(string) error { return nil } + profilesDeleteLegacy = func() error { return nil } + profilesDeleteMarker = func(string) error { return nil } + + setCurrentCfg := &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: "corp_set_fixture:identity_set_fixture", + OrgCurrentProfiles: map[string]string{ + "corp_set_fixture": "corp_set_fixture:identity_set_fixture", + }, + Profiles: []Profile{{ + Name: "Set Account", + CorpID: "corp_set_fixture", + UserID: "identity_set_fixture", + }}, + } + profilesLoad = func(string) (*ProfilesConfig, error) { return setCurrentCfg, nil } + if _, err := setCurrentProfileLocked(t.TempDir(), "corp_set_fixture:identity_set_fixture"); !errors.Is(err, identityFailure) { + t.Fatalf("setCurrentProfileLocked sync error = %v", err) + } + + usePreviousCfg := &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: "corp_previous_fixture:identity_current_fixture", + PreviousProfile: "corp_previous_fixture:identity_previous_fixture", + OrgCurrentProfiles: map[string]string{ + "corp_previous_fixture": "corp_previous_fixture:identity_current_fixture", + }, + Profiles: []Profile{ + {Name: "Current", CorpID: "corp_previous_fixture", UserID: "identity_current_fixture"}, + {Name: "Previous", CorpID: "corp_previous_fixture", UserID: "identity_previous_fixture"}, + }, + } + profilesLoad = func(string) (*ProfilesConfig, error) { return usePreviousCfg, nil } + if _, err := usePreviousProfileLocked(t.TempDir()); !errors.Is(err, identityFailure) { + t.Fatalf("usePreviousProfileLocked sync error = %v", err) + } + + snapshotFailure := errors.New("organization snapshot failure") + profilesLoadCorp = func(string) (*TokenData, error) { return nil, snapshotFailure } + if _, err := snapshotProfileSelectionMirrors(t.TempDir(), "corp_snapshot_fixture", true); !errors.Is(err, snapshotFailure) { + t.Fatalf("snapshot organization error = %v", err) + } + profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound } + + operationFailure := errors.New("selection operation failure") + organizationRestoreFailure := errors.New("organization restore failure") + profilesSaveCorp = func(string, *TokenData) error { return organizationRestoreFailure } + withOrganization := profileSelectionMirrorSnapshot{ + organization: tokenSlotSnapshot{known: true, exists: true, token: &TokenData{CorpID: "corp_rollback_fixture"}}, + marker: tokenMarkerSnapshot{known: true}, + } + if err := rollbackProfileSelection(t.TempDir(), &ProfilesConfig{}, "corp_rollback_fixture", withOrganization, operationFailure); !errors.Is(err, operationFailure) || !errors.Is(err, organizationRestoreFailure) { + t.Fatalf("rollback organization save error = %v", err) + } + + organizationDeleteFailure := errors.New("organization delete failure") + profilesSaveCorp = func(string, *TokenData) error { return nil } + profilesDeleteCorp = func(string) error { return organizationDeleteFailure } + withoutOrganization := profileSelectionMirrorSnapshot{ + organization: tokenSlotSnapshot{known: true}, + marker: tokenMarkerSnapshot{known: true}, + } + if err := rollbackProfileSelection(t.TempDir(), &ProfilesConfig{}, "corp_rollback_fixture", withoutOrganization, operationFailure); !errors.Is(err, operationFailure) || !errors.Is(err, organizationDeleteFailure) { + t.Fatalf("rollback organization delete error = %v", err) + } + profilesDeleteCorp = func(string) error { return nil } + + remainingCfg := &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: "corp_removed_fixture:identity_removed_fixture", + OrgCurrentProfiles: map[string]string{ + "corp_removed_fixture": "corp_removed_fixture:identity_removed_fixture", + }, + Profiles: []Profile{ + {Name: "Removed", CorpID: "corp_removed_fixture", UserID: "identity_removed_fixture"}, + {Name: "Remaining", CorpID: "corp_remaining_fixture", UserID: "identity_remaining_fixture"}, + }, + } + profilesLoad = func(string) (*ProfilesConfig, error) { return remainingCfg, nil } + if _, err := removeProfileLocked(t.TempDir(), "corp_removed_fixture:identity_removed_fixture"); err != nil { + t.Fatalf("removeProfileLocked(single fallback) error = %v", err) + } + if remainingCfg.CurrentProfile != "corp_remaining_fixture:identity_remaining_fixture" { + t.Fatalf("fallback current profile = %q", remainingCfg.CurrentProfile) + } + + blankCfg := &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: "corp_blank_fixture:identity_exact_fixture", + PreviousProfile: "legacy-blank-fixture", + OrgCurrentProfiles: map[string]string{ + "corp_blank_fixture": "corp_blank_fixture:identity_exact_fixture", + }, + Profiles: []Profile{ + {Name: "legacy-blank-fixture", CorpID: "corp_blank_fixture"}, + {Name: "Exact", CorpID: "corp_blank_fixture", UserID: "identity_exact_fixture"}, + }, + } + profilesLoad = func(string) (*ProfilesConfig, error) { return blankCfg, nil } + if _, err := removeProfileLocked(t.TempDir(), "corp_blank_fixture:identity_exact_fixture"); err != nil { + t.Fatalf("removeProfileLocked(blank fallback) error = %v", err) + } + if blankCfg.CurrentProfile != "corp_blank_fixture" || blankCfg.OrgCurrentProfiles["corp_blank_fixture"] != "" { + t.Fatalf("blank fallback selection = current %q org %q", blankCfg.CurrentProfile, blankCfg.OrgCurrentProfiles["corp_blank_fixture"]) + } +} diff --git a/internal/auth/legacy_version_migration_test.go b/internal/auth/legacy_version_migration_test.go new file mode 100644 index 00000000..851f4c25 --- /dev/null +++ b/internal/auth/legacy_version_migration_test.go @@ -0,0 +1,879 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "encoding/json" + "errors" + "os" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" +) + +// The fixture builders spell out the exact v1 profiles and token JSON keys. +// They deliberately avoid the current TokenData and ProfilesConfig serializers +// so that historical field omission and account names stay part of the upgrade +// contract exercised by these tests. +func TestCrossPlatformCoverageV1044GlobalSlotWithoutProfilesMigrates(t *testing.T) { + for _, tc := range []struct { + name string + userID string + }{ + {name: "known user", userID: "legacy-user-v1044"}, + {name: "unresolved external worker"}, + } { + t.Run(tc.name, func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + corpID := "ding_v1044_" + strings.ReplaceAll(tc.name, " ", "_") + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, "global-v1044-"+tc.name, corpID, "V1044 Org", tc.userID, "", + )) + + loaded, err := LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + if loaded.CorpID != corpID || loaded.UserID != tc.userID { + t.Fatalf("migrated v1.0.44 token = %#v", loaded) + } + if !TokenDataExistsKeychainForCorpID(corpID) { + t.Fatal("v1.0.44 global token was not copied to its organization slot") + } + if tc.userID != "" && !TokenDataExistsKeychainForIdentity(corpID, tc.userID) { + t.Fatal("v1.0.44 known identity slot was not created") + } + }) + } +} + +func TestCrossPlatformCoverageV1050AndV1051GlobalSlotWithV1ProfilesMigratesIdentity(t *testing.T) { + tests := []struct { + name string + corpID string + corpName string + userID string + userName string + tokenHasUID bool + }{ + { + name: "v1.0.50 token and profile both carry userId", + corpID: "ding_v1050", + corpName: "V1050 Org", + userID: "legacy-user-v1050", + userName: "V1050 User", + tokenHasUID: true, + }, + { + name: "v1.0.51 profile supplies omitted token userId", + corpID: "ding_v1051", + corpName: "V1051 Org", + userID: "legacy-user-v1051", + userName: "V1051 User", + tokenHasUID: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + writeHistoricalV1Profiles(t, configDir, []historicalV1Profile{{ + name: tc.corpName, + corpID: tc.corpID, + corpName: tc.corpName, + userID: tc.userID, + userName: tc.userName, + clientID: "ding-client-" + tc.corpID, + }}, tc.corpID, "", tc.corpID) + + tokenUserID, tokenUserName := "", "" + if tc.tokenHasUID { + tokenUserID, tokenUserName = tc.userID, tc.userName + } + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, + "global-"+tc.corpID, + tc.corpID, + tc.corpName, + tokenUserID, + tokenUserName, + )) + + // An ordinary first read is the upgrade trigger. A recoverable global + // token must populate both the organization and exact-identity slots + // even when a version-1 profiles registry already exists. + if _, err := LoadTokenData(configDir); err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + migrated, err := LoadTokenDataKeychainForIdentity(tc.corpID, tc.userID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity(%q, %q) error = %v", tc.corpID, tc.userID, err) + } + if migrated.CorpID != tc.corpID || migrated.UserID != tc.userID { + t.Fatalf("migrated identity token = %#v", migrated) + } + if migrated.AccessToken != "global-"+tc.corpID || + migrated.RefreshToken != "refresh-global-"+tc.corpID || + migrated.PersistentCode != "persistent-global-"+tc.corpID || + migrated.ClientID != "ding-client-historical" || + migrated.Source != "mcp" { + t.Fatalf("migrated token fields were not preserved: %#v", migrated) + } + orgMirror, err := LoadTokenDataKeychainForCorpID(tc.corpID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForCorpID(%q) error = %v", tc.corpID, err) + } + if orgMirror.AccessToken != "global-"+tc.corpID { + t.Fatalf("organization token slot %q = %#v", TokenAccountForCorpID(tc.corpID), orgMirror) + } + if !tc.tokenHasUID && orgMirror.UserID != "" { + t.Fatalf("organization mirror inferred userId %q; want untouched historical blob", orgMirror.UserID) + } + }) + } + +} + +func TestCrossPlatformCoverageV1052RawMultiOrganizationSlotsMigrateEveryIdentity(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + organizations := seedHistoricalV1052MultiOrganizationState(t, configDir) + + // Reading only the current organization must upgrade the complete registry, + // including inactive organizations that are not selected. + loaded, err := LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + if loaded.CorpID != organizations[1].corpID || loaded.UserID != organizations[1].userID { + t.Fatalf("current token after migration = %#v", loaded) + } + assertHistoricalV1052IdentitySlots(t, organizations, nil) +} + +func TestCrossPlatformCoverageV1052UnresolvedMultiOrganizationProfilesRemainUsable(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + organizations := []historicalV1052Organization{ + {corpID: "ding_v1052_external_a", corpName: "External Org A", accessToken: "external-access-a"}, + {corpID: "ding_v1052_external_b", corpName: "External Org B", accessToken: "external-access-b"}, + {corpID: "ding_v1052_external_c", corpName: "External Org C", accessToken: "external-access-c"}, + } + profiles := make([]historicalV1Profile, 0, len(organizations)) + for _, organization := range organizations { + profiles = append(profiles, historicalV1Profile{ + name: organization.corpName, corpID: organization.corpID, corpName: organization.corpName, + }) + seedHistoricalTokenSlot(t, TokenAccountForCorpID(organization.corpID), historicalTokenJSON( + t, organization.accessToken, organization.corpID, organization.corpName, "", "", + )) + } + writeHistoricalV1Profiles( + t, + configDir, + profiles, + organizations[0].corpID, + organizations[0].corpID, + organizations[1].corpID, + ) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + for _, organization := range organizations { + loaded, err := LoadTokenDataForProfile(configDir, organization.corpID) + if err != nil { + t.Fatalf("LoadTokenDataForProfile(%q) error = %v", organization.corpID, err) + } + if loaded.AccessToken != organization.accessToken || loaded.CorpID != organization.corpID || loaded.UserID != "" { + t.Fatalf("unresolved organization token for %q = %#v", organization.corpID, loaded) + } + } +} + +func TestCrossPlatformCoverageV1052FirstSaveMigratesAllOrganizationsBeforeV2Commit(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + organizations := seedHistoricalV1052MultiOrganizationState(t, configDir) + + // A login or refresh can make SaveTokenData the first new-version action. + // It must migrate every old organization before profiles.json becomes v2, + // otherwise the remaining organization mirrors are stranded permanently. + firstWrite := &TokenData{ + AccessToken: "new-first-action-access", + RefreshToken: "new-first-action-refresh", + PersistentCode: "new-first-action-persistent", + CorpID: organizations[1].corpID, + CorpName: organizations[1].corpName, + UserID: organizations[1].userID, + UserName: organizations[1].userName, + ClientID: "ding-client-new-first-action", + Source: "mcp", + } + if err := SaveTokenData(configDir, firstWrite); err != nil { + t.Fatalf("SaveTokenData(first new-version action) error = %v", err) + } + + assertHistoricalV1052IdentitySlots(t, organizations, map[string]string{ + organizations[1].corpID: firstWrite.AccessToken, + }) + migratedCurrent, err := LoadTokenDataKeychainForIdentity(firstWrite.CorpID, firstWrite.UserID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity(first write) error = %v", err) + } + if migratedCurrent.RefreshToken != firstWrite.RefreshToken || + migratedCurrent.PersistentCode != firstWrite.PersistentCode || + migratedCurrent.ClientID != firstWrite.ClientID || + migratedCurrent.Source != firstWrite.Source { + t.Fatalf("first-write identity token fields were not preserved: %#v", migratedCurrent) + } + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.Version != profilesVersion || len(cfg.Profiles) != len(organizations) { + t.Fatalf("profiles after first save = %#v", cfg) + } +} + +func TestCrossPlatformCoverageLegacyGlobalFallbackIsStrictlyScoped(t *testing.T) { + t.Run("different organization is never reused", func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + writeHistoricalV1Profiles(t, configDir, []historicalV1Profile{{ + name: "Expected Org", + corpID: "ding_expected", + corpName: "Expected Org", + userID: "expected-user", + userName: "Expected User", + clientID: "ding-client-expected", + }}, "ding_expected", "", "ding_expected") + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, + "wrong-org-access", + "ding_other", + "Other Org", + "other-user", + "Other User", + )) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if TokenDataExistsKeychainForCorpID("ding_expected") || + TokenDataExistsKeychainForIdentity("ding_expected", "expected-user") { + t.Fatal("global token from another organization was reused") + } + }) + + t.Run("version 2 empty tombstone never imports global slot", func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + if err := SaveProfiles(configDir, &ProfilesConfig{Version: profilesVersion}); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, + "stale-v2-global", + "ding_v2", + "V2 Org", + "v2-user", + "V2 User", + )) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if TokenDataExistsKeychainForCorpID("ding_v2") || + TokenDataExistsKeychainForIdentity("ding_v2", "v2-user") { + t.Fatal("version 2 logout tombstone imported the stale global slot") + } + }) + + t.Run("multiple same organization accounts never receive guessed identity", func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + writeHistoricalV1Profiles(t, configDir, []historicalV1Profile{ + { + name: "Shared Org One", + corpID: "ding_shared", + corpName: "Shared Org", + userID: "shared-user-one", + userName: "Shared User One", + clientID: "ding-client-shared", + }, + { + name: "Shared Org Two", + corpID: "ding_shared", + corpName: "Shared Org", + userID: "shared-user-two", + userName: "Shared User Two", + clientID: "ding-client-shared", + }, + }, "ding_shared", "", "ding_shared") + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, + "shared-without-user", + "ding_shared", + "Shared Org", + "", + "", + )) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if !TokenDataExistsKeychainForCorpID("ding_shared") { + t.Fatal("matching organization mirror was not restored") + } + for _, userID := range []string{"shared-user-one", "shared-user-two"} { + if TokenDataExistsKeychainForIdentity("ding_shared", userID) { + t.Fatalf("ambiguous global token was copied to identity %q", userID) + } + } + }) +} + +func TestCrossPlatformCoverageV1053PartialV2RegistryRepairsFromMatchingGlobalSlot(t *testing.T) { + tests := []struct { + name string + profileUserID string + tokenUserID string + }{ + { + name: "matching token identity", + profileUserID: "user_v1053_matching", + tokenUserID: "user_v1053_matching", + }, + {name: "token omitted identity", profileUserID: "user_v1053_token_omitted"}, + {name: "sole unresolved profile"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + suffix := strings.ReplaceAll(tc.name, " ", "_") + corpID := "ding_v1053_partial_" + suffix + profile := Profile{ + Name: "V1053 Partial Org", + CorpID: corpID, + CorpName: "V1053 Partial Org", + UserID: tc.profileUserID, + UserName: "V1053 Partial User", + } + identityLoads := 0 + if profile.UserID == "" { + originalLoadIdentity := profilesLoadIdentity + profilesLoadIdentity = func(corpID, userID string) (*TokenData, error) { + identityLoads++ + return originalLoadIdentity(corpID, userID) + } + t.Cleanup(func() { profilesLoadIdentity = originalLoadIdentity }) + } + selector := ProfileSelector(profile) + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: selector, + OrgCurrentProfiles: map[string]string{corpID: selector}, + Profiles: []Profile{profile}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, + "v1053-global-"+suffix, + corpID, + profile.CorpName, + tc.tokenUserID, + "", + )) + + if TokenDataExistsKeychainForCorpID(corpID) { + t.Fatal("partial v2 fixture unexpectedly contained an organization or identity slot") + } + if profile.UserID != "" && TokenDataExistsKeychainForIdentity(corpID, profile.UserID) { + t.Fatal("partial v2 fixture unexpectedly contained an identity slot") + } + loaded, err := LoadTokenDataForProfile(configDir, selector) + if err != nil { + t.Fatalf("LoadTokenDataForProfile() error = %v", err) + } + if loaded.AccessToken != "v1053-global-"+suffix || + loaded.CorpID != corpID || loaded.UserID != profile.UserID { + t.Fatalf("repaired v2 token = %#v", loaded) + } + + if profile.UserID != "" { + identityToken, identityErr := LoadTokenDataKeychainForIdentity(corpID, profile.UserID) + if identityErr != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", identityErr) + } + if identityToken.AccessToken != loaded.AccessToken || identityToken.UserID != profile.UserID { + t.Fatalf("repaired identity token = %#v", identityToken) + } + } + orgMirror, err := LoadTokenDataKeychainForCorpID(corpID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err) + } + if orgMirror.AccessToken != loaded.AccessToken || orgMirror.UserID != tc.tokenUserID { + t.Fatalf("repaired organization mirror = %#v", orgMirror) + } + if identityLoads != 0 { + t.Fatalf("unresolved profile caused %d identity-slot reads; want 0", identityLoads) + } + }) + } + + t.Run("matching organization among multiple organizations", func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + profiles := []Profile{ + {Name: "Partial Org A", CorpID: "ding_v1053_partial_a", UserID: "user_v1053_partial_a"}, + {Name: "Partial Org B", CorpID: "ding_v1053_partial_b", UserID: "user_v1053_partial_b"}, + } + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: ProfileSelector(profiles[1]), + Profiles: profiles, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, + "v1053-global-multi-org", + profiles[1].CorpID, + profiles[1].Name, + profiles[1].UserID, + "", + )) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if TokenDataExistsKeychainForCorpID(profiles[0].CorpID) || + TokenDataExistsKeychainForIdentity(profiles[0].CorpID, profiles[0].UserID) { + t.Fatal("global token was copied into the non-matching organization") + } + repaired, err := LoadTokenDataKeychainForIdentity(profiles[1].CorpID, profiles[1].UserID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity(matching organization) error = %v", err) + } + if repaired.AccessToken != "v1053-global-multi-org" || repaired.UserID != profiles[1].UserID { + t.Fatalf("multi-organization v2 repair token = %#v", repaired) + } + }) +} + +func TestCrossPlatformCoverageV1053PartialV2RegistryRejectsUnsafeGlobalSlot(t *testing.T) { + t.Run("global token organization differs", func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + profile := Profile{Name: "Expected Org", CorpID: "ding_v2_expected", UserID: "user_v2_expected"} + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: ProfileSelector(profile), + Profiles: []Profile{profile}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, "cross-corp-global", "ding_v2_other", "Other Org", profile.UserID, "", + )) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if TokenDataExistsKeychainForCorpID(profile.CorpID) || + TokenDataExistsKeychainForIdentity(profile.CorpID, profile.UserID) { + t.Fatal("cross-organization global token was imported") + } + }) + + t.Run("unresolved profile rejects global token identity", func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + profile := Profile{Name: "Unresolved External", CorpID: "ding_v2_unresolved"} + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: ProfileSelector(profile), + Profiles: []Profile{profile}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, "unexpected-identity-global", profile.CorpID, profile.Name, "user_v2_unexpected", "", + )) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if TokenDataExistsKeychainForCorpID(profile.CorpID) { + t.Fatal("global token userId was attached to an unresolved profile") + } + }) + + t.Run("global token identity differs", func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + profile := Profile{Name: "Expected User", CorpID: "ding_v2_uid", UserID: "user_v2_expected"} + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: ProfileSelector(profile), + Profiles: []Profile{profile}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, "wrong-user-global", profile.CorpID, profile.Name, "user_v2_other", "", + )) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if TokenDataExistsKeychainForCorpID(profile.CorpID) || + TokenDataExistsKeychainForIdentity(profile.CorpID, profile.UserID) { + t.Fatal("global token with a different userId was imported") + } + }) + + t.Run("multiple accounts in one organization stay unresolved", func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + corpID := "ding_v2_shared" + profiles := []Profile{ + {Name: "Shared User One", CorpID: corpID, UserID: "user_v2_shared_one"}, + {Name: "Shared User Two", CorpID: corpID, UserID: "user_v2_shared_two"}, + } + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: ProfileSelector(profiles[0]), + Profiles: profiles, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, "ambiguous-global", corpID, "Shared Org", profiles[0].UserID, "", + )) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if TokenDataExistsKeychainForCorpID(corpID) { + t.Fatal("multi-account organization imported the mutable global mirror") + } + for _, profile := range profiles { + if TokenDataExistsKeychainForIdentity(corpID, profile.UserID) { + t.Fatalf("multi-account global token was copied to identity %q", profile.UserID) + } + } + }) + + t.Run("existing exact identity is not overwritten", func(t *testing.T) { + cleanupHistoricalKeychain(t) + configDir := t.TempDir() + profile := Profile{Name: "Existing User", CorpID: "ding_v2_existing", UserID: "user_v2_existing"} + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: ProfileSelector(profile), + Profiles: []Profile{profile}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + seedHistoricalTokenSlot(t, TokenAccountForIdentity(profile.CorpID, profile.UserID), historicalTokenJSON( + t, "existing-exact", profile.CorpID, profile.Name, profile.UserID, "", + )) + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, "stale-global", profile.CorpID, profile.Name, profile.UserID, "", + )) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if TokenDataExistsKeychainForCorpID(profile.CorpID) { + t.Fatal("global mirror recreated an organization slot beside an existing exact identity") + } + exact, err := LoadTokenDataKeychainForIdentity(profile.CorpID, profile.UserID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err) + } + if exact.AccessToken != "existing-exact" { + t.Fatalf("existing exact identity was overwritten: %#v", exact) + } + }) +} + +func TestCrossPlatformCoverageLegacyMigrationPersistenceErrors(t *testing.T) { + oldLoad := profilesLoad + oldSave := profilesSave + oldLoadLegacy := profilesLoadLegacy + oldSaveCorp := profilesSaveCorp + oldLoadCorp := profilesLoadCorp + oldLoadIdentity := profilesLoadIdentity + oldSaveIdentity := profilesSaveIdentity + t.Cleanup(func() { + profilesLoad = oldLoad + profilesSave = oldSave + profilesLoadLegacy = oldLoadLegacy + profilesSaveCorp = oldSaveCorp + profilesLoadCorp = oldLoadCorp + profilesLoadIdentity = oldLoadIdentity + profilesSaveIdentity = oldSaveIdentity + }) + + fail := errors.New("legacy migration persistence failed") + baseConfig := func(version int) *ProfilesConfig { + return &ProfilesConfig{ + Version: version, + Profiles: []Profile{{ + Name: "Legacy User", CorpID: "ding_legacy_error", UserID: "legacy-user", + }}, + } + } + profilesSave = func(string, *ProfilesConfig) error { return nil } + profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound } + profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, ErrTokenDataNotFound } + profilesSaveIdentity = func(string, string, *TokenData) error { return nil } + + t.Run("global compatibility slot read", func(t *testing.T) { + profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(1), nil } + profilesLoadLegacy = func() (*TokenData, error) { return nil, fail } + profilesSaveCorp = func(string, *TokenData) error { return nil } + if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) { + t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail) + } + }) + + t.Run("restored organization slot write", func(t *testing.T) { + profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(1), nil } + profilesLoadLegacy = func() (*TokenData, error) { + return &TokenData{CorpID: "ding_legacy_error", AccessToken: "legacy-access"}, nil + } + profilesSaveCorp = func(string, *TokenData) error { return fail } + if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) { + t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail) + } + }) + + t.Run("repaired identity slot write", func(t *testing.T) { + profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(profilesVersion), nil } + profilesLoadCorp = func(string) (*TokenData, error) { + return &TokenData{CorpID: "ding_legacy_error", AccessToken: "legacy-access"}, nil + } + profilesSaveIdentity = func(string, string, *TokenData) error { return fail } + if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) { + t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail) + } + }) + + t.Run("partial v2 identity slot read", func(t *testing.T) { + profilesLoad = func(string) (*ProfilesConfig, error) { return baseConfig(profilesVersion), nil } + profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound } + profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, fail } + if err := ensureProfilesMigrationLocked("cfg"); !errors.Is(err, fail) { + t.Fatalf("ensureProfilesMigrationLocked() error = %v, want %v", err, fail) + } + }) +} + +type historicalV1Profile struct { + name string + corpID string + corpName string + userID string + userName string + clientID string +} + +type historicalV1052Organization struct { + corpID string + corpName string + userID string + userName string + accessToken string +} + +func seedHistoricalV1052MultiOrganizationState(t *testing.T, configDir string) []historicalV1052Organization { + t.Helper() + organizations := []historicalV1052Organization{ + {corpID: "ding_v1052_a", corpName: "V1052 Org A", userID: "legacy-user-v1052-a", userName: "V1052 User A", accessToken: "v1052-access-a"}, + {corpID: "ding_v1052_b", corpName: "V1052 Org B", userID: "legacy-user-v1052-b", userName: "V1052 User B", accessToken: "v1052-access-b"}, + {corpID: "ding_v1052_c", corpName: "V1052 Org C", userID: "legacy-user-v1052-c", userName: "V1052 User C", accessToken: "v1052-access-c"}, + } + profiles := make([]historicalV1Profile, 0, len(organizations)) + for _, organization := range organizations { + profiles = append(profiles, historicalV1Profile{ + name: organization.corpName, + corpID: organization.corpID, + corpName: organization.corpName, + userID: organization.userID, + userName: organization.userName, + clientID: "ding-client-v1052", + }) + } + writeHistoricalV1Profiles( + t, + configDir, + profiles, + organizations[0].corpID, + organizations[0].corpID, + organizations[1].corpID, + ) + + for _, organization := range organizations { + // v1.0.52 MCP responses commonly omitted userId while profiles.json + // retained a uniquely known identity. + seedHistoricalTokenSlot(t, TokenAccountForCorpID(organization.corpID), historicalTokenJSON( + t, + organization.accessToken, + organization.corpID, + organization.corpName, + "", + "", + )) + } + // v1.0.52 also mirrored the selected organization into the global account. + current := organizations[1] + seedHistoricalTokenSlot(t, keychain.AccountToken, historicalTokenJSON( + t, + current.accessToken, + current.corpID, + current.corpName, + "", + "", + )) + return organizations +} + +func assertHistoricalV1052IdentitySlots( + t *testing.T, + organizations []historicalV1052Organization, + accessOverrides map[string]string, +) { + t.Helper() + for _, organization := range organizations { + migrated, err := LoadTokenDataKeychainForIdentity(organization.corpID, organization.userID) + if err != nil { + t.Errorf("LoadTokenDataKeychainForIdentity(%q, %q) error = %v", organization.corpID, organization.userID, err) + continue + } + wantAccess := organization.accessToken + if override := accessOverrides[organization.corpID]; override != "" { + wantAccess = override + } + if migrated.AccessToken != wantAccess || + migrated.CorpID != organization.corpID || + migrated.UserID != organization.userID { + t.Errorf( + "migrated token for %q = %#v, want access=%q userId=%q", + organization.corpID, + migrated, + wantAccess, + organization.userID, + ) + } + if accessOverrides[organization.corpID] == "" && + (migrated.RefreshToken != "refresh-"+organization.accessToken || + migrated.PersistentCode != "persistent-"+organization.accessToken || + migrated.ClientID != "ding-client-historical" || + migrated.Source != "mcp") { + t.Errorf("historical token fields for %q were not preserved: %#v", organization.corpID, migrated) + } + } +} + +func historicalTokenJSON(t *testing.T, accessToken, corpID, corpName, userID, userName string) string { + t.Helper() + fixture := map[string]any{ + "access_token": accessToken, + "refresh_token": "refresh-" + accessToken, + "persistent_code": "persistent-" + accessToken, + "expires_at": "2030-01-02T03:04:05Z", + "refresh_expires_at": "2030-02-02T03:04:05Z", + "corp_id": corpID, + "corp_name": corpName, + "client_id": "ding-client-historical", + "source": "mcp", + } + if userID != "" { + fixture["user_id"] = userID + } + if userName != "" { + fixture["user_name"] = userName + } + data, err := json.MarshalIndent(fixture, "", " ") + if err != nil { + t.Fatalf("marshal historical token fixture: %v", err) + } + return string(data) +} + +func writeHistoricalV1Profiles( + t *testing.T, + configDir string, + profiles []historicalV1Profile, + primaryProfile string, + previousProfile string, + currentProfile string, +) { + t.Helper() + rawProfiles := make([]map[string]any, 0, len(profiles)) + for _, profile := range profiles { + rawProfiles = append(rawProfiles, map[string]any{ + "name": profile.name, + "corpId": profile.corpID, + "corpName": profile.corpName, + "userId": profile.userID, + "userName": profile.userName, + "clientId": profile.clientID, + "status": "active", + "expiresAt": "2030-01-02T03:04:05Z", + "refreshExpAt": "2030-02-02T03:04:05Z", + }) + } + fixture := map[string]any{ + "version": 1, + "primaryProfile": primaryProfile, + "currentProfile": currentProfile, + "previousProfile": previousProfile, + "profiles": rawProfiles, + } + data, err := json.MarshalIndent(fixture, "", " ") + if err != nil { + t.Fatalf("marshal historical profiles fixture: %v", err) + } + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatalf("create historical config directory: %v", err) + } + if err := os.WriteFile(ProfilesPath(configDir), append(data, '\n'), 0o600); err != nil { + t.Fatalf("write historical profiles.json: %v", err) + } +} + +func seedHistoricalTokenSlot(t *testing.T, account, raw string) { + t.Helper() + if err := keychain.Set(keychain.Service, account, raw); err != nil { + t.Fatalf("seed historical keychain account %q: %v", account, err) + } +} + +func cleanupHistoricalKeychain(t *testing.T) { + t.Helper() + t.Setenv(keychain.DisableKeychainEnv, "1") + cleanupKeychain(t) +} diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index 61e91ceb..08ba949f 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -290,6 +290,24 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) { return data, nil } +const legacyMCPRefreshRejectedCode = "invalidParameter.authCode.notFound" + +// MCPTokenExchangeError preserves the backend business code so refresh callers +// can distinguish a legacy credential that requires a new authorization from +// transient transport failures. +type MCPTokenExchangeError struct { + Code string + Message string +} + +func (e *MCPTokenExchangeError) Error() string { + return fmt.Sprintf("MCP token exchange failed: %s - %s", e.Code, e.Message) +} + +func (e *MCPTokenExchangeError) requiresReauthorization() bool { + return strings.TrimSpace(e.Code) == legacyMCPRefreshRejectedCode +} + // parseMCPTokenResponse parses token response from MCP proxy. // MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."} func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) { @@ -313,7 +331,7 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) { } // Check for error response if resp.ErrorCode != "" || resp.ErrorMsg != "" { - return nil, fmt.Errorf("MCP token exchange failed: %s - %s", resp.ErrorCode, resp.ErrorMsg) + return nil, &MCPTokenExchangeError{Code: resp.ErrorCode, Message: resp.ErrorMsg} } if resp.AccessToken == "" { return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body)) diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 1e5bebe0..90912bfd 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -24,6 +24,7 @@ import ( "net" "net/http" "os" + "strconv" "strings" "sync" "time" @@ -662,6 +663,7 @@ func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error } return nil, fmt.Errorf("load access token: %w", err) } + profileSelector := StableTokenProfileSelector(p.configDir, data) // Fast path: access_token still valid — no lock needed. if data.IsAccessTokenValid() { @@ -678,14 +680,26 @@ func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error // refresh credential. Keep the profile active so a long-running source // can retry after backoff. Terminal and unknown failures remain fatal. if ClassifyRefreshFailure(rErr) != RefreshFailureTransient { - _ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired) + _ = oauthMarkProfile(p.configDir, profileSelector, ProfileStatusExpired) } if p.logger != nil { p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr) } + var exchangeErr *MCPTokenExchangeError + if errors.As(rErr, &exchangeErr) && exchangeErr.requiresReauthorization() { + command := "dws auth login" + if profileSelector != "" { + command += " --profile " + strconv.Quote(profileSelector) + } + return nil, fmt.Errorf( + "旧版登录态已无法由当前认证服务刷新;本地 profile 已保留,请运行 %s 完成一次重新授权: %w", + command, + rErr, + ) + } return nil, fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr) } else { - _ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired) + _ = oauthMarkProfile(p.configDir, profileSelector, ProfileStatusExpired) } return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound) @@ -817,9 +831,12 @@ func (p *OAuthProvider) prepareLoginToken(ctx context.Context, tokenData *TokenD return fmt.Errorf("resolve login identity: %w", err) } } - if strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" { - return fmt.Errorf("resolve login identity: userId is required for corpId %q", tokenData.CorpID) - } + // v1.0.52 and earlier deliberately persisted the freshly exchanged token + // before best-effort contact enrichment. External-worker accounts can have a + // valid organization token while contact cannot return a userId, so rejecting + // that shape here makes an otherwise successful reauthorization impossible. + // SaveTokenData remains the safety boundary: an unresolved organization token + // cannot overwrite an organization that already has exact account identities. return nil } diff --git a/internal/auth/oauth_provider_coverage_test.go b/internal/auth/oauth_provider_coverage_test.go index 6c9269b8..d1de7ea1 100644 --- a/internal/auth/oauth_provider_coverage_test.go +++ b/internal/auth/oauth_provider_coverage_test.go @@ -685,7 +685,10 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) { resetAppConfigCache() oauthHTTPClient = mcpSrv.Client() mcpProvider := &OAuthProvider{configDir: configDir, httpClient: mcpSrv.Client()} - mcpOriginal := &TokenData{ClientID: "mcp-client", Source: "mcp", RefreshToken: "refresh", CorpID: "corp"} + mcpOriginal := &TokenData{ + ClientID: "mcp-client", Source: "mcp", RefreshToken: "refresh", + CorpID: "corp", UserID: "user", + } if updated, err := mcpProvider.refreshViaMCP(context.Background(), mcpOriginal); err != nil || updated.Source != "mcp" { t.Fatalf("MCP refresh = %#v, %v", updated, err) } diff --git a/internal/auth/profiles.go b/internal/auth/profiles.go index 074ec7a1..43559db8 100644 --- a/internal/auth/profiles.go +++ b/internal/auth/profiles.go @@ -15,6 +15,7 @@ package auth import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -75,8 +76,9 @@ func withProfilesLock(configDir string, fn func() error) error { } const ( - profilesJSONFile = "profiles.json" - profilesVersion = 2 + profilesJSONFile = "profiles.json" + profilesVersion = 2 + unresolvedProfileSelectorPrefix = "@legacy/" ) const ( @@ -240,6 +242,9 @@ func ensureProfilesMigrationLocked(configDir string) error { cfg.OrgCurrentProfiles = make(map[string]string) } orgTokens := make(map[string]*TokenData) + var legacyToken *TokenData + var legacyTokenErr error + legacyTokenLoaded := false for i := range cfg.Profiles { p := &cfg.Profiles[i] corpID := strings.TrimSpace(p.CorpID) @@ -255,12 +260,75 @@ func ensureProfilesMigrationLocked(configDir string) error { if loadErr != nil { token = nil } + var v2RepairProfile *Profile + if !legacySelectionState && errors.Is(loadErr, ErrTokenDataNotFound) { + v2RepairProfile = uniqueV2GlobalRepairProfile(cfg, corpID) + if v2RepairProfile != nil && strings.TrimSpace(v2RepairProfile.UserID) != "" { + _, identityErr := profilesLoadIdentity(corpID, v2RepairProfile.UserID) + switch { + case identityErr == nil: + // The exact identity is already usable. Do not let a stale + // global compatibility mirror recreate the organization slot. + v2RepairProfile = nil + case !errors.Is(identityErr, ErrTokenDataNotFound): + return identityErr + } + } + } + if (legacySelectionState || v2RepairProfile != nil) && errors.Is(loadErr, ErrTokenDataNotFound) { + // v1.0.50/1.0.51 installations can retain the selected + // organization only in the global compatibility slot. Consult + // that slot while migrating v1, or while repairing a non-empty + // v2 registry left half-migrated by an earlier CLI. The v2 path + // additionally requires one unambiguous profile, a missing exact + // slot when its userId is known, and a non-conflicting token userId. + // Persist the untouched organization mirror before identity + // enrichment below. + if !legacyTokenLoaded { + legacyToken, legacyTokenErr = profilesLoadLegacy() + legacyTokenLoaded = true + } + if legacyTokenErr != nil && !errors.Is(legacyTokenErr, ErrTokenDataNotFound) { + return legacyTokenErr + } + legacyMatchesProfile := legacySelectionState || + legacyTokenMatchesV2RepairProfile(legacyToken, v2RepairProfile) + if legacyTokenErr == nil && + legacyToken != nil && + strings.TrimSpace(legacyToken.CorpID) == corpID && + legacyMatchesProfile { + token = legacyToken + if err := profilesSaveCorp(corpID, token); err != nil { + return err + } + } + } orgToken = token orgTokens[corpID] = orgToken } if orgToken == nil { continue } + // v1.0.52 stored one token per organization. Some of those token blobs + // predate userId persistence even though profiles.json already recorded + // the account identity. Version 2 loads exact identities and therefore + // cannot safely use an organization mirror with no userId. A single + // profile with a known userId makes that association unambiguous, + // including when an earlier migration already bumped profiles.json to v2 + // but failed before writing the identity slot. Enrich only the copy saved + // to that exact slot; never infer an identity for an organization with + // multiple accounts. + identityToken := orgToken + if strings.TrimSpace(orgToken.UserID) == "" && + strings.TrimSpace(p.UserID) != "" && + len(profilesForCorpID(cfg, corpID)) == 1 { + enriched := *orgToken + enriched.UserID = strings.TrimSpace(p.UserID) + if strings.TrimSpace(enriched.UserName) == "" { + enriched.UserName = strings.TrimSpace(p.UserName) + } + identityToken = &enriched + } if strings.TrimSpace(p.UserID) == "" && strings.TrimSpace(orgToken.UserID) != "" { if existing := findExactProfile(cfg, corpID, orgToken.UserID); existing != nil && existing != p { p.CorpID = "" @@ -273,12 +341,12 @@ func ensureProfilesMigrationLocked(configDir string) error { } changed = true } - if strings.TrimSpace(p.UserID) == "" || strings.TrimSpace(orgToken.UserID) != strings.TrimSpace(p.UserID) { + if strings.TrimSpace(p.UserID) == "" || strings.TrimSpace(identityToken.UserID) != strings.TrimSpace(p.UserID) { continue } _, identityErr := profilesLoadIdentity(corpID, p.UserID) if errors.Is(identityErr, ErrTokenDataNotFound) { - if err := profilesSaveIdentity(corpID, p.UserID, orgToken); err != nil { + if err := profilesSaveIdentity(corpID, p.UserID, identityToken); err != nil { return err } } else if identityErr != nil { @@ -324,6 +392,10 @@ func ensureProfilesMigrationLocked(configDir string) error { cfg.CurrentProfile = exact changed = true } + if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" && exact != cfg.PrimaryProfile { + cfg.PrimaryProfile = exact + changed = true + } if legacySelectionState && cfg.CurrentProfile == "" { if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" { cfg.CurrentProfile = exact @@ -351,6 +423,29 @@ func ensureProfilesMigrationLocked(configDir string) error { return nil } +// uniqueV2GlobalRepairProfile returns the only profile that may safely be +// recovered from the legacy global token mirror. A v2 registry with multiple +// accounts in one organization is deliberately ineligible, even if one token +// happens to carry a matching userId: the global slot is a mutable compatibility +// mirror and is not authoritative account-selection state. A sole unresolved +// profile is eligible only for organization-slot repair; the token matcher below +// rejects any global token that tries to attach a userId to it. +func uniqueV2GlobalRepairProfile(cfg *ProfilesConfig, corpID string) *Profile { + profiles := profilesForCorpID(cfg, corpID) + if len(profiles) != 1 { + return nil + } + return profiles[0] +} + +func legacyTokenMatchesV2RepairProfile(data *TokenData, profile *Profile) bool { + if data == nil || strings.TrimSpace(data.CorpID) != strings.TrimSpace(profile.CorpID) { + return false + } + tokenUserID := strings.TrimSpace(data.UserID) + return tokenUserID == "" || tokenUserID == strings.TrimSpace(profile.UserID) +} + // UpsertProfileFromToken updates profiles.json after a successful login or refresh. func UpsertProfileFromToken(configDir string, data *TokenData) error { return UpsertProfileFromTokenWithCurrent(configDir, data, true) @@ -392,7 +487,11 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile) } idx := profileIndexByIdentity(cfg, corpID, userID) - if idx < 0 && userID != "" { + if idx < 0 && userID != "" && len(profilesForCorpID(cfg, corpID)) == 1 { + // Upgrade an organization-scoped v1 profile only when it is the sole + // account in that organization. If exact identities already coexist + // with a blank profile, consuming the blank profile here would silently + // discard that unresolved historical account. idx = legacyProfileIndexByCorpID(cfg, corpID) } if idx < 0 { @@ -409,6 +508,7 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa UpdatedAt: now, } cfg.Profiles = append(cfg.Profiles, profile) + idx = len(cfg.Profiles) - 1 } else { p := &cfg.Profiles[idx] if userID != "" { @@ -431,17 +531,33 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa p.LastUsedAt = now p.UpdatedAt = now } + storedProfile := &cfg.Profiles[idx] + if userID == "" && len(profilesForCorpID(cfg, corpID)) > 1 && + (strings.TrimSpace(storedProfile.Name) == corpID || profileNameTakenByOtherIdentity(cfg, storedProfile.Name, corpID, "")) { + // A blank profile needs a stable name when exact identities coexist in + // the same organization; the corpId selector denotes the organization + // as a whole and is therefore not an exact account selector. + storedProfile.Name = chooseProfileName(cfg, data) + } if makeCurrent { - newSelector := profileSelector(corpID, userID) - if previousCurrent != nil && ProfileSelector(*previousCurrent) != newSelector { - cfg.PreviousProfile = ProfileSelector(*previousCurrent) + newSelector := storedProfileSelector(cfg, storedProfile) + if previousCurrent != nil && storedProfileSelector(cfg, previousCurrent) != newSelector { + cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent) } cfg.CurrentProfile = newSelector - setOrgCurrentProfile(cfg, corpID, newSelector) + if userID == "" { + delete(cfg.OrgCurrentProfiles, corpID) + } else { + setOrgCurrentProfile(cfg, corpID, newSelector) + } } if cfg.CurrentProfile == "" { - cfg.CurrentProfile = profileSelector(corpID, userID) - setOrgCurrentProfile(cfg, corpID, cfg.CurrentProfile) + cfg.CurrentProfile = storedProfileSelector(cfg, storedProfile) + if userID == "" { + delete(cfg.OrgCurrentProfiles, corpID) + } else { + setOrgCurrentProfile(cfg, corpID, cfg.CurrentProfile) + } } return profilesSave(configDir, cfg) } @@ -452,6 +568,87 @@ func ProfileSelector(profile Profile) string { return profileSelector(profile.CorpID, profile.UserID) } +// storedProfileSelector returns a selector that remains exact inside +// profiles.json. A blank userId has only an organization selector in the +// public compatibility surface; when other accounts share that organization, +// use the profile's unique local name so current/previous pointers do not +// accidentally resolve to an exact account through OrgCurrentProfiles. +func storedProfileSelector(cfg *ProfilesConfig, profile *Profile) string { + if profile == nil { + return "" + } + if strings.TrimSpace(profile.UserID) != "" { + return ProfileSelector(*profile) + } + if cfg == nil { + return strings.TrimSpace(profile.CorpID) + } + if len(profilesForCorpID(cfg, profile.CorpID)) <= 1 { + return strings.TrimSpace(profile.CorpID) + } + name := strings.TrimSpace(profile.Name) + if localProfileSelectorIsSafe(cfg, profile, name) { + return name + } + return unresolvedProfileSelector(profile.CorpID) +} + +// ProfileSelectionSelector returns the stable selector used for one profile. +// Exact identities use corpId:userId. A historical profile without userId +// keeps the organization selector while it is the only account, and otherwise +// uses either an unambiguous local name or a reserved, reversible selector. +func ProfileSelectionSelector(profile Profile, cfg *ProfilesConfig) string { + return storedProfileSelector(cfg, &profile) +} + +func localProfileSelectorIsSafe(cfg *ProfilesConfig, profile *Profile, name string) bool { + if cfg == nil || profile == nil { + return false + } + name = strings.TrimSpace(name) + if name == "" || strings.Contains(name, ":") || strings.HasPrefix(name, unresolvedProfileSelectorPrefix) { + return false + } + nameMatches := 0 + for i := range cfg.Profiles { + candidate := &cfg.Profiles[i] + if strings.TrimSpace(candidate.Name) == name { + nameMatches++ + } + // Organization selectors are resolved before ordinary local names. + // Never persist a local selector that can be captured by that grammar. + if strings.TrimSpace(candidate.CorpID) == name || strings.TrimSpace(candidate.CorpName) == name { + return false + } + } + return nameMatches == 1 +} + +func unresolvedProfileSelector(corpID string) string { + corpID = strings.TrimSpace(corpID) + if corpID == "" { + return "" + } + return unresolvedProfileSelectorPrefix + base64.RawURLEncoding.EncodeToString([]byte(corpID)) +} + +func parseUnresolvedProfileSelector(selector string) (string, bool) { + selector = strings.TrimSpace(selector) + if !strings.HasPrefix(selector, unresolvedProfileSelectorPrefix) { + return "", false + } + encoded := strings.TrimPrefix(selector, unresolvedProfileSelectorPrefix) + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return "", false + } + corpID := strings.TrimSpace(string(decoded)) + if corpID == "" || unresolvedProfileSelector(corpID) != selector { + return "", false + } + return corpID, true +} + // TokenProfileSelector returns the exact identity selector for token data when // its userId is known, otherwise it returns the historical corpId selector. func TokenProfileSelector(data *TokenData) string { @@ -461,6 +658,34 @@ func TokenProfileSelector(data *TokenData) string { return profileSelector(data.CorpID, data.UserID) } +// StableTokenProfileSelector preserves the exact selector that loaded a token. +// This matters for an unresolved historical account sharing an organization +// with exact identities: reducing its selector to corpId would follow +// OrgCurrentProfiles and could mark or reauthorize a different account. +func StableTokenProfileSelector(configDir string, data *TokenData) string { + if selector := strings.TrimSpace(RuntimeProfile()); selector != "" { + return selector + } + fallback := TokenProfileSelector(data) + if data == nil { + return fallback + } + cfg, err := LoadProfiles(configDir) + if err != nil || cfg == nil || strings.TrimSpace(cfg.CurrentProfile) == "" { + return fallback + } + selector := canonicalStoredSelector(cfg, cfg.CurrentProfile) + if selector == "" { + selector = strings.TrimSpace(cfg.CurrentProfile) + } + profile, _, err := resolveProfileSelection(configDir, cfg, selector) + if err != nil || profile == nil || + !sameProfileIdentity(profile.CorpID, profile.UserID, data.CorpID, data.UserID) { + return fallback + } + return storedProfileSelector(cfg, profile) +} + func profileSelector(corpID, userID string) string { corpID = strings.TrimSpace(corpID) userID = strings.TrimSpace(userID) @@ -609,7 +834,8 @@ func setCurrentProfileLocked(configDir, selector string) (*Profile, error) { return nil, err } originalCfg := cloneProfilesConfig(cfg) - mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID) + syncOrganization := shouldSyncOrganizationMirror(cfg, *p) + mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization) if err != nil { return nil, err } @@ -617,20 +843,26 @@ func setCurrentProfileLocked(configDir, selector string) (*Profile, error) { if strings.TrimSpace(cfg.CurrentProfile) != "" { previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile) } - storedSelector := ProfileSelector(*p) + storedSelector := storedProfileSelector(cfg, p) if cfg.CurrentProfile != storedSelector { if previousCurrent != nil { - cfg.PreviousProfile = ProfileSelector(*previousCurrent) + cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent) } cfg.CurrentProfile = storedSelector } - setOrgCurrentProfile(cfg, p.CorpID, storedSelector) + if strings.TrimSpace(p.UserID) == "" { + delete(cfg.OrgCurrentProfiles, strings.TrimSpace(p.CorpID)) + } else { + setOrgCurrentProfile(cfg, p.CorpID, storedSelector) + } touchProfileUsage(p) if err := profilesSave(configDir, cfg); err != nil { return nil, err } - if err := syncOrganizationTokenMirrorForProfile(*p); err != nil { - return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err) + if syncOrganization { + if err := syncOrganizationTokenMirrorForProfile(*p); err != nil { + return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err) + } } if err := profilesSyncLegacyMirror(configDir); err != nil { return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err) @@ -669,7 +901,8 @@ func usePreviousProfileLocked(configDir string) (*Profile, error) { return nil, fmt.Errorf("resolve previous profile %q: %w", prev, err) } originalCfg := cloneProfilesConfig(cfg) - mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID) + syncOrganization := shouldSyncOrganizationMirror(cfg, *p) + mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization) if err != nil { return nil, err } @@ -677,19 +910,25 @@ func usePreviousProfileLocked(configDir string) (*Profile, error) { if strings.TrimSpace(cfg.CurrentProfile) != "" { current, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile) } - cfg.CurrentProfile = ProfileSelector(*p) + cfg.CurrentProfile = storedProfileSelector(cfg, p) if current != nil { - cfg.PreviousProfile = ProfileSelector(*current) + cfg.PreviousProfile = storedProfileSelector(cfg, current) } else { cfg.PreviousProfile = "" } - setOrgCurrentProfile(cfg, p.CorpID, ProfileSelector(*p)) + if strings.TrimSpace(p.UserID) == "" { + delete(cfg.OrgCurrentProfiles, strings.TrimSpace(p.CorpID)) + } else { + setOrgCurrentProfile(cfg, p.CorpID, ProfileSelector(*p)) + } touchProfileUsage(p) if err := profilesSave(configDir, cfg); err != nil { return nil, err } - if err := syncOrganizationTokenMirrorForProfile(*p); err != nil { - return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err) + if syncOrganization { + if err := syncOrganizationTokenMirrorForProfile(*p); err != nil { + return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err) + } } if err := profilesSyncLegacyMirror(configDir); err != nil { return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err) @@ -733,6 +972,21 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) { return nil, err } removed := *p + originalCurrentSelector := strings.TrimSpace(cfg.CurrentProfile) + originalOrganizationCurrent := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(removed.CorpID)]) + pointers := []*string{&cfg.PrimaryProfile, &cfg.CurrentProfile, &cfg.PreviousProfile} + pointerMatches := make([]bool, len(pointers)) + for i, pointer := range pointers { + selected, _, resolveErr := resolveProfileSelection(configDir, cfg, *pointer) + if resolveErr == nil && selected != nil { + if exact { + pointerMatches[i] = + sameProfileIdentity(selected.CorpID, selected.UserID, removed.CorpID, removed.UserID) + } else { + pointerMatches[i] = strings.TrimSpace(selected.CorpID) == strings.TrimSpace(removed.CorpID) + } + } + } kept := cfg.Profiles[:0] for _, profile := range cfg.Profiles { remove := profile.CorpID == removed.CorpID @@ -748,7 +1002,7 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) { if exact && len(profilesForCorpID(cfg, removed.CorpID)) > 0 { remaining := profilesForCorpID(cfg, removed.CorpID) if len(remaining) == 1 { - replacementSelector = ProfileSelector(*remaining[0]) + replacementSelector = storedProfileSelector(cfg, remaining[0]) } } if exact { @@ -762,15 +1016,14 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) { } else { delete(cfg.OrgCurrentProfiles, removed.CorpID) } - pointers := []*string{&cfg.PrimaryProfile, &cfg.CurrentProfile, &cfg.PreviousProfile} - for _, pointer := range pointers { + for i, pointer := range pointers { if exact { - if selectorMatchesIdentity(*pointer, removed) { + if pointerMatches[i] { *pointer = replacementSelector } continue } - if selectorTargetsCorp(*pointer, removed.CorpID) { + if pointerMatches[i] || selectorTargetsCorp(*pointer, removed.CorpID) { *pointer = "" } } @@ -779,12 +1032,27 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) { cfg.CurrentProfile = previous cfg.PreviousProfile = "" } else if len(cfg.Profiles) == 1 { - cfg.CurrentProfile = ProfileSelector(cfg.Profiles[0]) + cfg.CurrentProfile = storedProfileSelector(cfg, &cfg.Profiles[0]) } } if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile { cfg.PreviousProfile = "" } + currentSelectionChanged := strings.TrimSpace(cfg.CurrentProfile) != originalCurrentSelector + organizationCurrentChanged := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(removed.CorpID)]) != originalOrganizationCurrent + if strings.TrimSpace(cfg.CurrentProfile) != "" { + if current, _, resolveErr := resolveProfileSelection(configDir, cfg, cfg.CurrentProfile); resolveErr == nil && current != nil { + if (currentSelectionChanged || organizationCurrentChanged) && + strings.TrimSpace(current.CorpID) == strings.TrimSpace(removed.CorpID) && + unresolvedProfileForCorp(cfg, removed.CorpID) != nil { + if strings.TrimSpace(current.UserID) == "" { + delete(cfg.OrgCurrentProfiles, strings.TrimSpace(current.CorpID)) + } else { + setOrgCurrentProfile(cfg, current.CorpID, storedProfileSelector(cfg, current)) + } + } + } + } if len(cfg.Profiles) == 0 { cfg.PrimaryProfile = "" cfg.CurrentProfile = "" @@ -808,6 +1076,9 @@ func selectorTargetsCorp(selector, corpID string) bool { if selector == corpID { return true } + if selectedCorpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved { + return selectedCorpID == corpID + } selectedCorpID, _, exact := ParseIdentitySelector(selector) return exact && selectedCorpID == corpID } @@ -856,12 +1127,16 @@ type profileSelectionMirrorSnapshot struct { marker tokenMarkerSnapshot } -func snapshotProfileSelectionMirrors(configDir, corpID string) (profileSelectionMirrorSnapshot, error) { - organization, err := snapshotTokenSlot(func() (*TokenData, error) { - return profilesLoadCorp(corpID) - }) - if err != nil { - return profileSelectionMirrorSnapshot{}, err +func snapshotProfileSelectionMirrors(configDir, corpID string, includeOrganization bool) (profileSelectionMirrorSnapshot, error) { + var organization tokenSlotSnapshot + if includeOrganization { + var err error + organization, err = snapshotTokenSlot(func() (*TokenData, error) { + return profilesLoadCorp(corpID) + }) + if err != nil { + return profileSelectionMirrorSnapshot{}, err + } } legacy, err := snapshotTokenSlot(profilesLoadLegacy) if err != nil { @@ -889,12 +1164,14 @@ func rollbackProfileSelection( if err := profilesSave(configDir, cloneProfilesConfig(cfg)); err != nil { rollbackErr = errors.Join(rollbackErr, err) } - if mirrors.organization.exists { - if err := profilesSaveCorp(corpID, mirrors.organization.token); err != nil { + if mirrors.organization.known { + if mirrors.organization.exists { + if err := profilesSaveCorp(corpID, mirrors.organization.token); err != nil { + rollbackErr = errors.Join(rollbackErr, err) + } + } else if err := profilesDeleteCorp(corpID); err != nil { rollbackErr = errors.Join(rollbackErr, err) } - } else if err := profilesDeleteCorp(corpID); err != nil { - rollbackErr = errors.Join(rollbackErr, err) } if mirrors.legacy.exists { if err := profilesSaveLegacy(mirrors.legacy.token); err != nil { @@ -975,33 +1252,48 @@ func syncOrganizationTokenMirrorForProfile(profile Profile) error { } func loadTokenForProfileIdentity(profile Profile) (*TokenData, error) { - if strings.TrimSpace(profile.UserID) != "" { - data, err := profilesLoadIdentity(profile.CorpID, profile.UserID) - if err == nil { - return data, nil - } - if !errors.Is(err, ErrTokenDataNotFound) { + if strings.TrimSpace(profile.UserID) == "" { + data, err := profilesLoadCorp(profile.CorpID) + if err != nil { return nil, err } - orgData, orgErr := profilesLoadCorp(profile.CorpID) - if orgErr != nil { - if errors.Is(orgErr, ErrTokenDataNotFound) { - return nil, err - } - return nil, orgErr + if data == nil { + return nil, ErrTokenDataNotFound } - if strings.TrimSpace(orgData.UserID) == "" { - return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", profile.CorpID, ProfileSelector(profile)) + if strings.TrimSpace(data.UserID) != "" { + return nil, fmt.Errorf( + "organization token mirror for corpId %q belongs to userId %q; cannot use it for unresolved profile %q", + profile.CorpID, + data.UserID, + profile.Name, + ) } - if strings.TrimSpace(orgData.UserID) != strings.TrimSpace(profile.UserID) { - return nil, err - } - if saveErr := profilesSaveIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil { - return nil, saveErr - } - return orgData, nil + return data, nil } - return profilesLoadCorp(profile.CorpID) + data, err := profilesLoadIdentity(profile.CorpID, profile.UserID) + if err == nil { + return data, nil + } + if !errors.Is(err, ErrTokenDataNotFound) { + return nil, err + } + orgData, orgErr := profilesLoadCorp(profile.CorpID) + if orgErr != nil { + if errors.Is(orgErr, ErrTokenDataNotFound) { + return nil, err + } + return nil, orgErr + } + if strings.TrimSpace(orgData.UserID) == "" { + return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", profile.CorpID, ProfileSelector(profile)) + } + if strings.TrimSpace(orgData.UserID) != strings.TrimSpace(profile.UserID) { + return nil, err + } + if saveErr := profilesSaveIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil { + return nil, saveErr + } + return orgData, nil } func normalizeProfilesConfig(cfg *ProfilesConfig) { @@ -1127,6 +1419,12 @@ func resolveProfileSelection(_ string, cfg *ProfilesConfig, selector string) (*P if selector == "" { return nil, false, fmt.Errorf("profile selector is empty") } + if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved { + if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil { + return profile, true, nil + } + return nil, true, fmt.Errorf("historical profile for organization %q not found", corpID) + } if organization, account, compound := ParseIdentitySelector(selector); compound { corpID, err := resolveOrganizationCorpID(cfg, organization) @@ -1218,6 +1516,12 @@ func resolveProfileDeletionSelection(cfg *ProfilesConfig, selector string) (*Pro if selector == "" { return nil, false, fmt.Errorf("profile selector is empty") } + if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved { + if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil { + return profile, true, nil + } + return nil, true, fmt.Errorf("historical profile for organization %q not found", corpID) + } if _, _, compound := ParseIdentitySelector(selector); compound { return resolveProfileSelection("", cfg, selector) } @@ -1303,6 +1607,12 @@ func resolveOrganizationDefault(cfg *ProfilesConfig, corpID, displaySelector str return p, false, nil } } + if unresolved := unresolvedProfileForCorp(cfg, corpID); unresolved != nil { + // With no exact organization-current selection, the organization slot + // belongs to the sole unresolved historical account. Do not choose an + // arbitrary exact identity merely because it shares the corpId. + return unresolved, false, nil + } if len(profiles) == 1 { return profiles[0], false, nil } @@ -1314,12 +1624,18 @@ func resolveOrganizationDefault(cfg *ProfilesConfig, corpID, displaySelector str } func profileSelectorCandidates(profiles []*Profile) []string { + cfg := &ProfilesConfig{Profiles: make([]Profile, 0, len(profiles))} + for _, profile := range profiles { + if profile != nil { + cfg.Profiles = append(cfg.Profiles, *profile) + } + } candidates := make([]string, 0, len(profiles)) for _, p := range profiles { if p == nil { continue } - candidates = append(candidates, ProfileSelector(*p)) + candidates = append(candidates, storedProfileSelector(cfg, p)) } sort.Strings(candidates) return candidates @@ -1341,18 +1657,35 @@ func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string { if selector == "" { return "" } + if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved { + if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil { + return storedProfileSelector(cfg, profile) + } + return "" + } if corpID, userID, exact := ParseIdentitySelector(selector); exact { if p := findExactProfile(cfg, corpID, userID); p != nil { return ProfileSelector(*p) } + // A colon-containing legacy local name is recoverable only when it does + // not name a real exact identity. Exact corpId:userId always wins. + if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil { + return storedProfileSelector(cfg, profile) + } return "" } + // Older multi-account writers stored the unresolved profile's local name. + // Recover that intent before the organization grammar can capture a + // CorpName-like name, then persist the safe selector. + if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil { + return storedProfileSelector(cfg, profile) + } if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 { if exact := exactProfileSelectorForCorp(cfg, selector, cfg.OrgCurrentProfiles[selector]); exact != "" { return exact } if len(profiles) == 1 { - return ProfileSelector(*profiles[0]) + return storedProfileSelector(cfg, profiles[0]) } return "" } @@ -1360,7 +1693,7 @@ func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string { if err != nil || p == nil { return "" } - return ProfileSelector(*p) + return storedProfileSelector(cfg, p) } func setOrgCurrentProfile(cfg *ProfilesConfig, corpID, selector string) { @@ -1445,6 +1778,51 @@ func profilesForCorpID(cfg *ProfilesConfig, corpID string) []*Profile { return result } +func unresolvedProfileForCorp(cfg *ProfilesConfig, corpID string) *Profile { + if cfg == nil { + return nil + } + corpID = strings.TrimSpace(corpID) + for i := range cfg.Profiles { + profile := &cfg.Profiles[i] + if strings.TrimSpace(profile.CorpID) == corpID && strings.TrimSpace(profile.UserID) == "" { + return profile + } + } + return nil +} + +func unresolvedProfileForLocalName(cfg *ProfilesConfig, name string) *Profile { + if cfg == nil { + return nil + } + name = strings.TrimSpace(name) + if name == "" { + return nil + } + var match *Profile + for i := range cfg.Profiles { + profile := &cfg.Profiles[i] + if strings.TrimSpace(profile.UserID) != "" || strings.TrimSpace(profile.Name) != name || + len(profilesForCorpID(cfg, profile.CorpID)) <= 1 { + continue + } + if match != nil { + return nil + } + match = profile + } + return match +} + +// When a blank profile coexists with exact accounts, the organization slot is +// that unresolved profile's only canonical credential. Exact identities must +// remain in their identity slots and may still become global current without +// overwriting the organization slot. +func shouldSyncOrganizationMirror(cfg *ProfilesConfig, profile Profile) bool { + return strings.TrimSpace(profile.UserID) == "" || unresolvedProfileForCorp(cfg, profile.CorpID) == nil +} + func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool { if cfg == nil { return false @@ -1453,6 +1831,12 @@ func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool { if selector == "" { return false } + if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved { + return unresolvedProfileForCorp(cfg, corpID) != nil + } + if unresolvedProfileForLocalName(cfg, selector) != nil { + return true + } if corpID, userID, exact := ParseIdentitySelector(selector); exact { if findExactProfile(cfg, corpID, userID) != nil { return true diff --git a/internal/auth/refresh_failure_test.go b/internal/auth/refresh_failure_test.go index e27a4920..243dd24c 100644 --- a/internal/auth/refresh_failure_test.go +++ b/internal/auth/refresh_failure_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" @@ -152,4 +153,95 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR if markCalls != 1 { t.Fatalf("terminal refresh marked profile expired %d times, want 1", markCalls) } + + oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, &MCPTokenExchangeError{ + Code: legacyMCPRefreshRejectedCode, + Message: "不合法的临时授权码", + } + } + _, err := provider.GetTokenSnapshot(context.Background()) + if err == nil || !strings.Contains(err.Error(), `dws auth login --profile "corp:user"`) || + !strings.Contains(err.Error(), legacyMCPRefreshRejectedCode) { + t.Fatalf("legacy MCP refresh guidance = %v", err) + } + var exchangeErr *MCPTokenExchangeError + if !errors.As(err, &exchangeErr) || !exchangeErr.requiresReauthorization() { + t.Fatalf("legacy MCP refresh cause was not preserved: %v", err) + } + if markCalls != 2 { + t.Fatalf("legacy MCP rejection marked profile expired %d times, want 2", markCalls) + } + + SetRuntimeProfile("External Worker") + _, err = provider.GetTokenSnapshot(context.Background()) + SetRuntimeProfile("") + if err == nil || !strings.Contains(err.Error(), `dws auth login --profile "External Worker"`) { + t.Fatalf("legacy MCP refresh guidance did not quote spaced selector: %v", err) + } + if markCalls != 3 { + t.Fatalf("spaced legacy MCP rejection marked profile expired %d times, want 3", markCalls) + } +} + +func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) { + fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true) + expired := *fixture.blankToken + expired.ExpiresAt = time.Now().Add(-time.Hour) + expired.RefreshExpAt = time.Now().Add(time.Hour) + + oldLoad := oauthLoadToken + oldLoadLocked := oauthLoadTokenLocked + oldAcquire := oauthAcquireLock + oldRefresh := oauthRefreshToken + oldMark := oauthMarkProfile + oldEdition := edition.Get() + t.Cleanup(func() { + oauthLoadToken = oldLoad + oauthLoadTokenLocked = oldLoadLocked + oauthAcquireLock = oldAcquire + oauthRefreshToken = oldRefresh + oauthMarkProfile = oldMark + edition.Override(oldEdition) + }) + edition.Override(&edition.Hooks{}) + oauthLoadToken = func(string) (*TokenData, error) { return &expired, nil } + oauthLoadTokenLocked = func(string, string) (*TokenData, error) { return &expired, nil } + oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil } + oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, &MCPTokenExchangeError{ + Code: legacyMCPRefreshRejectedCode, + Message: "legacy refresh rejected", + } + } + var markedSelector string + oauthMarkProfile = func(configDir, selector, status string) error { + markedSelector = selector + return MarkProfileStatus(configDir, selector, status) + } + + provider := NewOAuthProvider(fixture.configDir, nil) + _, err := provider.GetTokenSnapshot(context.Background()) + if err == nil || !strings.Contains(err.Error(), "--profile "+strconv.Quote(fixture.blankSelector)) { + t.Fatalf("legacy blank refresh guidance = %v, want selector %q", err, fixture.blankSelector) + } + if markedSelector != fixture.blankSelector { + t.Fatalf("marked selector = %q, want blank %q", markedSelector, fixture.blankSelector) + } + cfg, loadErr := LoadProfiles(fixture.configDir) + if loadErr != nil { + t.Fatalf("LoadProfiles() error = %v", loadErr) + } + for _, profile := range cfg.Profiles { + switch profile.UserID { + case "": + if profile.Status != ProfileStatusExpired { + t.Fatalf("blank profile status = %q, want expired", profile.Status) + } + case fixture.exactUserID: + if profile.Status != ProfileStatusActive { + t.Fatalf("exact profile status = %q, want active", profile.Status) + } + } + } } diff --git a/internal/auth/token.go b/internal/auth/token.go index 518733b1..70e964c7 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -59,27 +59,28 @@ var ( profile, _, err := resolveProfileForLoadLocked(configDir, selector) return profile, err } - tokenResolveDeletion = resolveProfileDeletionSelection - tokenResolveSelection = resolveProfileSelection - tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked - tokenRemoveProfile = removeProfileLocked - tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked - tokenSyncOrganizationMirror = syncOrganizationTokenMirrorForProfile - tokenLoadProfiles = LoadProfiles - tokenSaveProfiles = SaveProfiles - tokenWriteMarker = WriteTokenMarker - tokenWriteManualMarker = WriteManualTokenMarker - tokenDeleteMarker = DeleteTokenMarker - tokenParseURL = url.Parse - tokenNewRequest = http.NewRequestWithContext - tokenDefaultConfigDir = getDefaultConfigDir - tokenLoadData = LoadTokenData - tokenRevokeURL = GetRevokeTokenURL - tokenMCPBaseURL = GetMCPBaseURL - tokenLogoutURL = LogoutURL - tokenLogoutContinueURL = LogoutContinueURL - tokenLogoutHTTPClient = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} - tokenRevokeHTTPClient = &http.Client{Timeout: 10 * time.Second} + tokenResolveDeletion = resolveProfileDeletionSelection + tokenResolveSelection = resolveProfileSelection + tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked + tokenRemoveProfile = removeProfileLocked + tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked + tokenSyncOrganizationMirror = syncOrganizationTokenMirrorForProfile + tokenLoadProfiles = LoadProfiles + tokenEnsureProfilesMigration = ensureProfilesMigrationLocked + tokenSaveProfiles = SaveProfiles + tokenWriteMarker = WriteTokenMarker + tokenWriteManualMarker = WriteManualTokenMarker + tokenDeleteMarker = DeleteTokenMarker + tokenParseURL = url.Parse + tokenNewRequest = http.NewRequestWithContext + tokenDefaultConfigDir = getDefaultConfigDir + tokenLoadData = LoadTokenData + tokenRevokeURL = GetRevokeTokenURL + tokenMCPBaseURL = GetMCPBaseURL + tokenLogoutURL = LogoutURL + tokenLogoutContinueURL = LogoutContinueURL + tokenLogoutHTTPClient = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + tokenRevokeHTTPClient = &http.Client{Timeout: 10 * time.Second} ) // TokenData holds the OAuth token set persisted to disk. @@ -96,6 +97,10 @@ type TokenData struct { ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh UpdatedAt string `json:"updated_at,omitempty"` Source string `json:"source,omitempty"` + // LegacyOrgScopedProfile is an in-memory destination for an explicitly + // matched historical profile whose userId was never resolved. It is never + // persisted as token material. + LegacyOrgScopedProfile string `json:"-"` } // IsAccessTokenValid returns true if the access token has not expired. @@ -235,16 +240,44 @@ func saveTokenDataLocked(configDir string, data *TokenData) error { if err != nil { return err } + // A login may be the first operation after upgrading. Finish a v1 + // registry migration before an upsert can raise profiles.json to v2; + // otherwise untouched organizations would permanently lose their chance + // to receive exact identity token slots. Do not re-run v2 repair here: + // refresh has already rotated the remote credential at this point, and an + // unrelated damaged identity slot must not prevent the new token from + // being committed. Normal load/preflight paths repair v2 before exchange. + if cfg.Version < profilesVersion { + if err := tokenEnsureProfilesMigration(configDir); err != nil { + return err + } + cfg, err = tokenLoadProfiles(configDir) + if err != nil { + return err + } + } if err := ensureProfilesWritable(cfg); err != nil { return err } runtimeSelector := strings.TrimSpace(RuntimeProfile()) - makeCurrent := runtimeSelector == "" + persistenceSelector := runtimeSelector + if persistenceSelector == "" && userID == "" { + persistenceSelector = strings.TrimSpace(data.LegacyOrgScopedProfile) + } + makeCurrent := persistenceSelector == "" exactSelector := profileSelector(corpID, userID) - mirrorOrg := makeCurrent || - exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) == exactSelector existingIdentity := profileIndexByIdentity(cfg, corpID, userID) >= 0 - upgradesLegacyProfile := !existingIdentity && userID != "" && legacyProfileIndexByCorpID(cfg, corpID) >= 0 + upgradesLegacyProfile := !existingIdentity && userID != "" && + len(profilesForCorpID(cfg, corpID)) == 1 && legacyProfileIndexByCorpID(cfg, corpID) >= 0 + preserveUnresolvedOrg := userID != "" && unresolvedProfileForCorp(cfg, corpID) != nil && !upgradesLegacyProfile + // An unresolved legacy identity has no exact slot by definition. Its + // organization slot is therefore the canonical credential even when an + // explicit runtime profile means this login should not become global + // current. When it coexists with exact accounts, those accounts remain + // identity-slot-only so profile switching or refresh cannot overwrite the + // unresolved account's sole credential. + mirrorOrg := userID == "" || (!preserveUnresolvedOrg && (makeCurrent || + exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) == exactSelector)) logging.AuthDebug( "auth.token.persist.plan", "corp_id", corpID, @@ -255,6 +288,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error { "upgrades_legacy_profile", upgradesLegacyProfile, "profiles_before", len(cfg.Profiles), "runtime_profile", runtimeSelector, + "persistence_profile", persistenceSelector, "write_identity_slot", userID != "", "write_org_mirror", mirrorOrg, "write_global_mirror", makeCurrent, @@ -277,12 +311,6 @@ func saveTokenDataLocked(configDir string, data *TokenData) error { if err := tokenSaveKeychainForIdentity(corpID, userID, data); err != nil { return rollback(err) } - } else { - for _, profile := range cfg.Profiles { - if strings.TrimSpace(profile.CorpID) == corpID && strings.TrimSpace(profile.UserID) != "" { - return fmt.Errorf("cannot store profile for corpId %q without userId because account identities already exist", corpID) - } - } } if err := tokenUpsertProfile(configDir, data, makeCurrent); err != nil { return rollback(err) @@ -459,7 +487,22 @@ func loadTokenDataForProfileLocked(configDir, profile string) (*TokenData, error func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) { if strings.TrimSpace(profile.UserID) == "" { - return tokenLoadKeychainForCorpID(profile.CorpID) + data, err := tokenLoadKeychainForCorpID(profile.CorpID) + if err != nil { + return nil, err + } + if data == nil { + return nil, ErrTokenDataNotFound + } + if strings.TrimSpace(data.UserID) != "" { + return nil, fmt.Errorf( + "organization token mirror for corpId %q belongs to userId %q; cannot use it for unresolved profile %q", + profile.CorpID, + data.UserID, + profile.Name, + ) + } + return data, nil } data, err := tokenLoadKeychainIdentity(profile.CorpID, profile.UserID) if err == nil { @@ -561,12 +604,20 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error { removeSelector := removed.CorpID orgCurrent := false if exact { - removeSelector = ProfileSelector(removed) - orgCurrent = exactProfileSelectorForCorp( - cfg, - removed.CorpID, - cfg.OrgCurrentProfiles[removed.CorpID], - ) == ProfileSelector(removed) + if strings.TrimSpace(removed.UserID) == "" { + // A blank profile is exact only when it was selected by its unique + // local name. Converting it back to corpId here would turn a + // one-profile logout into whole-organization deletion. + removeSelector = effectiveSelector + orgCurrent = true + } else { + removeSelector = ProfileSelector(removed) + orgCurrent = exactProfileSelectorForCorp( + cfg, + removed.CorpID, + cfg.OrgCurrentProfiles[removed.CorpID], + ) == ProfileSelector(removed) + } } if _, err := tokenRemoveProfile(configDir, removeSelector); err != nil { return err @@ -592,7 +643,8 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error { return rollback(loadErr) } replacementSelector := updated.OrgCurrentProfiles[removed.CorpID] - if exact && replacementSelector != "" { + preserveUnresolvedOrg := unresolvedProfileForCorp(updated, removed.CorpID) != nil + if exact && replacementSelector != "" && !preserveUnresolvedOrg { replacement, _, resolveErr := tokenResolveSelection(configDir, updated, replacementSelector) if resolveErr != nil { return rollback(resolveErr) @@ -600,8 +652,10 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error { if err := tokenSyncOrganizationMirror(*replacement); err != nil { return rollback(err) } - } else if err := tokenDeleteKeychainForCorpID(removed.CorpID); err != nil { - return rollback(err) + } else if !preserveUnresolvedOrg { + if err := tokenDeleteKeychainForCorpID(removed.CorpID); err != nil { + return rollback(err) + } } } preserveManualDefault := markerSnapshot.known && diff --git a/internal/auth/token_preflight_test.go b/internal/auth/token_preflight_test.go index 059fd309..c2540932 100644 --- a/internal/auth/token_preflight_test.go +++ b/internal/auth/token_preflight_test.go @@ -210,6 +210,45 @@ func TestExactNonOrgCurrentRefreshIgnoresUnreadableOrgMirror(t *testing.T) { } } +func TestCrossPlatformCoverageExactRefreshAndSwitchIgnoreUnreadableReservedBlankOrgSlot(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + fixture := seedLegacyBlankAndExactIdentitySlots(t) + if err := os.WriteFile(profileCiphertextPathForTest(fixture.corpID), []byte("corrupt reserved blank slot"), 0o600); err != nil { + t.Fatalf("WriteFile(reserved blank ciphertext) error = %v", err) + } + + SetRuntimeProfile("") + if err := preflightTokenRefreshPersistence(fixture.configDir, fixture.beta); err != nil { + t.Fatalf("preflightTokenRefreshPersistence(current exact with reserved blank) error = %v", err) + } + refreshed := *fixture.beta + refreshed.AccessToken = "at_identity_beta_refreshed" + if err := SaveTokenData(fixture.configDir, &refreshed); err != nil { + t.Fatalf("SaveTokenData(current exact with reserved blank) error = %v", err) + } + if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" { + t.Fatalf("reserved blank slot changed during exact refresh: %q, %v", raw, err) + } + + if selected, err := SetCurrentProfile(fixture.configDir, profileSelector(fixture.alpha.CorpID, fixture.alpha.UserID)); err != nil || selected.UserID != fixture.alpha.UserID { + t.Fatalf("SetCurrentProfile(exact with reserved blank) = %#v, %v", selected, err) + } + if selected, err := UsePreviousProfile(fixture.configDir); err != nil || selected.UserID != fixture.beta.UserID { + t.Fatalf("UsePreviousProfile(exact with reserved blank) = %#v, %v", selected, err) + } + if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" { + t.Fatalf("reserved blank slot changed during exact switches: %q, %v", raw, err) + } + + blankRefresh := *fixture.blank + blankRefresh.LegacyOrgScopedProfile = fixture.blankName + SetRuntimeProfile(fixture.blankName) + if err := preflightTokenRefreshPersistence(fixture.configDir, &blankRefresh); err == nil || + !strings.Contains(err.Error(), "profile token slot") { + t.Fatalf("blank refresh preflight error = %v, want unreadable reserved slot", err) + } +} + func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go index bce09e8a..d186fefd 100644 --- a/internal/auth/token_test.go +++ b/internal/auth/token_test.go @@ -17,6 +17,7 @@ import ( "bytes" "context" "errors" + "fmt" "log/slog" "os" "path/filepath" @@ -1614,7 +1615,7 @@ func TestOAuthLoginIdentityFailureLeavesExistingSameCorpAccountUntouched(t *test } } -func TestOAuthLoginMissingIdentityCannotOverwriteLegacySameCorpAccount(t *testing.T) { +func TestCrossPlatformCoverageOAuthLoginMissingIdentityRefreshesLegacySameCorpAccount(t *testing.T) { cleanupKeychain(t) configDir := t.TempDir() @@ -1628,18 +1629,136 @@ func TestOAuthLoginMissingIdentityCannotOverwriteLegacySameCorpAccount(t *testin incoming := testToken("at_incoming", "corp_same", "同一组织") incoming.UserID = "" incoming.UserName = "" + // Explicit profile logins do not become global current. A legacy identity + // still has to replace its organization slot because no exact slot exists. + SetRuntimeProfile("corp_same") provider := NewOAuthProvider(configDir, nil) provider.IdentityEnricher = func(context.Context, *TokenData) error { return nil } - if err := provider.persistLoginToken(context.Background(), incoming); err == nil { - t.Fatal("persistLoginToken() error = nil, want missing identity failure") + if err := provider.persistLoginToken(context.Background(), incoming); err != nil { + t.Fatalf("persistLoginToken() error = %v", err) } loaded, err := LoadTokenDataForProfile(configDir, "corp_same") if err != nil { t.Fatalf("LoadTokenDataForProfile(existing) error = %v", err) } - if loaded.AccessToken != "at_existing" { - t.Fatalf("legacy account changed after missing identity: %#v", loaded) + if loaded.AccessToken != "at_incoming" || loaded.UserID != "" { + t.Fatalf("legacy account was not refreshed in its organization slot: %#v", loaded) + } +} + +func TestCrossPlatformCoverageOAuthLoginMissingIdentityUsesSeparateOrgSlotBesideExactAccount(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + + existing := testToken("at_existing_exact", "corp_same", "同一组织") + existing.UserID = "user_1" + if err := SaveTokenData(configDir, existing); err != nil { + t.Fatalf("SaveTokenData(existing) error = %v", err) + } + + incoming := testToken("at_incoming_unknown", "corp_same", "同一组织") + incoming.UserID = "" + incoming.UserName = "" + provider := NewOAuthProvider(configDir, nil) + provider.IdentityEnricher = func(context.Context, *TokenData) error { return nil } + if err := provider.persistLoginToken(context.Background(), incoming); err != nil { + t.Fatalf("persistLoginToken(unresolved organization identity) error = %v", err) + } + + loaded, err := LoadTokenDataKeychainForIdentity(existing.CorpID, existing.UserID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity(existing) error = %v", err) + } + if loaded.AccessToken != existing.AccessToken || loaded.UserID != existing.UserID { + t.Fatalf("exact account changed after unresolved login: %#v", loaded) + } + organization, err := LoadTokenDataKeychainForCorpID(existing.CorpID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForCorpID(unresolved) error = %v", err) + } + if organization.AccessToken != incoming.AccessToken || organization.UserID != "" { + t.Fatalf("unresolved login was not isolated in the organization slot: %#v", organization) + } + current, err := LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData(current unresolved organization identity) error = %v", err) + } + if current.AccessToken != incoming.AccessToken || current.UserID != "" { + t.Fatalf("current login did not resolve the isolated organization slot: %#v", current) + } +} + +func TestCrossPlatformCoverageOAuthLoginUpdatesExplicitLegacyProfileBesideExactAccount(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + + exact := testToken("at_exact", "corp_same", "同一组织") + exact.UserID = "user_1" + if err := SaveTokenData(configDir, exact); err != nil { + t.Fatalf("SaveTokenData(exact) error = %v", err) + } + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + cfg.Profiles = append(cfg.Profiles, Profile{ + Name: "external-worker", CorpID: exact.CorpID, CorpName: exact.CorpName, + }) + cfg.Profiles = append(cfg.Profiles, Profile{ + Name: "unrelated-worker", CorpID: "corp_other", UserID: "other-user", + }) + if err := SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles(with legacy profile) error = %v", err) + } + + incoming := testToken("at_external_new", exact.CorpID, exact.CorpName) + incoming.UserID = "" + incoming.UserName = "" + incoming.LegacyOrgScopedProfile = "external-worker" + provider := NewOAuthProvider(configDir, nil) + provider.IdentityEnricher = func(context.Context, *TokenData) error { return nil } + if err := provider.persistLoginToken(context.Background(), incoming); err != nil { + t.Fatalf("persistLoginToken(explicit legacy profile) error = %v", err) + } + + loadedExact, err := LoadTokenDataKeychainForIdentity(exact.CorpID, exact.UserID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity(exact) error = %v", err) + } + if loadedExact.AccessToken != exact.AccessToken { + t.Fatalf("exact identity token was overwritten: %#v", loadedExact) + } + orgToken, err := LoadTokenDataKeychainForCorpID(exact.CorpID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err) + } + if orgToken.AccessToken != incoming.AccessToken || orgToken.UserID != "" { + t.Fatalf("legacy organization slot was not updated: %#v", orgToken) + } + global, err := LoadTokenDataKeychain() + if err != nil { + t.Fatalf("LoadTokenDataKeychain() error = %v", err) + } + if global.AccessToken != exact.AccessToken || global.UserID != exact.UserID { + t.Fatalf("explicit legacy login replaced the global exact account: %#v", global) + } + + // A later refresh reloads TokenData from disk, so the transient destination + // is gone. The existing runtime --profile selector remains sufficient. + refreshed := testToken("at_external_refreshed", exact.CorpID, exact.CorpName) + refreshed.UserID = "" + refreshed.UserName = "" + SetRuntimeProfile("external-worker") + if err := provider.persistLoginToken(context.Background(), refreshed); err != nil { + t.Fatalf("persistLoginToken(runtime legacy profile) error = %v", err) + } + orgToken, err = LoadTokenDataKeychainForCorpID(exact.CorpID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForCorpID(after refresh) error = %v", err) + } + if orgToken.AccessToken != refreshed.AccessToken || orgToken.UserID != "" { + t.Fatalf("runtime-selected legacy organization slot was not refreshed: %#v", orgToken) } } @@ -1820,6 +1939,348 @@ func TestLegacyCorpScopedTokenMigratesToIdentitySlot(t *testing.T) { } } +func TestCrossPlatformCoverageV1052CorpTokenWithoutUserIDMigratesFromSingleProfileIdentity(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + legacy := testToken("at_v1052", "corp_legacy", "Legacy Org") + legacy.UserID = "" + legacy.UserName = "" + if err := SaveTokenDataKeychainForCorpID(legacy.CorpID, legacy); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) + } + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: 1, + PrimaryProfile: legacy.CorpID, + CurrentProfile: legacy.CorpID, + Profiles: []Profile{{ + Name: legacy.CorpName, + CorpID: legacy.CorpID, + CorpName: legacy.CorpName, + UserID: "user_v1052", + UserName: "Legacy User", + }}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + // The ordinary first read after upgrading performs the migration; no + // explicit repair command or login flow is required. + loaded, err := LoadTokenDataForProfile(configDir, "corp_legacy:user_v1052") + if err != nil { + t.Fatalf("LoadTokenDataForProfile() error = %v", err) + } + if loaded.AccessToken != legacy.AccessToken || loaded.UserID != "user_v1052" { + t.Fatalf("loaded token = %#v", loaded) + } + migrated, err := LoadTokenDataKeychainForIdentity(legacy.CorpID, "user_v1052") + if err != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err) + } + if migrated.AccessToken != legacy.AccessToken || migrated.UserID != "user_v1052" || migrated.UserName != "Legacy User" { + t.Fatalf("migrated token = %#v", migrated) + } + // Keep the organization mirror byte-compatible with the old writer. The + // inferred identity belongs only in the exact identity slot. + orgMirror, err := LoadTokenDataKeychainForCorpID(legacy.CorpID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err) + } + if orgMirror.UserID != "" { + t.Fatalf("legacy organization mirror userId = %q, want empty", orgMirror.UserID) + } +} + +func TestCrossPlatformCoverageV1052CorpTokenWithoutUserIDDoesNotGuessAmongMultipleProfiles(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + legacy := testToken("at_v1052", "corp_shared", "Shared Org") + legacy.UserID = "" + if err := SaveTokenDataKeychainForCorpID(legacy.CorpID, legacy); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) + } + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: 1, + CurrentProfile: legacy.CorpID, + Profiles: []Profile{ + {Name: "one", CorpID: legacy.CorpID, UserID: "user_one"}, + {Name: "two", CorpID: legacy.CorpID, UserID: "user_two"}, + }, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if TokenDataExistsKeychainForIdentity(legacy.CorpID, "user_one") || + TokenDataExistsKeychainForIdentity(legacy.CorpID, "user_two") { + t.Fatal("ambiguous organization token was copied into an identity slot") + } +} + +func TestCrossPlatformCoverageV1052MigrationCompletesEveryOrganizationOnFirstRead(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + tokens := []*TokenData{ + testToken("at_org_one", "corp_one", "Org One"), + testToken("at_org_two", "corp_two", "Org Two"), + testToken("at_org_three", "corp_three", "Org Three"), + } + profiles := make([]Profile, 0, len(tokens)) + for i, token := range tokens { + token.UserID = "" + token.UserName = "" + if err := SaveTokenDataKeychainForCorpID(token.CorpID, token); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID(%q) error = %v", token.CorpID, err) + } + profiles = append(profiles, Profile{ + Name: token.CorpName, + CorpID: token.CorpID, + CorpName: token.CorpName, + UserID: fmt.Sprintf("user_%d", i+1), + UserName: fmt.Sprintf("User %d", i+1), + }) + } + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: 1, + PrimaryProfile: profiles[0].CorpID, + CurrentProfile: profiles[1].CorpID, + PreviousProfile: profiles[0].CorpID, + Profiles: profiles, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + // Reading only the current organization must perform the one-time schema + // migration for the complete v1 registry, including inactive organizations. + loaded, err := LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + if loaded.CorpID != profiles[1].CorpID || loaded.UserID != profiles[1].UserID { + t.Fatalf("current token after migration = %#v", loaded) + } + for i, profile := range profiles { + migrated, loadErr := LoadTokenDataKeychainForIdentity(profile.CorpID, profile.UserID) + if loadErr != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity(%q, %q) error = %v", profile.CorpID, profile.UserID, loadErr) + } + if migrated.AccessToken != tokens[i].AccessToken || + migrated.UserID != profile.UserID || + migrated.UserName != profile.UserName { + t.Fatalf("migrated token for %q = %#v", profile.CorpID, migrated) + } + } +} + +func TestCrossPlatformCoverageV2DamagedRegistryRepairsSingleIdentitySlot(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + legacy := testToken("at_v2_repair", "corp_v2_repair", "V2 Repair Org") + legacy.UserID = "" + legacy.UserName = "" + if err := SaveTokenDataKeychainForCorpID(legacy.CorpID, legacy); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) + } + profile := Profile{ + Name: legacy.CorpName, + CorpID: legacy.CorpID, + CorpName: legacy.CorpName, + UserID: "user_v2_repair", + UserName: "V2 Repair User", + } + selector := ProfileSelector(profile) + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + PrimaryProfile: selector, + CurrentProfile: selector, + OrgCurrentProfiles: map[string]string{legacy.CorpID: selector}, + Profiles: []Profile{profile}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + if TokenDataExistsKeychainForIdentity(legacy.CorpID, profile.UserID) { + t.Fatal("identity slot unexpectedly existed before repair") + } + + loaded, err := LoadTokenDataForProfile(configDir, selector) + if err != nil { + t.Fatalf("LoadTokenDataForProfile() error = %v", err) + } + if loaded.AccessToken != legacy.AccessToken || loaded.RefreshToken != legacy.RefreshToken || + loaded.UserID != profile.UserID || loaded.UserName != profile.UserName { + t.Fatalf("loaded repaired token = %#v", loaded) + } + + // Re-running migration is intentionally harmless: an existing exact slot + // wins and the old organization mirror remains untouched. + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("second EnsureProfilesMigration() error = %v", err) + } + repaired, err := LoadTokenDataKeychainForIdentity(legacy.CorpID, profile.UserID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err) + } + if repaired.AccessToken != legacy.AccessToken || repaired.RefreshToken != legacy.RefreshToken || + repaired.UserID != profile.UserID || repaired.UserName != profile.UserName { + t.Fatalf("identity token after idempotent repair = %#v", repaired) + } + orgMirror, err := LoadTokenDataKeychainForCorpID(legacy.CorpID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err) + } + if orgMirror.UserID != "" || orgMirror.UserName != "" { + t.Fatalf("organization mirror changed during repair = %#v", orgMirror) + } +} + +func TestCrossPlatformCoverageV2DamagedRegistryRepairsEveryOrganization(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + tokens := []*TokenData{ + testToken("at_v2_one", "corp_v2_one", "V2 Org One"), + testToken("at_v2_two", "corp_v2_two", "V2 Org Two"), + testToken("at_v2_three", "corp_v2_three", "V2 Org Three"), + } + profiles := make([]Profile, 0, len(tokens)) + orgCurrent := make(map[string]string, len(tokens)) + for i, token := range tokens { + token.UserID = "" + token.UserName = "" + if err := SaveTokenDataKeychainForCorpID(token.CorpID, token); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID(%q) error = %v", token.CorpID, err) + } + profile := Profile{ + Name: token.CorpName, + CorpID: token.CorpID, + CorpName: token.CorpName, + UserID: fmt.Sprintf("user_v2_%d", i+1), + UserName: fmt.Sprintf("V2 User %d", i+1), + } + profiles = append(profiles, profile) + orgCurrent[token.CorpID] = ProfileSelector(profile) + } + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + PrimaryProfile: ProfileSelector(profiles[0]), + CurrentProfile: ProfileSelector(profiles[1]), + PreviousProfile: ProfileSelector(profiles[0]), + OrgCurrentProfiles: orgCurrent, + Profiles: profiles, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + // A single ordinary read repairs all organizations, including inactive + // ones, even though the registry already advertises the v2 schema. + loaded, err := LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + if loaded.CorpID != profiles[1].CorpID || loaded.UserID != profiles[1].UserID { + t.Fatalf("current token after repair = %#v", loaded) + } + for i, profile := range profiles { + repaired, loadErr := LoadTokenDataKeychainForIdentity(profile.CorpID, profile.UserID) + if loadErr != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity(%q, %q) error = %v", profile.CorpID, profile.UserID, loadErr) + } + if repaired.AccessToken != tokens[i].AccessToken || repaired.RefreshToken != tokens[i].RefreshToken || + repaired.UserID != profile.UserID || repaired.UserName != profile.UserName { + t.Fatalf("repaired token for %q = %#v", profile.CorpID, repaired) + } + } +} + +func TestCrossPlatformCoverageV2DamagedRegistryDoesNotGuessIdentity(t *testing.T) { + t.Run("multiple profiles", func(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + legacy := testToken("at_v2_shared", "corp_v2_shared", "V2 Shared Org") + legacy.UserID = "" + if err := SaveTokenDataKeychainForCorpID(legacy.CorpID, legacy); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) + } + profiles := []Profile{ + {Name: "one", CorpID: legacy.CorpID, UserID: "user_one"}, + {Name: "two", CorpID: legacy.CorpID, UserID: "user_two"}, + } + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + Profiles: profiles, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + for _, profile := range profiles { + if TokenDataExistsKeychainForIdentity(profile.CorpID, profile.UserID) { + t.Fatalf("ambiguous organization token was copied to %q", ProfileSelector(profile)) + } + } + }) + + t.Run("mismatched token identity", func(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + orgToken := testToken("at_v2_mismatch", "corp_v2_mismatch", "V2 Mismatch Org") + orgToken.UserID = "different_user" + if err := SaveTokenDataKeychainForCorpID(orgToken.CorpID, orgToken); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) + } + profile := Profile{Name: "expected", CorpID: orgToken.CorpID, UserID: "expected_user"} + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{profile}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if TokenDataExistsKeychainForIdentity(profile.CorpID, profile.UserID) { + t.Fatal("mismatched organization token was copied into the profile identity slot") + } + }) +} + +func TestCrossPlatformCoverageV2DamagedRegistryDoesNotGuessBesideUnresolvedProfile(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + legacy := testToken("at_v2_unresolved", "corp_v2_unresolved", "V2 Unresolved Org") + legacy.UserID = "" + legacy.UserName = "" + if err := SaveTokenDataKeychainForCorpID(legacy.CorpID, legacy); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) + } + known := Profile{ + Name: "known", + CorpID: legacy.CorpID, + CorpName: legacy.CorpName, + UserID: "known_user", + UserName: "Known User", + } + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{ + {Name: "legacy-corp-only", CorpID: legacy.CorpID}, + known, + }, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if TokenDataExistsKeychainForIdentity(known.CorpID, known.UserID) { + t.Fatal("ambiguous organization token was copied beside an unresolved profile") + } +} + func TestTokenDataExistsKeychain(t *testing.T) { cleanupKeychain(t) @@ -1960,7 +2421,7 @@ func TestV2EmptyProfilesDoesNotRestoreLegacyToken(t *testing.T) { } } -func TestIdentityLoadRejectsOrganizationMirrorWithoutUserID(t *testing.T) { +func TestCrossPlatformCoverageIdentityLoadRepairsOrganizationMirrorWithoutUserID(t *testing.T) { cleanupKeychain(t) configDir := t.TempDir() if err := SaveProfiles(configDir, &ProfilesConfig{ @@ -1983,12 +2444,15 @@ func TestIdentityLoadRejectsOrganizationMirrorWithoutUserID(t *testing.T) { t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) } - _, err := LoadTokenDataForProfile(configDir, "corp_same:user_1") - if err == nil || !strings.Contains(err.Error(), "has no userId") { - t.Fatalf("LoadTokenDataForProfile() error = %v, want missing mirror identity error", err) + loaded, err := LoadTokenDataForProfile(configDir, "corp_same:user_1") + if err != nil { + t.Fatalf("LoadTokenDataForProfile() error = %v", err) } - if TokenDataExistsKeychainForIdentity("corp_same", "user_1") { - t.Fatal("organization mirror without userId was persisted as an identity token") + if loaded.AccessToken != legacy.AccessToken || loaded.UserID != "user_1" { + t.Fatalf("repaired identity token = %#v", loaded) + } + if !TokenDataExistsKeychainForIdentity("corp_same", "user_1") { + t.Fatal("organization mirror without userId was not repaired into an identity token") } } @@ -2095,7 +2559,7 @@ func TestMissingCurrentIdentityClearsLegacyMirror(t *testing.T) { } } -func TestOAuthLoginRequiresIdentityForFirstProfile(t *testing.T) { +func TestCrossPlatformCoverageOAuthLoginAllowsLegacyOrgScopedFirstProfile(t *testing.T) { cleanupKeychain(t) configDir := t.TempDir() incoming := testToken("at_missing_identity", "corp_first", "First Org") @@ -2104,12 +2568,50 @@ func TestOAuthLoginRequiresIdentityForFirstProfile(t *testing.T) { provider := NewOAuthProvider(configDir, nil) provider.IdentityEnricher = func(context.Context, *TokenData) error { return nil } - if err := provider.persistLoginToken(context.Background(), incoming); err == nil || - !strings.Contains(err.Error(), "userId is required") { - t.Fatalf("persistLoginToken() error = %v, want required userId", err) + if err := provider.persistLoginToken(context.Background(), incoming); err != nil { + t.Fatalf("persistLoginToken() error = %v", err) } - if TokenDataExistsKeychain() || TokenDataExistsKeychainForCorpID("corp_first") { - t.Fatal("missing-identity login persisted token data") + if !TokenDataExistsKeychain() || !TokenDataExistsKeychainForCorpID("corp_first") { + t.Fatal("legacy organization-scoped login did not persist token data") + } + loaded, err := LoadTokenDataForProfile(configDir, "corp_first") + if err != nil { + t.Fatalf("LoadTokenDataForProfile() error = %v", err) + } + if loaded.AccessToken != incoming.AccessToken || loaded.UserID != "" { + t.Fatalf("loaded organization-scoped token = %#v", loaded) + } +} + +func TestCrossPlatformCoverageSaveTokenMigrationErrors(t *testing.T) { + oldLoadProfiles := tokenLoadProfiles + oldEnsureMigration := tokenEnsureProfilesMigration + t.Cleanup(func() { + tokenLoadProfiles = oldLoadProfiles + tokenEnsureProfilesMigration = oldEnsureMigration + }) + + fail := errors.New("save-time legacy migration failed") + incoming := &TokenData{CorpID: "ding_legacy", AccessToken: "new-access"} + tokenLoadProfiles = func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: 1}, nil + } + tokenEnsureProfilesMigration = func(string) error { return fail } + if err := saveTokenDataLocked("cfg", incoming); !errors.Is(err, fail) { + t.Fatalf("saveTokenDataLocked(migration error) = %v, want %v", err, fail) + } + + loadCount := 0 + tokenLoadProfiles = func(string) (*ProfilesConfig, error) { + loadCount++ + if loadCount == 1 { + return &ProfilesConfig{Version: 1}, nil + } + return nil, fail + } + tokenEnsureProfilesMigration = func(string) error { return nil } + if err := saveTokenDataLocked("cfg", incoming); !errors.Is(err, fail) { + t.Fatalf("saveTokenDataLocked(post-migration reload error) = %v, want %v", err, fail) } } @@ -2130,6 +2632,447 @@ func TestDeleteAllTokenDataRemovesOrphanIdentitySlots(t *testing.T) { } } +type legacyBlankLifecycleFixture struct { + configDir string + corpID string + blankName string + blank *TokenData + alpha *TokenData + beta *TokenData +} + +func seedLegacyBlankAndExactIdentitySlots(t *testing.T) legacyBlankLifecycleFixture { + t.Helper() + cleanupKeychain(t) + configDir := t.TempDir() + corpID := "corp_lifecycle" + corpName := "Lifecycle Organization" + + alpha := testToken("at_identity_alpha", corpID, corpName) + alpha.UserID = "identity_alpha" + alpha.UserName = "Account Alpha" + if err := SaveTokenData(configDir, alpha); err != nil { + t.Fatalf("SaveTokenData(alpha) error = %v", err) + } + + blank := testToken("at_legacy_blank", corpID, corpName) + blank.UserID = "" + blank.UserName = "" + if err := SaveTokenData(configDir, blank); err != nil { + t.Fatalf("SaveTokenData(blank) error = %v", err) + } + + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles(after blank) error = %v", err) + } + blankName := unresolvedProfileNameForTest(t, cfg, corpID) + + beta := testToken("at_identity_beta", corpID, corpName) + beta.UserID = "identity_beta" + beta.UserName = "Account Beta" + if err := SaveTokenData(configDir, beta); err != nil { + t.Fatalf("SaveTokenData(beta) error = %v", err) + } + + return legacyBlankLifecycleFixture{ + configDir: configDir, + corpID: corpID, + blankName: blankName, + blank: blank, + alpha: alpha, + beta: beta, + } +} + +func unresolvedProfileNameForTest(t *testing.T, cfg *ProfilesConfig, corpID string) string { + t.Helper() + var name string + for i := range cfg.Profiles { + profile := cfg.Profiles[i] + if profile.CorpID != corpID || profile.UserID != "" { + continue + } + if name != "" { + t.Fatalf("multiple unresolved profiles for %q: %#v", corpID, cfg.Profiles) + } + name = profile.Name + } + if name == "" { + t.Fatalf("unresolved profile for %q not found: %#v", corpID, cfg.Profiles) + } + return name +} + +func assertIdentityTokenAccessForTest(t *testing.T, corpID, userID, wantAccess string) { + t.Helper() + loaded, err := LoadTokenDataKeychainForIdentity(corpID, userID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity(%q, %q) error = %v", corpID, userID, err) + } + if loaded.AccessToken != wantAccess || loaded.UserID != userID { + t.Fatalf("identity token %q:%q = %#v, want access %q", corpID, userID, loaded, wantAccess) + } +} + +func assertOrganizationTokenAccessForTest(t *testing.T, corpID, wantAccess, wantUserID string) { + t.Helper() + loaded, err := LoadTokenDataKeychainForCorpID(corpID) + if err != nil { + t.Fatalf("LoadTokenDataKeychainForCorpID(%q) error = %v", corpID, err) + } + if loaded.AccessToken != wantAccess || loaded.UserID != wantUserID { + t.Fatalf("organization token %q = %#v, want access %q and user %q", corpID, loaded, wantAccess, wantUserID) + } +} + +func TestCrossPlatformCoverageLegacyBlankAndExactIdentitySlotsPersistAcrossRepeatedSaves(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + + assertOrganizationTokenAccessForTest(t, fixture.corpID, fixture.blank.AccessToken, "") + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.alpha.UserID, fixture.alpha.AccessToken) + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.beta.UserID, fixture.beta.AccessToken) + + refreshedBlank := *fixture.blank + refreshedBlank.AccessToken = "at_legacy_blank_refreshed" + refreshedBlank.RefreshToken = "rt_legacy_blank_refreshed" + refreshedBlank.LegacyOrgScopedProfile = fixture.blankName + if err := SaveTokenData(fixture.configDir, &refreshedBlank); err != nil { + t.Fatalf("SaveTokenData(repeated blank) error = %v", err) + } + + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles(after repeated blank) error = %v", err) + } + if got := unresolvedProfileNameForTest(t, cfg, fixture.corpID); got != fixture.blankName { + t.Fatalf("blank profile name after repeated save = %q, want %q", got, fixture.blankName) + } + if got := len(profilesForCorpID(cfg, fixture.corpID)); got != 3 { + t.Fatalf("same-organization profiles after repeated blank save = %d, want 3: %#v", got, cfg.Profiles) + } + assertOrganizationTokenAccessForTest(t, fixture.corpID, refreshedBlank.AccessToken, "") + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.alpha.UserID, fixture.alpha.AccessToken) + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.beta.UserID, fixture.beta.AccessToken) + global, err := LoadTokenDataKeychain() + if err != nil { + t.Fatalf("LoadTokenDataKeychain() error = %v", err) + } + if global.AccessToken != fixture.beta.AccessToken || global.UserID != fixture.beta.UserID { + t.Fatalf("repeated blank save changed global exact account: %#v", global) + } +} + +func TestCrossPlatformCoverageSwitchFromLegacyBlankToExactPreservesBlankOrganizationSlot(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + + selectedBlank, err := SetCurrentProfile(fixture.configDir, fixture.blankName) + if err != nil { + t.Fatalf("SetCurrentProfile(blank) error = %v", err) + } + if selectedBlank.UserID != "" { + t.Fatalf("selected blank profile = %#v", selectedBlank) + } + assertOrganizationTokenAccessForTest(t, fixture.corpID, fixture.blank.AccessToken, "") + + selectedExact, err := SetCurrentProfile( + fixture.configDir, + ProfileSelector(Profile{CorpID: fixture.corpID, UserID: fixture.alpha.UserID}), + ) + if err != nil { + t.Fatalf("SetCurrentProfile(exact) error = %v", err) + } + if selectedExact.UserID != fixture.alpha.UserID { + t.Fatalf("selected exact profile = %#v", selectedExact) + } + assertOrganizationTokenAccessForTest(t, fixture.corpID, fixture.blank.AccessToken, "") + blankLoaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankName) + if err != nil { + t.Fatalf("LoadTokenDataForProfile(blank after exact switch) error = %v", err) + } + if blankLoaded.AccessToken != fixture.blank.AccessToken || blankLoaded.UserID != "" { + t.Fatalf("blank token after exact switch = %#v", blankLoaded) + } + global, err := LoadTokenDataKeychain() + if err != nil { + t.Fatalf("LoadTokenDataKeychain() error = %v", err) + } + if global.AccessToken != fixture.alpha.AccessToken || global.UserID != fixture.alpha.UserID { + t.Fatalf("global token after exact switch = %#v", global) + } +} + +func TestCrossPlatformCoverageExactIdentityRefreshDoesNotOverwriteLegacyBlankOrganizationSlot(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + refreshedAlpha := *fixture.alpha + refreshedAlpha.AccessToken = "at_identity_alpha_refreshed" + refreshedAlpha.RefreshToken = "rt_identity_alpha_refreshed" + + SetRuntimeProfile(ProfileSelector(Profile{CorpID: fixture.corpID, UserID: fixture.alpha.UserID})) + if err := SaveTokenData(fixture.configDir, &refreshedAlpha); err != nil { + t.Fatalf("SaveTokenData(exact refresh) error = %v", err) + } + SetRuntimeProfile("") + + assertOrganizationTokenAccessForTest(t, fixture.corpID, fixture.blank.AccessToken, "") + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.alpha.UserID, refreshedAlpha.AccessToken) + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.beta.UserID, fixture.beta.AccessToken) + global, err := LoadTokenDataKeychain() + if err != nil { + t.Fatalf("LoadTokenDataKeychain() error = %v", err) + } + if global.AccessToken != fixture.beta.AccessToken || global.UserID != fixture.beta.UserID { + t.Fatalf("non-current exact refresh changed global account: %#v", global) + } +} + +func TestCrossPlatformCoverageNonCurrentLegacyBlankRefreshDoesNotChangeGlobalExactAccount(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + refreshedBlank := *fixture.blank + refreshedBlank.AccessToken = "at_legacy_blank_noncurrent_refreshed" + refreshedBlank.RefreshToken = "rt_legacy_blank_noncurrent_refreshed" + + SetRuntimeProfile(fixture.blankName) + if err := SaveTokenData(fixture.configDir, &refreshedBlank); err != nil { + t.Fatalf("SaveTokenData(non-current blank refresh) error = %v", err) + } + SetRuntimeProfile("") + + assertOrganizationTokenAccessForTest(t, fixture.corpID, refreshedBlank.AccessToken, "") + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.alpha.UserID, fixture.alpha.AccessToken) + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.beta.UserID, fixture.beta.AccessToken) + global, err := LoadTokenDataKeychain() + if err != nil { + t.Fatalf("LoadTokenDataKeychain() error = %v", err) + } + if global.AccessToken != fixture.beta.AccessToken || global.UserID != fixture.beta.UserID { + t.Fatalf("non-current blank refresh changed global exact account: %#v", global) + } + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + wantCurrent := ProfileSelector(Profile{CorpID: fixture.corpID, UserID: fixture.beta.UserID}) + if cfg.CurrentProfile != wantCurrent { + t.Fatalf("current profile after non-current blank refresh = %q, want %q", cfg.CurrentProfile, wantCurrent) + } +} + +func TestCrossPlatformCoverageLegacyBlankAndExactIdentityDeletionIsolation(t *testing.T) { + t.Run("delete non-current exact identity", func(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + selector := ProfileSelector(Profile{CorpID: fixture.corpID, UserID: fixture.alpha.UserID}) + if err := DeleteTokenDataForProfile(fixture.configDir, selector); err != nil { + t.Fatalf("DeleteTokenDataForProfile(non-current exact) error = %v", err) + } + if TokenDataExistsKeychainForIdentity(fixture.corpID, fixture.alpha.UserID) { + t.Fatal("deleted non-current exact identity slot still exists") + } + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.beta.UserID, fixture.beta.AccessToken) + assertOrganizationTokenAccessForTest(t, fixture.corpID, fixture.blank.AccessToken, "") + blankLoaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankName) + if err != nil || blankLoaded.AccessToken != fixture.blank.AccessToken { + t.Fatalf("blank profile after non-current exact delete = %#v, %v", blankLoaded, err) + } + }) + + t.Run("delete current exact identity", func(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + if _, err := SetCurrentProfile(fixture.configDir, profileSelector(fixture.corpID, fixture.alpha.UserID)); err != nil { + t.Fatalf("SetCurrentProfile(alpha) error = %v", err) + } + if _, err := SetCurrentProfile(fixture.configDir, profileSelector(fixture.corpID, fixture.beta.UserID)); err != nil { + t.Fatalf("SetCurrentProfile(beta) error = %v", err) + } + selector := ProfileSelector(Profile{CorpID: fixture.corpID, UserID: fixture.beta.UserID}) + if err := DeleteTokenDataForProfile(fixture.configDir, selector); err != nil { + t.Fatalf("DeleteTokenDataForProfile(current exact) error = %v", err) + } + if TokenDataExistsKeychainForIdentity(fixture.corpID, fixture.beta.UserID) { + t.Fatal("deleted current exact identity slot still exists") + } + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.alpha.UserID, fixture.alpha.AccessToken) + assertOrganizationTokenAccessForTest(t, fixture.corpID, fixture.blank.AccessToken, "") + blankLoaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankName) + if err != nil || blankLoaded.AccessToken != fixture.blank.AccessToken { + t.Fatalf("blank profile after current exact delete = %#v, %v", blankLoaded, err) + } + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles(after current exact delete) error = %v", err) + } + wantCurrent := profileSelector(fixture.corpID, fixture.alpha.UserID) + if cfg.CurrentProfile != wantCurrent || cfg.OrgCurrentProfiles[fixture.corpID] != wantCurrent { + t.Fatalf("selection after current exact delete = current %q org %q, want %q", cfg.CurrentProfile, cfg.OrgCurrentProfiles[fixture.corpID], wantCurrent) + } + resolved, err := ResolveProfile(fixture.configDir, fixture.corpID) + if err != nil || resolved.UserID != fixture.alpha.UserID { + t.Fatalf("organization selector after current exact delete = %#v, %v", resolved, err) + } + }) + + t.Run("delete blank identity by unique name", func(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + if _, err := SetCurrentProfile(fixture.configDir, fixture.blankName); err != nil { + t.Fatalf("SetCurrentProfile(blank) error = %v", err) + } + if err := DeleteTokenDataForProfile(fixture.configDir, fixture.blankName); err != nil { + t.Fatalf("DeleteTokenDataForProfile(blank name) error = %v", err) + } + if _, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankName); err == nil { + t.Fatal("deleted blank profile is still loadable by name") + } + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.alpha.UserID, fixture.alpha.AccessToken) + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.beta.UserID, fixture.beta.AccessToken) + if TokenDataExistsKeychainForCorpID(fixture.corpID) { + t.Fatal("deleting current blank profile left an arbitrary exact organization mirror") + } + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles(after blank delete) error = %v", err) + } + if got := len(profilesForCorpID(cfg, fixture.corpID)); got != 2 { + t.Fatalf("same-organization profiles after blank delete = %d, want 2: %#v", got, cfg.Profiles) + } + wantCurrent := profileSelector(fixture.corpID, fixture.beta.UserID) + if cfg.CurrentProfile != wantCurrent || cfg.OrgCurrentProfiles[fixture.corpID] != "" { + t.Fatalf("selection after current blank delete = current %q org %q, want current %q with organization unselected", cfg.CurrentProfile, cfg.OrgCurrentProfiles[fixture.corpID], wantCurrent) + } + if resolved, err := ResolveProfile(fixture.configDir, fixture.corpID); err == nil || resolved != nil { + t.Fatalf("organization selector after current blank delete = %#v, %v; want explicit account requirement", resolved, err) + } + }) + + t.Run("delete entire organization only", func(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + other := testToken("at_other_identity", "corp_other_lifecycle", "Other Organization") + other.UserID = "identity_other" + other.UserName = "Other Account" + if err := SaveTokenData(fixture.configDir, other); err != nil { + t.Fatalf("SaveTokenData(other organization) error = %v", err) + } + + if err := DeleteTokenDataForProfile(fixture.configDir, fixture.corpID); err != nil { + t.Fatalf("DeleteTokenDataForProfile(entire organization) error = %v", err) + } + if TokenDataExistsKeychainForCorpID(fixture.corpID) || + TokenDataExistsKeychainForIdentity(fixture.corpID, fixture.alpha.UserID) || + TokenDataExistsKeychainForIdentity(fixture.corpID, fixture.beta.UserID) { + t.Fatal("organization deletion left one of its token slots behind") + } + assertIdentityTokenAccessForTest(t, other.CorpID, other.UserID, other.AccessToken) + assertOrganizationTokenAccessForTest(t, other.CorpID, other.AccessToken, other.UserID) + loadedOther, err := LoadTokenDataForProfile(fixture.configDir, ProfileSelector(Profile{ + CorpID: other.CorpID, + UserID: other.UserID, + })) + if err != nil || loadedOther.AccessToken != other.AccessToken { + t.Fatalf("unrelated organization after deletion = %#v, %v", loadedOther, err) + } + }) +} + +func TestCrossPlatformCoverageDeleteCurrentLegacyBlankOrganizationFallsBackToOtherOrganization(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + other := testToken("at_fallback_identity", "corp_fallback", "Fallback Organization") + other.UserID = "identity_fallback" + other.UserName = "Fallback Account" + if err := SaveTokenData(fixture.configDir, other); err != nil { + t.Fatalf("SaveTokenData(fallback organization) error = %v", err) + } + selected, err := SetCurrentProfile(fixture.configDir, fixture.blankName) + if err != nil { + t.Fatalf("SetCurrentProfile(blank local name) error = %v", err) + } + if selected.CorpID != fixture.corpID || selected.UserID != "" { + t.Fatalf("selected blank profile = %#v", selected) + } + + if err := DeleteTokenDataForProfile(fixture.configDir, fixture.corpID); err != nil { + t.Fatalf("DeleteTokenDataForProfile(blank organization) error = %v", err) + } + + if TokenDataExistsKeychainForCorpID(fixture.corpID) || + TokenDataExistsKeychainForIdentity(fixture.corpID, fixture.alpha.UserID) || + TokenDataExistsKeychainForIdentity(fixture.corpID, fixture.beta.UserID) { + t.Fatal("deleted blank organization left one of its token slots behind") + } + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles(after blank organization delete) error = %v", err) + } + wantCurrent := ProfileSelector(Profile{CorpID: other.CorpID, UserID: other.UserID}) + if cfg.CurrentProfile != wantCurrent { + t.Fatalf("current profile after blank organization delete = %q, want %q", cfg.CurrentProfile, wantCurrent) + } + if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != other.CorpID || cfg.Profiles[0].UserID != other.UserID { + t.Fatalf("profiles after blank organization delete = %#v, want only fallback account", cfg.Profiles) + } + assertIdentityTokenAccessForTest(t, other.CorpID, other.UserID, other.AccessToken) + assertOrganizationTokenAccessForTest(t, other.CorpID, other.AccessToken, other.UserID) + global, err := LoadTokenDataKeychain() + if err != nil { + t.Fatalf("LoadTokenDataKeychain(after blank organization delete) error = %v", err) + } + if global.AccessToken != other.AccessToken || global.UserID != other.UserID { + t.Fatalf("global token after blank organization delete = %#v, want fallback account", global) + } + loadedCurrent, err := LoadTokenData(fixture.configDir) + if err != nil { + t.Fatalf("LoadTokenData(after blank organization delete) error = %v", err) + } + if loadedCurrent.AccessToken != other.AccessToken || loadedCurrent.UserID != other.UserID { + t.Fatalf("current token after blank organization delete = %#v, want fallback account", loadedCurrent) + } +} + +func TestCrossPlatformCoverageKnownThirdIdentityDoesNotConsumeLegacyBlankProfile(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + gamma := testToken("at_identity_gamma", fixture.corpID, fixture.blank.CorpName) + gamma.UserID = "identity_gamma" + gamma.UserName = "Account Gamma" + if err := SaveTokenData(fixture.configDir, gamma); err != nil { + t.Fatalf("SaveTokenData(third exact identity) error = %v", err) + } + + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles(after third identity) error = %v", err) + } + if got := unresolvedProfileNameForTest(t, cfg, fixture.corpID); got != fixture.blankName { + t.Fatalf("third identity consumed or renamed blank profile: got %q, want %q", got, fixture.blankName) + } + if got := len(profilesForCorpID(cfg, fixture.corpID)); got != 4 { + t.Fatalf("same-organization profiles after third identity = %d, want 4: %#v", got, cfg.Profiles) + } + assertOrganizationTokenAccessForTest(t, fixture.corpID, fixture.blank.AccessToken, "") + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.alpha.UserID, fixture.alpha.AccessToken) + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.beta.UserID, fixture.beta.AccessToken) + assertIdentityTokenAccessForTest(t, fixture.corpID, gamma.UserID, gamma.AccessToken) +} + +func TestCrossPlatformCoverageLegacyBlankProfileRejectsOrganizationTokenOwnedByKnownIdentity(t *testing.T) { + cleanupKeychain(t) + corpID := "corp_mismatched_blank" + organizationToken := testToken("at_known_identity", corpID, "Mismatched Organization") + organizationToken.UserID = "identity_known" + organizationToken.UserName = "Known Account" + if err := SaveTokenDataKeychainForCorpID(corpID, organizationToken); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) + } + + blank := Profile{Name: "legacy-unresolved", CorpID: corpID, CorpName: organizationToken.CorpName} + loaded, err := tokenLoadProfileIdentity(blank) + if err == nil { + t.Fatalf("tokenLoadProfileIdentity(blank) = %#v, nil; want ownership rejection", loaded) + } + for _, want := range []string{corpID, organizationToken.UserID, blank.Name, "cannot use"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("tokenLoadProfileIdentity(blank) error = %q, want %q", err, want) + } + } +} + func testToken(accessToken, corpID, corpName string) *TokenData { now := time.Now().UTC() return &TokenData{ diff --git a/test/smoke/auth_legacy_slots_smoke_test.go b/test/smoke/auth_legacy_slots_smoke_test.go new file mode 100644 index 00000000..6993fd9d --- /dev/null +++ b/test/smoke/auth_legacy_slots_smoke_test.go @@ -0,0 +1,451 @@ +package smoke_test + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" +) + +// TestCLISmoke_LegacyAuthSlotsMigrateThroughStatus exercises the upgrade +// boundary through a real CLI subprocess. Fixtures are deliberately written +// as version-1 JSON and raw historical keychain accounts; using the current +// auth SaveTokenData API here would only prove that the new format can read +// itself. +func TestCLISmoke_LegacyAuthSlotsMigrateThroughStatus(t *testing.T) { + t.Run("v1.0.51 global-only slot", func(t *testing.T) { + env, configDir := isolatedLegacyAuthCLIEnv(t) + corpID, userID := legacyFixtureIdentity(t, "global") + accessToken := "fixture-access-global" + + writeLegacyProfilesV1(t, configDir, []legacySmokeProfile{{ + Name: "Legacy Global Org", CorpID: corpID, CorpName: "Legacy Global Org", UserID: userID, + }}, corpID) + seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken( + t, accessToken, corpID, "Legacy Global Org", "", "", + )) + + status := runLegacyAuthStatus(t, env) + assertAuthenticatedStatus(t, status, corpID, userID) + assertRawIdentitySlot(t, corpID, userID, accessToken) + assertMigratedProfiles(t, configDir, map[string]string{corpID: userID}) + }) + + t.Run("v1.0.52 migrates every organization", func(t *testing.T) { + env, configDir := isolatedLegacyAuthCLIEnv(t) + profiles := make([]legacySmokeProfile, 0, 3) + wantIdentities := make(map[string]string, 3) + wantAccess := make(map[string]string, 3) + for i := 0; i < 3; i++ { + corpID, userID := legacyFixtureIdentity(t, fmt.Sprintf("org-%d", i)) + corpName := fmt.Sprintf("Legacy Multi Org %d", i+1) + accessToken := fmt.Sprintf("fixture-access-multi-%d", i+1) + profiles = append(profiles, legacySmokeProfile{ + Name: corpName, CorpID: corpID, CorpName: corpName, UserID: userID, + }) + wantIdentities[corpID] = userID + wantAccess[corpID] = accessToken + // v1.0.52 kept one token per organization. The token response could + // omit userId even when the v1 profile registry still knew it. + seedRawLegacyKeychainSlot(t, legacyOrganizationAccount(corpID), legacyRawToken( + t, accessToken, corpID, corpName, "", "", + )) + } + current := profiles[1] + writeLegacyProfilesV1(t, configDir, profiles, current.CorpID) + // v1.0.52 also mirrored the selected organization in the global slot. + seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken( + t, wantAccess[current.CorpID], current.CorpID, current.CorpName, "", "", + )) + + status := runLegacyAuthStatus(t, env) + assertAuthenticatedStatus(t, status, current.CorpID, current.UserID) + for corpID, userID := range wantIdentities { + assertRawIdentitySlot(t, corpID, userID, wantAccess[corpID]) + } + assertMigratedProfiles(t, configDir, wantIdentities) + }) + + t.Run("v1.0.52 unresolved external account remains usable", func(t *testing.T) { + env, configDir := isolatedLegacyAuthCLIEnv(t) + corpID, _ := legacyFixtureIdentity(t, "external") + accessToken := "fixture-access-external" + writeLegacyProfilesV1(t, configDir, []legacySmokeProfile{{ + Name: "Legacy External Org", CorpID: corpID, CorpName: "Legacy External Org", + }}, corpID) + seedRawLegacyKeychainSlot(t, legacyOrganizationAccount(corpID), legacyRawToken( + t, accessToken, corpID, "Legacy External Org", "", "", + )) + seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken( + t, accessToken, corpID, "Legacy External Org", "", "", + )) + + status := runLegacyAuthStatus(t, env) + assertAuthenticatedStatus(t, status, corpID, "") + assertRawOrganizationSlot(t, corpID, accessToken, "") + + var migrated legacyProfilesDocument + readJSONFile(t, filepath.Join(configDir, "profiles.json"), &migrated) + if migrated.Version != 2 || len(migrated.Profiles) != 1 { + t.Fatalf("migrated unresolved profiles = %#v", migrated) + } + if migrated.Profiles[0].CorpID != corpID || migrated.Profiles[0].UserID != "" { + t.Fatalf("unresolved external identity was changed or discarded: %#v", migrated.Profiles[0]) + } + }) + + t.Run("v1.0.53 v2 exact profile repairs global-only slot", func(t *testing.T) { + for _, tc := range []struct { + name string + globalTokenUserID bool + }{ + {name: "global token omits user id"}, + {name: "global token user id matches", globalTokenUserID: true}, + } { + t.Run(tc.name, func(t *testing.T) { + env, configDir := isolatedLegacyAuthCLIEnv(t) + corpID, userID := legacyFixtureIdentity(t, "v2-exact-"+tc.name) + profile := legacySmokeProfile{ + Name: "Half Migrated Exact Account", CorpID: corpID, + CorpName: "Half Migrated Exact Org", UserID: userID, + } + accessToken := "fixture-access-v2-exact-" + strings.ReplaceAll(tc.name, " ", "-") + writeHalfMigratedProfilesV2(t, configDir, profile) + + tokenUserID := "" + if tc.globalTokenUserID { + tokenUserID = userID + } + // Reproduce the interrupted 1.0.53 migration exactly: the registry is + // already v2, while token material still exists only in the global + // compatibility account. Do not seed either the org or identity slot. + seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken( + t, accessToken, corpID, profile.CorpName, tokenUserID, "", + )) + + exactSelector := corpID + ":" + userID + status := runLegacyAuthStatus(t, env, exactSelector) + assertAuthenticatedStatus(t, status, corpID, userID) + assertRawIdentitySlot(t, corpID, userID, accessToken) + assertRawOrganizationSlot(t, corpID, accessToken, tokenUserID) + assertMigratedProfiles(t, configDir, map[string]string{corpID: userID}) + }) + } + }) + + t.Run("v1.0.53 v2 unresolved profile repairs global-only organization slot", func(t *testing.T) { + env, configDir := isolatedLegacyAuthCLIEnv(t) + corpID, _ := legacyFixtureIdentity(t, "v2-unresolved") + profile := legacySmokeProfile{ + Name: "Half Migrated External Account", CorpID: corpID, + CorpName: "Half Migrated External Org", + } + accessToken := "fixture-access-v2-unresolved" + writeHalfMigratedProfilesV2(t, configDir, profile) + seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken( + t, accessToken, corpID, profile.CorpName, "", "", + )) + + status := runLegacyAuthStatus(t, env, profile.Name) + assertAuthenticatedStatus(t, status, corpID, "") + assertRawOrganizationSlot(t, corpID, accessToken, "") + + var migrated legacyProfilesDocument + readJSONFile(t, filepath.Join(configDir, "profiles.json"), &migrated) + if migrated.Version != 2 || len(migrated.Profiles) != 1 { + t.Fatalf("repaired unresolved v2 profiles = %#v", migrated) + } + if migrated.Profiles[0].CorpID != corpID || migrated.Profiles[0].UserID != "" { + t.Fatalf("unresolved v2 identity was changed or discarded: %#v", migrated.Profiles[0]) + } + }) +} + +type legacySmokeProfile struct { + Name string + CorpID string + CorpName string + UserID string + UserName string +} + +type legacyProfilesDocument struct { + Version int `json:"version"` + CurrentProfile string `json:"currentProfile"` + OrgCurrentProfiles map[string]string `json:"orgCurrentProfiles"` + Profiles []struct { + CorpID string `json:"corpId"` + UserID string `json:"userId"` + } `json:"profiles"` +} + +type legacyAuthStatus struct { + Success bool `json:"success"` + Authenticated bool `json:"authenticated"` + Refreshed bool `json:"refreshed"` + TokenValid bool `json:"token_valid"` + CorpID string `json:"corp_id"` + UserID string `json:"user_id"` +} + +func isolatedLegacyAuthCLIEnv(t *testing.T) ([]string, string) { + t.Helper() + env := isolatedCLIEnv(t) + configDir := smokeEnvValue(t, env, "DWS_CONFIG_DIR") + // File-backed platforms are already isolated by DWS_KEYCHAIN_DIR. Windows + // uses HKCU, so give the parent seeder and CLI child the same throwaway + // registry namespace as well. + namespace := filepath.Join(configDir, "legacy-auth-smoke") + t.Setenv(keychain.TestNamespaceEnv, namespace) + env = append(env, keychain.TestNamespaceEnv+"="+namespace) + sort.Strings(env) + + if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil { + t.Fatalf("clear isolated historical keychain: %v", err) + } + t.Cleanup(func() { + _ = keychain.RemoveAuthTokenEntries(keychain.Service) + }) + return env, configDir +} + +func smokeEnvValue(t *testing.T, env []string, key string) string { + t.Helper() + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + return strings.TrimPrefix(entry, prefix) + } + } + t.Fatalf("CLI environment does not contain %s", key) + return "" +} + +func legacyFixtureIdentity(t *testing.T, suffix string) (string, string) { + t.Helper() + sum := sha256.Sum256([]byte(t.Name() + "\x00" + suffix)) + return fmt.Sprintf("ding_fixture_%x", sum[:6]), fmt.Sprintf("fixture-user-%x", sum[6:12]) +} + +func writeLegacyProfilesV1(t *testing.T, configDir string, profiles []legacySmokeProfile, currentCorpID string) { + t.Helper() + rawProfiles := make([]map[string]any, 0, len(profiles)) + for _, profile := range profiles { + raw := map[string]any{ + "name": profile.Name, + "corpId": profile.CorpID, + "corpName": profile.CorpName, + "clientId": "fixture-legacy-client", + "status": "active", + "expiresAt": "2099-01-02T03:04:05Z", + } + if profile.UserID != "" { + raw["userId"] = profile.UserID + } + if profile.UserName != "" { + raw["userName"] = profile.UserName + } + rawProfiles = append(rawProfiles, raw) + } + document := map[string]any{ + "version": 1, + "primaryProfile": profiles[0].CorpID, + "currentProfile": currentCorpID, + "profiles": rawProfiles, + } + data, err := json.MarshalIndent(document, "", " ") + if err != nil { + t.Fatalf("marshal raw v1 profiles fixture: %v", err) + } + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatalf("create raw v1 config directory: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "profiles.json"), append(data, '\n'), 0o600); err != nil { + t.Fatalf("write raw v1 profiles fixture: %v", err) + } +} + +func writeHalfMigratedProfilesV2(t *testing.T, configDir string, profile legacySmokeProfile) { + t.Helper() + selector := profile.CorpID + orgCurrentProfiles := map[string]string{} + if profile.UserID != "" { + selector = profile.CorpID + ":" + profile.UserID + orgCurrentProfiles[profile.CorpID] = selector + } + rawProfile := map[string]any{ + "name": profile.Name, + "corpId": profile.CorpID, + "corpName": profile.CorpName, + "clientId": "fixture-legacy-client", + "status": "active", + "expiresAt": "2099-01-02T03:04:05Z", + } + if profile.UserID != "" { + rawProfile["userId"] = profile.UserID + } + document := map[string]any{ + "version": 2, + "primaryProfile": selector, + "currentProfile": selector, + "orgCurrentProfiles": orgCurrentProfiles, + "profiles": []map[string]any{rawProfile}, + } + data, err := json.MarshalIndent(document, "", " ") + if err != nil { + t.Fatalf("marshal raw half-migrated v2 profiles fixture: %v", err) + } + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatalf("create raw v2 config directory: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "profiles.json"), append(data, '\n'), 0o600); err != nil { + t.Fatalf("write raw half-migrated v2 profiles fixture: %v", err) + } +} + +func legacyRawToken(t *testing.T, accessToken, corpID, corpName, userID, userName string) string { + t.Helper() + document := map[string]any{ + "access_token": accessToken, + "refresh_token": "fixture-refresh-" + accessToken, + "persistent_code": "fixture-persistent-" + accessToken, + "expires_at": "2099-01-02T03:04:05Z", + "refresh_expires_at": "2099-02-02T03:04:05Z", + "corp_id": corpID, + "corp_name": corpName, + "client_id": "fixture-legacy-client", + "source": "mcp", + } + if userID != "" { + document["user_id"] = userID + } + if userName != "" { + document["user_name"] = userName + } + data, err := json.MarshalIndent(document, "", " ") + if err != nil { + t.Fatalf("marshal raw historical token: %v", err) + } + return string(data) +} + +func seedRawLegacyKeychainSlot(t *testing.T, account, rawToken string) { + t.Helper() + // Write the old account directly. Do not route setup through auth.SaveTokenData, + // which would eagerly create the very identity slot this E2E must observe. + if err := keychain.Set(keychain.Service, account, rawToken); err != nil { + t.Fatalf("write raw historical keychain account %q: %v", account, err) + } +} + +func runLegacyAuthStatus(t *testing.T, env []string, profile ...string) legacyAuthStatus { + t.Helper() + if len(profile) > 1 { + t.Fatalf("auth status accepts at most one profile selector, got %d", len(profile)) + } + args := []string{"--format", "json", "auth", "status"} + if len(profile) == 1 && strings.TrimSpace(profile[0]) != "" { + args = append(args, "--profile", profile[0]) + } + stdout, stderr, err := runCLI(t, env, args...) + if err != nil { + t.Fatalf("dws auth status failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + var status legacyAuthStatus + if err := json.Unmarshal([]byte(stdout), &status); err != nil { + t.Fatalf("dws auth status returned invalid JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + return status +} + +func assertAuthenticatedStatus(t *testing.T, status legacyAuthStatus, corpID, userID string) { + t.Helper() + if !status.Success || !status.Authenticated || !status.TokenValid || status.Refreshed { + t.Fatalf("auth status did not reuse valid historical login: %#v", status) + } + if status.CorpID != corpID || status.UserID != userID { + t.Fatalf("auth status identity = %q:%q, want %q:%q", status.CorpID, status.UserID, corpID, userID) + } +} + +func assertRawIdentitySlot(t *testing.T, corpID, userID, accessToken string) { + t.Helper() + account := legacyIdentityAccount(corpID, userID) + raw, err := keychain.Get(keychain.Service, account) + if err != nil { + t.Fatalf("read migrated identity account %q: %v", account, err) + } + if raw == "" { + t.Fatalf("migrated identity account %q is missing", account) + } + var token map[string]any + if err := json.Unmarshal([]byte(raw), &token); err != nil { + t.Fatalf("parse migrated identity account %q: %v", account, err) + } + if token["access_token"] != accessToken || token["corp_id"] != corpID || token["user_id"] != userID { + t.Fatalf("migrated identity account %q = %#v", account, token) + } +} + +func assertRawOrganizationSlot(t *testing.T, corpID, accessToken, userID string) { + t.Helper() + account := legacyOrganizationAccount(corpID) + raw, err := keychain.Get(keychain.Service, account) + if err != nil { + t.Fatalf("read organization account %q: %v", account, err) + } + var token map[string]any + if err := json.Unmarshal([]byte(raw), &token); err != nil { + t.Fatalf("parse organization account %q: %v", account, err) + } + if token["access_token"] != accessToken || token["corp_id"] != corpID { + t.Fatalf("organization account %q = %#v", account, token) + } + if got, _ := token["user_id"].(string); got != userID { + t.Fatalf("organization account %q user_id = %q, want %q", account, got, userID) + } +} + +func assertMigratedProfiles(t *testing.T, configDir string, identities map[string]string) { + t.Helper() + var migrated legacyProfilesDocument + readJSONFile(t, filepath.Join(configDir, "profiles.json"), &migrated) + if migrated.Version != 2 { + t.Fatalf("profiles version = %d, want 2", migrated.Version) + } + if len(migrated.Profiles) != len(identities) { + t.Fatalf("migrated profile count = %d, want %d", len(migrated.Profiles), len(identities)) + } + for corpID, userID := range identities { + selector := corpID + ":" + userID + if migrated.OrgCurrentProfiles[corpID] != selector { + t.Errorf("orgCurrentProfiles[%q] = %q, want %q", corpID, migrated.OrgCurrentProfiles[corpID], selector) + } + } +} + +func readJSONFile(t *testing.T, path string, target any) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if err := json.Unmarshal(data, target); err != nil { + t.Fatalf("parse %s: %v", path, err) + } +} + +func legacyOrganizationAccount(corpID string) string { + return keychain.AccountToken + ":" + strings.TrimSpace(corpID) +} + +func legacyIdentityAccount(corpID, userID string) string { + identity := strings.TrimSpace(corpID) + "\x00" + strings.TrimSpace(userID) + return fmt.Sprintf("%s:id:%x", keychain.AccountToken, sha256.Sum256([]byte(identity))) +} From 6d7cc4128448a970727459b9d4560ed01a990d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E5=AE=98=E7=8E=84?= Date: Thu, 23 Jul 2026 12:21:58 +0800 Subject: [PATCH 2/6] fix(auth): harden legacy token compatibility --- Makefile | 9 +- internal/app/auth_command.go | 24 +- .../app/auth_command_full_coverage_test.go | 202 ++- .../auth_login_persistence_boundary_test.go | 107 ++ internal/app/event_personal_command.go | 13 + .../app/event_profile_selector_compat_test.go | 100 ++ internal/app/event_stdin_gate_test.go | 56 +- internal/app/pat_auth_retry.go | 2 +- internal/auth/access_token_snapshot_test.go | 96 +- .../blank_profile_selector_regression_test.go | 33 +- internal/auth/device_flow.go | 5 +- internal/auth/forward_compat_guard_test.go | 1070 +++++++++++++ internal/auth/keychain_store.go | 242 ++- .../auth/login_persistence_preflight_test.go | 1392 +++++++++++++++++ internal/auth/oauth_helpers.go | 15 +- internal/auth/oauth_provider.go | 42 +- internal/auth/oauth_provider_coverage_test.go | 66 + .../oauth_reauthorization_guidance_test.go | 62 + internal/auth/profiles.go | 142 +- internal/auth/refresh_failure_test.go | 16 +- internal/auth/remaining_coverage_test.go | 6 + internal/auth/token.go | 288 +++- internal/auth/token_preflight_test.go | 77 +- internal/auth/token_test.go | 6 +- internal/keychain/auth_entries_windows.go | 48 +- internal/keychain/keychain_windows.go | 31 +- .../keychain_windows_validation_test.go | 135 ++ scripts/policy/check-auth-legacy-compat.sh | 90 ++ test/smoke/auth_legacy_slots_smoke_test.go | 123 ++ 29 files changed, 4226 insertions(+), 272 deletions(-) create mode 100644 internal/app/auth_login_persistence_boundary_test.go create mode 100644 internal/app/event_profile_selector_compat_test.go create mode 100644 internal/auth/forward_compat_guard_test.go create mode 100644 internal/auth/login_persistence_preflight_test.go create mode 100644 internal/auth/oauth_reauthorization_guidance_test.go create mode 100644 internal/keychain/keychain_windows_validation_test.go create mode 100755 scripts/policy/check-auth-legacy-compat.sh diff --git a/Makefile b/Makefile index b5131e7b..18713cee 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)" GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go' -.PHONY: all help build rebuild test test-plan lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks +.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks all: setup-hooks fmt lint build test rebuild @@ -17,6 +17,7 @@ help: @printf " make build - Build the dws CLI binary\n" @printf " make test - Run the Go test suite\n" @printf " make test-plan - Verify every default Go package belongs to one CI test shard\n" + @printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n" @printf " make lint - Run formatting checks, go vet, and staticcheck\n" @printf " make format-check - Check all repository Go source files with gofmt\n" @printf " make fmt - Format all repository Go source files\n" @@ -54,6 +55,10 @@ test: test-plan: @./scripts/ci/test-packages.sh verify +test-auth-legacy-compat: + @mkdir -p "$(POLICY_GOTMPDIR)" + @GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh + lint: @./scripts/dev/lint.sh @@ -76,7 +81,7 @@ fmt: $(GO_SOURCE_LIST) > "$$go_files"; \ xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files" -policy: +policy: test-auth-legacy-compat @mkdir -p "$(POLICY_GOTMPDIR)" @$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh @$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index fc2f948b..e887341b 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -307,7 +307,7 @@ var ( migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK authMigrateTarget = func(cmd *cobra.Command) (string, error) { return cmd.Flags().GetString("to") } authRunForm = (*huh.Form).Run - authSaveTokenData = authpkg.SaveTokenData + authSaveTokenData = authpkg.SaveLoginTokenData authSaveAppConfig = authpkg.SaveAppConfig authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) { return provider.Login(ctx) @@ -1496,12 +1496,16 @@ func enrichAuthLoginProfileFromContact( return nil } -// enrichAuthLoginProfileFromHistory recovers identity metadata when the -// contact service cannot describe an external-worker account. Explicit -// profile selection wins; otherwise only a sole same-corp profile is -// deterministic. -// Historical v1 profiles without userId are still useful: selecting one is a -// successful fallback that preserves org-scoped token storage semantics. +// enrichAuthLoginProfileFromHistory recovers display metadata when the contact +// service cannot describe an external-worker account. Historical profile +// selection is never proof of the user who completed a fresh authorization: +// only the token exchange or contact service may supply UserID. +// +// An explicit profile remains useful as a storage/selection hint. Keeping it in +// LegacyOrgScopedProfile prevents the login from switching the process-global +// current profile while SaveTokenData publishes the UID-less credential to the +// unresolved organization slot. A historical blank profile is updated in +// place; an exact historical profile and its token remain untouched. func enrichAuthLoginProfileFromHistory(configDir string, data *authpkg.TokenData, hints ...authLoginHistoryHint) (bool, error) { if data == nil || strings.TrimSpace(data.UserID) != "" { return false, nil @@ -1552,8 +1556,7 @@ func enrichAuthLoginProfileFromHistory(configDir string, data *authpkg.TokenData } updated := *data - updated.UserID = strings.TrimSpace(candidate.UserID) - if updated.UserID == "" && hint.Explicit { + if hint.Explicit { updated.LegacyOrgScopedProfile = strings.TrimSpace(hint.Selector) } if strings.TrimSpace(updated.CorpName) == "" { @@ -1565,11 +1568,12 @@ func enrichAuthLoginProfileFromHistory(configDir string, data *authpkg.TokenData *data = updated logging.AuthDebug( "auth.login.identity.resolved", - "source", "local_profile_history", + "source", "local_profile_history_display_only", "corp_id", corpID, "user_id", updated.UserID, "user_name", updated.UserName, "corp_name", updated.CorpName, + "identity_proven", false, ) return true, nil } diff --git a/internal/app/auth_command_full_coverage_test.go b/internal/app/auth_command_full_coverage_test.go index e469498c..5fd5b26d 100644 --- a/internal/app/auth_command_full_coverage_test.go +++ b/internal/app/auth_command_full_coverage_test.go @@ -423,7 +423,7 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) { } } -func TestCrossPlatformCoverageContactFailureReusesUniqueSameCorpHistoricalIdentity(t *testing.T) { +func TestCrossPlatformCoverageContactFailureReusesOnlySameCorpHistoricalDisplayMetadata(t *testing.T) { configDir := t.TempDir() if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{ Version: 1, @@ -498,8 +498,8 @@ func TestCrossPlatformCoverageContactFailureReusesUniqueSameCorpHistoricalIdenti if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, tc.caller, data); err != nil { t.Fatalf("contact failure blocked historical identity recovery: %v", err) } - if data.UserID != "external-user" || data.UserName != "Historical Worker" { - t.Fatalf("recovered identity = %#v", data) + if data.UserID != "" || data.UserName != "Historical Worker" { + t.Fatalf("historical metadata supplied UID evidence: %#v", data) } if data.CorpName != tc.wantCorp { t.Fatalf("corp name = %q, want %q", data.CorpName, tc.wantCorp) @@ -605,7 +605,7 @@ func TestCrossPlatformCoverageContactHistoryFallbackEdges(t *testing.T) { if err != nil || !reused { t.Fatalf("history fallback = %v, %v", reused, err) } - if data.CorpName != "Historical Corp" || data.UserName != "Historical Worker" || data.UserID != "external-user" { + if data.CorpName != "Historical Corp" || data.UserName != "Historical Worker" || data.UserID != "" { t.Fatalf("history metadata = %#v", data) } @@ -728,8 +728,7 @@ func TestCrossPlatformCoverageAuthLoginConfigPreservesHistoryIdentityHint(t *tes } } -func TestCrossPlatformCoverageOAuthAndDeviceApplyExactHistoryHint(t *testing.T) { - t.Setenv("DWS_CONFIG_DIR", t.TempDir()) +func TestCrossPlatformCoverageOAuthAndDeviceKeepFreshUnknownIdentityIsolatedFromExactHistory(t *testing.T) { oldResolve := authResolveProfile oldLoad := authLoadProfiles oldDevice := authDeviceLogin @@ -743,73 +742,127 @@ func TestCrossPlatformCoverageOAuthAndDeviceApplyExactHistoryHint(t *testing.T) authLoginInteractiveTerminal = oldInteractive }) - selected := authpkg.Profile{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"} - authResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) { - if selector != "ding_same:user_2" { - return nil, errors.New("missing") - } - clone := selected - return &clone, nil - } - authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { - return &authpkg.ProfilesConfig{ - CurrentProfile: "ding_same:user_1", - OrgCurrentProfiles: map[string]string{ - "ding_same": "ding_same:user_1", - }, - Profiles: []authpkg.Profile{ - {CorpID: "ding_same", UserID: "user_1", UserName: "First"}, - selected, - }, - }, nil - } + authResolveProfile = authpkg.ResolveProfile + authLoadProfiles = authpkg.LoadProfiles authLoginInteractiveTerminal = func() bool { return false } - caller := &authCoverageCaller{err: errors.New("contact unavailable")} - newToken := func() *authpkg.TokenData { - return &authpkg.TokenData{ - AccessToken: "new-access", - CorpID: "ding_same", - ExpiresAt: time.Now().Add(time.Hour), - } - } - deviceApplied := false - authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) { - data := newToken() - if provider.IdentityEnricher == nil { - return nil, errors.New("missing device identity enricher") - } - if err := provider.IdentityEnricher(ctx, data); err != nil { - return nil, err - } - deviceApplied = data.UserID == "user_2" - return data, nil - } - if _, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{ - "device": "true", - "profile": "ding_same:user_2", - }); err != nil || !deviceApplied { - t.Fatalf("device exact history hint applied=%v err=%v", deviceApplied, err) - } + t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) - oauthApplied := false - authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, _ bool) (*authpkg.TokenData, error) { - if provider.TargetCorpID != "ding_same" { - return nil, fmt.Errorf("OAuth target corp = %q", provider.TargetCorpID) - } - data := newToken() - if provider.IdentityEnricher == nil { - return nil, errors.New("missing OAuth identity enricher") - } - if err := provider.IdentityEnricher(ctx, data); err != nil { - return nil, err - } - oauthApplied = data.UserID == "user_2" - return data, nil - } - if _, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{ - "profile": "ding_same:user_2", - }); err != nil || !oauthApplied { - t.Fatalf("OAuth exact history hint applied=%v err=%v", oauthApplied, err) + for _, flow := range []string{"oauth", "device"} { + t.Run(flow, func(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + authpkg.SetRuntimeProfile("") + + const ( + corpID = "ding_same" + historicalUID = "user_a" + exactSelector = corpID + ":" + historicalUID + ) + oldToken := &authpkg.TokenData{ + AccessToken: "old-user-a-access", + RefreshToken: "old-user-a-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: corpID, + CorpName: "Same Corp", + UserID: historicalUID, + UserName: "Historical User A", + } + if err := authpkg.SaveTokenData(configDir, oldToken); err != nil { + t.Fatalf("persist historical exact identity: %v", err) + } + + caller := &authCoverageCaller{err: errors.New("contact unavailable")} + var enriched *authpkg.TokenData + freshToken := func() *authpkg.TokenData { + return &authpkg.TokenData{ + AccessToken: "fresh-user-b-access-" + flow, + RefreshToken: "fresh-user-b-refresh-" + flow, + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: corpID, + } + } + persistUnknown := func(ctx context.Context, identityEnricher func(context.Context, *authpkg.TokenData) error) (*authpkg.TokenData, error) { + if identityEnricher == nil { + return nil, errors.New("missing identity enricher") + } + data := freshToken() + if err := identityEnricher(ctx, data); err != nil { + return nil, err + } + enriched = data + if data.UserID != "" { + return nil, fmt.Errorf("historical profile supplied unproven userId %q", data.UserID) + } + if err := authpkg.SaveTokenData(configDir, data); err != nil { + return nil, err + } + return data, nil + } + + flags := map[string]string{"profile": exactSelector} + switch flow { + case "device": + flags["device"] = "true" + authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) { + return persistUnknown(ctx, provider.IdentityEnricher) + } + case "oauth": + authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, _ bool) (*authpkg.TokenData, error) { + if provider.TargetCorpID != corpID { + return nil, fmt.Errorf("OAuth target corp = %q", provider.TargetCorpID) + } + return persistUnknown(ctx, provider.IdentityEnricher) + } + } + if _, _, err := authCoverageRunLogin(t, caller, "table", true, flags); err != nil { + t.Fatalf("%s login with unresolved fresh identity: %v", flow, err) + } + if enriched == nil || enriched.UserID != "" || + enriched.LegacyOrgScopedProfile != exactSelector || + enriched.CorpName != "Same Corp" || + enriched.UserName != "Historical User A" { + t.Fatalf("%s history hint became identity evidence: %#v", flow, enriched) + } + + historical, err := authpkg.LoadTokenDataForProfile(configDir, exactSelector) + if err != nil { + t.Fatalf("load historical exact identity: %v", err) + } + if historical.AccessToken != oldToken.AccessToken || historical.UserID != historicalUID { + t.Fatalf("historical exact slot was overwritten: %#v", historical) + } + + profiles, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("load profiles: %v", err) + } + var unresolved *authpkg.Profile + for i := range profiles.Profiles { + profile := &profiles.Profiles[i] + if profile.CorpID == corpID && profile.UserID == "" { + unresolved = profile + break + } + } + if unresolved == nil { + t.Fatalf("fresh UID-less token did not create an unresolved profile: %#v", profiles.Profiles) + } + unresolvedSelector := authpkg.ProfileSelectionSelector(*unresolved, profiles) + if unresolvedSelector == "" || unresolvedSelector == exactSelector { + t.Fatalf("unresolved selector = %q", unresolvedSelector) + } + fresh, err := authpkg.LoadTokenDataForProfile(configDir, unresolvedSelector) + if err != nil { + t.Fatalf("load fresh unresolved identity: %v", err) + } + if fresh.AccessToken != "fresh-user-b-access-"+flow || fresh.UserID != "" { + t.Fatalf("fresh token was not isolated in unresolved org slot: %#v", fresh) + } + }) } } @@ -835,7 +888,9 @@ func TestCrossPlatformCoverageHistoricalIdentityPriorityAndBlankUserID(t *testin explicitData := &authpkg.TokenData{CorpID: "ding_same"} reused, err := enrichAuthLoginProfileFromHistory("cfg", explicitData, authLoginHistoryHint{Selector: "ding_same:user_1", Explicit: true}) - if err != nil || !reused || explicitData.UserID != "user_1" { + if err != nil || !reused || explicitData.UserID != "" || + explicitData.LegacyOrgScopedProfile != "ding_same:user_1" || + explicitData.CorpName != "Same Corp" || explicitData.UserName != "First" { t.Fatalf("explicit history selection = %#v, reused=%v err=%v", explicitData, reused, err) } mismatchedHintData := &authpkg.TokenData{CorpID: "ding_same"} @@ -852,7 +907,8 @@ func TestCrossPlatformCoverageHistoricalIdentityPriorityAndBlankUserID(t *testin cfg.Profiles = []authpkg.Profile{cfg.Profiles[1]} soleData := &authpkg.TokenData{CorpID: "ding_same"} reused, err = enrichAuthLoginProfileFromHistory("cfg", soleData) - if err != nil || !reused || soleData.UserID != "user_2" { + if err != nil || !reused || soleData.UserID != "" || + soleData.CorpName != "Same Corp" || soleData.UserName != "Second" { t.Fatalf("sole history selection = %#v, reused=%v err=%v", soleData, reused, err) } diff --git a/internal/app/auth_login_persistence_boundary_test.go b/internal/app/auth_login_persistence_boundary_test.go new file mode 100644 index 00000000..97f2db08 --- /dev/null +++ b/internal/app/auth_login_persistence_boundary_test.go @@ -0,0 +1,107 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "strings" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" +) + +func TestPATFreshAuthorizationSaveUsesLoginIsolationBoundary(t *testing.T) { + configDir := t.TempDir() + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + const ( + corpID = "corp_pat_login_boundary" + userID = "exact-user" + ) + cfg := &authpkg.ProfilesConfig{ + Version: 2, + Profiles: []authpkg.Profile{ + {Name: "External Account", CorpID: corpID, CorpName: "PAT Boundary Organization"}, + {Name: "Exact Account", CorpID: corpID, CorpName: "PAT Boundary Organization", UserID: userID}, + }, + } + blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg) + cfg.CurrentProfile = blankSelector + cfg.PrimaryProfile = blankSelector + if err := authpkg.SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + blank := &authpkg.TokenData{AccessToken: "existing-unresolved", CorpID: corpID, CorpName: "PAT Boundary Organization"} + exact := &authpkg.TokenData{AccessToken: "existing-exact", CorpID: corpID, CorpName: "PAT Boundary Organization", UserID: userID} + if err := authpkg.SaveTokenDataKeychainForCorpID(corpID, blank); err != nil { + t.Fatalf("save unresolved token: %v", err) + } + if err := authpkg.SaveTokenDataKeychainForIdentity(corpID, userID, exact); err != nil { + t.Fatalf("save exact token: %v", err) + } + previousRuntimeProfile := authpkg.RuntimeProfile() + authpkg.SetRuntimeProfile("") + t.Cleanup(func() { authpkg.SetRuntimeProfile(previousRuntimeProfile) }) + + fresh := &authpkg.TokenData{AccessToken: "pat-fresh-unknown", CorpID: corpID, CorpName: "PAT Boundary Organization"} + err := patSaveTokenData(configDir, fresh) + if err == nil || !strings.Contains(err.Error(), "fresh UID-less token") { + t.Fatalf("patSaveTokenData() error = %v, want unresolved-sibling protection", err) + } + persisted, loadErr := authpkg.LoadTokenDataKeychainForCorpID(corpID) + if loadErr != nil || persisted.AccessToken != blank.AccessToken || persisted.UserID != "" { + t.Fatalf("PAT save changed unresolved sibling: token=%#v err=%v", persisted, loadErr) + } +} + +func TestManualLoginSaveRepairsHalfMigratedGlobalBeforeOverwrite(t *testing.T) { + configDir := t.TempDir() + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + const ( + corpID = "corp_manual_login_boundary" + userID = "legacy-user" + ) + selector := corpID + ":" + userID + if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{ + Version: 2, + CurrentProfile: selector, + Profiles: []authpkg.Profile{{ + Name: "Legacy Exact Account", CorpID: corpID, CorpName: "Manual Boundary Organization", UserID: userID, + }}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + legacy := &authpkg.TokenData{AccessToken: "only-legacy-copy", CorpID: corpID, CorpName: "Manual Boundary Organization"} + if err := authpkg.SaveTokenDataKeychain(legacy); err != nil { + t.Fatalf("save half-migrated global: %v", err) + } + manual := &authpkg.TokenData{AccessToken: "manual-default", ExpiresAt: time.Now().Add(time.Hour)} + if err := authSaveTokenData(configDir, manual); err != nil { + t.Fatalf("authSaveTokenData(manual) error = %v", err) + } + org, err := authpkg.LoadTokenDataKeychainForCorpID(corpID) + if err != nil || org.AccessToken != legacy.AccessToken || org.UserID != "" { + t.Fatalf("organization repair = %#v, %v", org, err) + } + identity, err := authpkg.LoadTokenDataKeychainForIdentity(corpID, userID) + if err != nil || identity.AccessToken != legacy.AccessToken || identity.UserID != userID { + t.Fatalf("identity repair = %#v, %v", identity, err) + } + global, err := authpkg.LoadTokenDataKeychain() + if err != nil || global.AccessToken != manual.AccessToken || global.CorpID != "" { + t.Fatalf("manual global = %#v, %v", global, err) + } +} diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go index 368a82d2..bf135413 100644 --- a/internal/app/event_personal_command.go +++ b/internal/app/event_personal_command.go @@ -880,6 +880,19 @@ func personalBusProfileSelector(configDir string, identity personal.Identity) st return selector } if cfg, err := authpkg.LoadProfiles(configDir); err == nil && cfg != nil { + // With no explicit process-local override, LoadTokenData selected the + // persisted current profile. Prefer that selection over the enriched + // identity: $currentUserId may describe an exact same-corp account even + // though the token came from the historical unresolved profile. + currentSelector := strings.TrimSpace(cfg.CurrentProfile) + for i := range cfg.Profiles { + profile := cfg.Profiles[i] + selector := authpkg.ProfileSelectionSelector(profile, cfg) + if selector == currentSelector && + (strings.TrimSpace(identity.CorpID) == "" || strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID)) { + return selector + } + } for i := range cfg.Profiles { profile := cfg.Profiles[i] if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID) && diff --git a/internal/app/event_profile_selector_compat_test.go b/internal/app/event_profile_selector_compat_test.go new file mode 100644 index 00000000..267af36d --- /dev/null +++ b/internal/app/event_profile_selector_compat_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" +) + +func TestPersonalBusProfileSelectorUsesDefaultBlankCurrentBeforeRuntimeEnrichedIdentity(t *testing.T) { + configDir, cfg, blankSelector, exactSelector := seedPersonalBusProfileSelectorConfig(t) + cfg.CurrentProfile = blankSelector + cfg.OrgCurrentProfiles = map[string]string{cfg.Profiles[0].CorpID: exactSelector} + if err := authpkg.SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + authpkg.SetRuntimeProfile("") + t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) + + identityAfterRuntimeEnrichment := personal.Identity{ + CorpID: cfg.Profiles[0].CorpID, + UserID: cfg.Profiles[1].UserID, + } + if got := personalBusProfileSelector(configDir, identityAfterRuntimeEnrichment); got != blankSelector { + t.Fatalf("personalBusProfileSelector() = %q, want default blank selector %q", got, blankSelector) + } +} + +func TestPersonalBusProfileSelectorUsesDefaultExactCurrent(t *testing.T) { + configDir, cfg, _, exactSelector := seedPersonalBusProfileSelectorConfig(t) + cfg.CurrentProfile = exactSelector + cfg.OrgCurrentProfiles = map[string]string{cfg.Profiles[0].CorpID: exactSelector} + if err := authpkg.SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + authpkg.SetRuntimeProfile("") + t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) + + identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID} + if got := personalBusProfileSelector(configDir, identity); got != exactSelector { + t.Fatalf("personalBusProfileSelector() = %q, want default exact selector %q", got, exactSelector) + } +} + +func TestPersonalBusProfileSelectorPrefersExplicitRuntimeSelector(t *testing.T) { + configDir, cfg, blankSelector, _ := seedPersonalBusProfileSelectorConfig(t) + cfg.CurrentProfile = blankSelector + if err := authpkg.SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + const explicitSelector = "corp_explicit:user_explicit" + authpkg.SetRuntimeProfile(explicitSelector) + t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) + + identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID} + if got := personalBusProfileSelector(configDir, identity); got != explicitSelector { + t.Fatalf("personalBusProfileSelector() = %q, want explicit selector %q", got, explicitSelector) + } +} + +func seedPersonalBusProfileSelectorConfig(t *testing.T) (string, *authpkg.ProfilesConfig, string, string) { + t.Helper() + configDir := t.TempDir() + cfg := &authpkg.ProfilesConfig{ + Version: 2, + Profiles: []authpkg.Profile{ + { + Name: "External Fixture", + CorpID: "corp_event_current_fixture", + CorpName: "Fixture Organization", + }, + { + Name: "Exact Fixture", + CorpID: "corp_event_current_fixture", + CorpName: "Fixture Organization", + UserID: "identity_runtime_enriched_fixture", + }, + }, + } + blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg) + exactSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[1], cfg) + if blankSelector == "" || blankSelector == cfg.Profiles[0].CorpID { + t.Fatalf("blank selector = %q, want stable account selector", blankSelector) + } + return configDir, cfg, blankSelector, exactSelector +} diff --git a/internal/app/event_stdin_gate_test.go b/internal/app/event_stdin_gate_test.go index 64587b52..557838b5 100644 --- a/internal/app/event_stdin_gate_test.go +++ b/internal/app/event_stdin_gate_test.go @@ -19,6 +19,7 @@ import ( authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" ) // A bounded run never arms the stdin-EOF watcher, regardless of stdin @@ -67,6 +68,8 @@ func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) { func TestCrossPlatformCoveragePersonalBusSpawnArgsPreservesReservedBlankProfile(t *testing.T) { configDir := t.TempDir() + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) cfg := &authpkg.ProfilesConfig{ Version: 2, OrgCurrentProfiles: map[string]string{ @@ -86,29 +89,70 @@ func TestCrossPlatformCoveragePersonalBusSpawnArgsPreservesReservedBlankProfile( }, }, } + blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg) + cfg.PrimaryProfile = blankSelector + cfg.CurrentProfile = blankSelector if err := authpkg.SaveProfiles(configDir, cfg); err != nil { t.Fatalf("SaveProfiles() error = %v", err) } - identity := personal.Identity{CorpID: "corp_event_fixture", SourceID: "open"} + blankToken := &authpkg.TokenData{ + AccessToken: "parent-blank-token", + CorpID: "corp_event_fixture", + CorpName: "Fixture Organization", + } + exactToken := &authpkg.TokenData{ + AccessToken: "other-exact-token", + CorpID: "corp_event_fixture", + CorpName: "Fixture Organization", + UserID: "identity_exact_fixture", + } + if err := authpkg.SaveTokenDataKeychainForCorpID(blankToken.CorpID, blankToken); err != nil { + t.Fatalf("save parent blank token: %v", err) + } + if err := authpkg.SaveTokenDataKeychainForIdentity(exactToken.CorpID, exactToken.UserID, exactToken); err != nil { + t.Fatalf("save other exact token: %v", err) + } + + // Runtime identity enrichment points at the exact sibling, but the parent + // already loaded the persisted blank current profile. + identity := personal.Identity{ + CorpID: "corp_event_fixture", + UserID: "identity_exact_fixture", + SourceID: "open", + } selector := personalBusProfileSelector(configDir, identity) - want := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg) + want := blankSelector if selector != want || selector == identity.CorpID { t.Fatalf("personalBusProfileSelector(blank) = %q, want reserved %q", selector, want) } args := personalBusSpawnArgs(identity, "", "", selector) - found := false + forwardedSelector := "" for i := 0; i+1 < len(args); i++ { if args[i] == "--profile" && args[i+1] == want { - found = true + forwardedSelector = args[i+1] break } } - if !found { + if forwardedSelector == "" { t.Fatalf("spawn args did not preserve reserved blank selector: %v", args) } + parentToken, err := authpkg.LoadTokenDataForProfile(configDir, selector) + if err != nil { + t.Fatalf("load parent token: %v", err) + } + authpkg.SetRuntimeProfile(forwardedSelector) + t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) + childToken, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("load detached child token: %v", err) + } + if parentToken.AccessToken != blankToken.AccessToken || + childToken.AccessToken != parentToken.AccessToken || + childToken.UserID != "" { + t.Fatalf("parent/child token drift: parent=%#v child=%#v", parentToken, childToken) + } authpkg.SetRuntimeProfile(want) - t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) inferredExact := personal.Identity{ CorpID: "corp_event_fixture", UserID: "identity_exact_fixture", diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index cff16e16..93c86344 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -61,7 +61,7 @@ var ( patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval patSaveAppConfig = authpkg.SaveAppConfig patExchangeCodeForToken = authpkg.ExchangeCodeForToken - patSaveTokenData = authpkg.SaveTokenData + patSaveTokenData = authpkg.SaveLoginTokenData patSleep = time.Sleep patPollHTTPDo = (*http.Client).Do patPollNewRequest = http.NewRequestWithContext diff --git a/internal/auth/access_token_snapshot_test.go b/internal/auth/access_token_snapshot_test.go index 4db27780..6595da74 100644 --- a/internal/auth/access_token_snapshot_test.go +++ b/internal/auth/access_token_snapshot_test.go @@ -3,6 +3,9 @@ package auth import ( "context" "errors" + "io" + "net/http" + "net/url" "testing" "time" ) @@ -22,15 +25,92 @@ func TestCrossPlatformCoverageOAuthProviderTokenSnapshotPreservesLoadFailure(t * } } -func TestCrossPlatformCoverageOAuthProviderLoginPreservesLoadFailure(t *testing.T) { - oldLoad := oauthLoadToken - want := errors.New("keychain permission denied") - oauthLoadToken = func(string) (*TokenData, error) { return nil, want } - t.Cleanup(func() { oauthLoadToken = oldLoad }) +func TestCrossPlatformCoverageOAuthProviderLoginReauthorizesAfterLoadFailureAndRejectsUnreadableTarget(t *testing.T) { + cleanupKeychain(t) + setLoginPreflightCredentials(t) - _, err := NewOAuthProvider(t.TempDir(), nil).Login(context.Background(), false) - if !errors.Is(err, want) { - t.Fatalf("error = %v, want cause %v", err, want) + oldLoad := oauthLoadToken + oldOpenBrowser := oauthOpenBrowser + oldExchange := oauthExchange + oldCheckStatus := oauthCheckStatus + oldSave := oauthSaveToken + oldKeychainGet := authKeychainGet + oldLoginTimeout := oauthLoginTimeout + t.Cleanup(func() { + oauthLoadToken = oldLoad + oauthOpenBrowser = oldOpenBrowser + oauthExchange = oldExchange + oauthCheckStatus = oldCheckStatus + oauthSaveToken = oldSave + authKeychainGet = oldKeychainGet + oauthLoginTimeout = oldLoginTimeout + }) + oauthLoginTimeout = 2 * time.Second + + loadErr := errors.New("keychain permission denied") + targetErr := errors.New("target token ciphertext is unreadable") + oauthLoadToken = func(string) (*TokenData, error) { return nil, loadErr } + + browserCalls := 0 + oauthOpenBrowser = func(authURL string) error { + browserCalls++ + parsed, err := url.Parse(authURL) + if err != nil { + return err + } + callbackURL := parsed.Query().Get("redirect_uri") + "?code=reauthorize" + response, err := (&http.Client{Timeout: 5 * time.Second}).Get(callbackURL) + if err != nil { + return err + } + _, _ = io.Copy(io.Discard, response.Body) + return response.Body.Close() + } + + exchangeCalls := 0 + oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) { + exchangeCalls++ + return &TokenData{ + AccessToken: "new-access", + CorpID: "corp-target", + UserID: "user-target", + }, nil + } + oauthCheckStatus = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) { + return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil + } + + targetReads := 0 + authKeychainGet = func(_ string, account string) (string, error) { + if account == TokenAccountForIdentity("corp-target", "user-target") { + targetReads++ + return "", targetErr + } + return "", nil + } + saveCalls := 0 + oauthSaveToken = func(string, *TokenData) error { + saveCalls++ + return nil + } + + provider := NewOAuthProvider(t.TempDir(), nil) + provider.Output = io.Discard + _, err := provider.Login(context.Background(), false) + if !errors.Is(err, targetErr) { + t.Fatalf("Login() error = %v, want target cause %v", err, targetErr) + } + if errors.Is(err, loadErr) { + t.Fatalf("Login() returned stale load failure instead of reauthorizing: %v", err) + } + if browserCalls != 1 || exchangeCalls != 1 { + t.Fatalf("authorization calls = browser:%d exchange:%d, want 1 each", browserCalls, exchangeCalls) + } + if targetReads != 1 { + t.Fatalf("target slot reads = %d, want 1", targetReads) + } + if saveCalls != 0 { + t.Fatalf("SaveTokenData calls = %d, want 0", saveCalls) } } diff --git a/internal/auth/blank_profile_selector_regression_test.go b/internal/auth/blank_profile_selector_regression_test.go index 695ca06e..3b70190f 100644 --- a/internal/auth/blank_profile_selector_regression_test.go +++ b/internal/auth/blank_profile_selector_regression_test.go @@ -16,6 +16,7 @@ package auth import ( "encoding/json" "os" + "strings" "testing" ) @@ -43,12 +44,6 @@ func seedBlankProfileSelectorFixture( corpID := "corp_selector_fixture" exactUserID := "identity_exact_fixture" exactSelector := profileSelector(corpID, exactUserID) - currentSelector := exactSelector - previousSelector := blankName - if blankCurrent { - currentSelector = blankName - previousSelector = exactSelector - } blankToken := testToken("at_unresolved_fixture", corpID, corpName) blankToken.UserID = "" @@ -71,10 +66,8 @@ func seedBlankProfileSelectorFixture( } cfg := &ProfilesConfig{ - Version: profilesVersion, - PrimaryProfile: exactSelector, - CurrentProfile: currentSelector, - PreviousProfile: previousSelector, + Version: profilesVersion, + PrimaryProfile: exactSelector, OrgCurrentProfiles: map[string]string{ corpID: exactSelector, }, @@ -95,6 +88,24 @@ func seedBlankProfileSelectorFixture( }, }, } + blankSelector := ProfileSelectionSelector(cfg.Profiles[0], cfg) + persistedBlankPointer := strings.TrimSpace(blankName) + if selectorConflictsWithOrganizationGrammar(cfg, persistedBlankPointer) { + // An ambiguous local name has always been captured by CorpId/CorpName + // grammar in the public resolver. New writers must use the reserved + // selector to preserve exact blank-profile intent without changing that + // precedence. + persistedBlankPointer = blankSelector + if _, reserved := parseUnresolvedProfileSelector(persistedBlankPointer); reserved { + cfg.Version = profilesUnresolvedSelectorVersion + } + } + cfg.CurrentProfile = exactSelector + cfg.PreviousProfile = persistedBlankPointer + if blankCurrent { + cfg.CurrentProfile = persistedBlankPointer + cfg.PreviousProfile = exactSelector + } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { t.Fatalf("json.MarshalIndent(profiles) error = %v", err) @@ -108,7 +119,7 @@ func seedBlankProfileSelectorFixture( configDir: configDir, corpID: corpID, blankName: blankName, - blankSelector: ProfileSelectionSelector(cfg.Profiles[0], cfg), + blankSelector: blankSelector, exactUserID: exactUserID, exactSelector: exactSelector, blankToken: blankToken, diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 30e86fc1..c711949a 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -195,7 +195,7 @@ func (p *DeviceFlowProvider) resetCredentialState() { } func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) { - if err := preflightTokenPersistence(p.configDir); err != nil { + if err := prepareLoginPersistence(p.configDir); err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) } @@ -357,6 +357,9 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token if err := oauthProvider.prepareLoginToken(ctx, tokenData); err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err) } + if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) + } if err := deviceSaveToken(p.configDir, tokenData); err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err) } diff --git a/internal/auth/forward_compat_guard_test.go b/internal/auth/forward_compat_guard_test.go new file mode 100644 index 00000000..15b9ca4e --- /dev/null +++ b/internal/auth/forward_compat_guard_test.go @@ -0,0 +1,1070 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "encoding/json" + "errors" + "os" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" +) + +func TestDefaultBlankCurrentRejectsSameCorpGlobalMirrorOwnedByExactIdentity(t *testing.T) { + originalResolveProfile := tokenResolveProfile + originalLoadOrganization := tokenLoadKeychainForCorpID + originalLoadGlobal := tokenLoadKeychain + t.Cleanup(func() { + tokenResolveProfile = originalResolveProfile + tokenLoadKeychainForCorpID = originalLoadOrganization + tokenLoadKeychain = originalLoadGlobal + }) + + const corpID = "corp_blank_default_guard" + tokenResolveProfile = func(string, string) (*Profile, error) { + return &Profile{Name: "Legacy Contractor", CorpID: corpID}, nil + } + tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { + return nil, ErrTokenDataNotFound + } + tokenLoadKeychain = func() (*TokenData, error) { + return &TokenData{ + AccessToken: "exact-global-token", + CorpID: corpID, + UserID: "different-exact-user", + }, nil + } + + loaded, err := loadTokenDataForProfileLocked(t.TempDir(), "") + if !errors.Is(err, ErrTokenDataNotFound) { + t.Fatalf("loadTokenDataForProfileLocked() error = %v, want %v", err, ErrTokenDataNotFound) + } + if loaded != nil { + t.Fatalf("loadTokenDataForProfileLocked() = %#v, want nil", loaded) + } +} + +func TestTokenLoadIsolationMatrix(t *testing.T) { + const ( + corpID = "corp_token_load_matrix" + exactUserID = "matrix-user-a" + otherUserID = "matrix-user-b" + ) + type profileKind string + type tokenSlot string + type uidRelation string + const ( + blankProfile profileKind = "blank" + exactProfile profileKind = "exact" + identitySlot tokenSlot = "identity" + orgSlot tokenSlot = "organization" + globalSlot tokenSlot = "global" + emptyUID uidRelation = "empty" + sameUID uidRelation = "same" + differentUID uidRelation = "different" + ) + + var tests []struct { + name string + profile Profile + slot tokenSlot + tokenUserID string + wantAccepted bool + } + for _, kind := range []profileKind{blankProfile, exactProfile} { + profile := Profile{Name: string(kind) + " matrix profile", CorpID: corpID} + if kind == exactProfile { + profile.UserID = exactUserID + } + for _, slot := range []tokenSlot{identitySlot, orgSlot, globalSlot} { + for _, relation := range []uidRelation{emptyUID, sameUID, differentUID} { + tokenUserID := "" + switch relation { + case sameUID: + tokenUserID = profile.UserID + case differentUID: + tokenUserID = otherUserID + } + wantAccepted := false + switch slot { + case identitySlot: + wantAccepted = kind == exactProfile && tokenUserID == profile.UserID + case orgSlot, globalSlot: + wantAccepted = tokenUserID == profile.UserID + } + tests = append(tests, struct { + name string + profile Profile + slot tokenSlot + tokenUserID string + wantAccepted bool + }{ + name: string(kind) + "/" + string(slot) + "/" + string(relation), + profile: profile, + slot: slot, + tokenUserID: tokenUserID, + wantAccepted: wantAccepted, + }) + } + } + } + if len(tests) != 18 { + t.Fatalf("token isolation matrix has %d cells, want 18", len(tests)) + } + + originalResolveProfile := tokenResolveProfile + originalLoadIdentity := tokenLoadKeychainIdentity + originalLoadOrganization := tokenLoadKeychainForCorpID + originalLoadGlobal := tokenLoadKeychain + originalSaveIdentity := tokenSaveKeychainForIdentity + t.Cleanup(func() { + tokenResolveProfile = originalResolveProfile + tokenLoadKeychainIdentity = originalLoadIdentity + tokenLoadKeychainForCorpID = originalLoadOrganization + tokenLoadKeychain = originalLoadGlobal + tokenSaveKeychainForIdentity = originalSaveIdentity + }) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := &TokenData{ + AccessToken: "matrix-token-" + strings.ReplaceAll(tt.name, "/", "-"), + CorpID: corpID, + UserID: tt.tokenUserID, + } + tokenResolveProfile = func(string, string) (*Profile, error) { + profile := tt.profile + return &profile, nil + } + tokenLoadKeychainIdentity = func(string, string) (*TokenData, error) { + if tt.slot == identitySlot { + return token, nil + } + return nil, ErrTokenDataNotFound + } + tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { + if tt.slot == orgSlot { + return token, nil + } + return nil, ErrTokenDataNotFound + } + tokenLoadKeychain = func() (*TokenData, error) { + if tt.slot == globalSlot { + return token, nil + } + return nil, ErrTokenDataNotFound + } + tokenSaveKeychainForIdentity = func(string, string, *TokenData) error { return nil } + + loaded, err := loadTokenDataForProfileLocked(t.TempDir(), "") + if tt.wantAccepted { + if err != nil || loaded == nil || loaded.AccessToken != token.AccessToken { + t.Fatalf("matrix cell accepted = false: loaded=%#v err=%v", loaded, err) + } + return + } + if err == nil || loaded != nil { + t.Fatalf("matrix cell accepted = true: loaded=%#v err=%v", loaded, err) + } + }) + } + + // Slot names do not make a token from another organization trustworthy. + for _, profile := range []Profile{ + {Name: "blank wrong-corp matrix profile", CorpID: corpID}, + {Name: "exact wrong-corp matrix profile", CorpID: corpID, UserID: exactUserID}, + } { + t.Run(profile.Name, func(t *testing.T) { + tokenResolveProfile = func(string, string) (*Profile, error) { + selected := profile + return &selected, nil + } + tokenLoadKeychainIdentity = func(string, string) (*TokenData, error) { + return &TokenData{AccessToken: "wrong-corp", CorpID: "corp_other", UserID: profile.UserID}, nil + } + tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { + return &TokenData{AccessToken: "wrong-corp", CorpID: "corp_other", UserID: profile.UserID}, nil + } + tokenLoadKeychain = func() (*TokenData, error) { return nil, ErrTokenDataNotFound } + if loaded, err := loadTokenDataForProfileLocked(t.TempDir(), ""); err == nil || loaded != nil { + t.Fatalf("wrong-corp token accepted: loaded=%#v err=%v", loaded, err) + } + }) + } +} + +func TestTokenPersistenceWritePlanKeepsOrganizationOwnershipIsolated(t *testing.T) { + const ( + corpID = "corp_write_plan" + resolvedUID = "resolved-user" + coexistingID = "coexisting-user" + ) + tests := []struct { + name string + cfg *ProfilesConfig + data *TokenData + runtimeSelector string + wantUpgrade bool + wantOrganization bool + wantMakeCurrent bool + }{ + { + name: "explicit reauth completes sole unresolved profile", + cfg: &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{{Name: "Historical Worker", CorpID: corpID}}, + }, + data: &TokenData{CorpID: corpID, UserID: resolvedUID}, + runtimeSelector: corpID, + wantUpgrade: true, + wantOrganization: true, + }, + { + name: "coexisting exact identity cannot overwrite unresolved owner", + cfg: &ProfilesConfig{ + Version: profilesUnresolvedSelectorVersion, + Profiles: []Profile{ + {Name: "Historical Worker", CorpID: corpID}, + {Name: "Exact Worker", CorpID: corpID, UserID: coexistingID}, + }, + }, + data: &TokenData{CorpID: corpID, UserID: coexistingID}, + runtimeSelector: profileSelector(corpID, coexistingID), + wantOrganization: false, + }, + { + name: "default exact login without unresolved owner updates organization", + cfg: &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{{Name: "Exact Worker", CorpID: corpID, UserID: resolvedUID}}, + }, + data: &TokenData{CorpID: corpID, UserID: resolvedUID}, + wantOrganization: true, + wantMakeCurrent: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plan := planTokenPersistenceWrites(tt.cfg, tt.data, tt.runtimeSelector) + if !plan.WriteIdentity || !plan.WriteGlobal { + t.Fatalf("write plan = %#v, want identity and global slots included", plan) + } + if plan.UpgradesLegacyProfile != tt.wantUpgrade { + t.Fatalf("UpgradesLegacyProfile = %v, want %v", plan.UpgradesLegacyProfile, tt.wantUpgrade) + } + if plan.WriteOrganization != tt.wantOrganization { + t.Fatalf("WriteOrganization = %v, want %v", plan.WriteOrganization, tt.wantOrganization) + } + if plan.MakeCurrent != tt.wantMakeCurrent { + t.Fatalf("MakeCurrent = %v, want %v", plan.MakeCurrent, tt.wantMakeCurrent) + } + }) + } +} + +func TestFreshUIDLessExactLoginCannotOverwriteExistingUnresolvedProfile(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + exactSelector := profileSelector(fixture.corpID, fixture.alpha.UserID) + previousRuntimeProfile := RuntimeProfile() + SetRuntimeProfile(exactSelector) + t.Cleanup(func() { SetRuntimeProfile(previousRuntimeProfile) }) + + freshUnknown := testToken("fresh-unknown-exact-target", fixture.corpID, fixture.blank.CorpName) + freshUnknown.UserID = "" + freshUnknown.UserName = "" + freshUnknown.LegacyOrgScopedProfile = exactSelector + freshUnknown.FreshAuthorization = true + + if err := preflightTokenWritePersistence(fixture.configDir, freshUnknown); err == nil || + !strings.Contains(err.Error(), "refusing to save UID-less token") { + t.Fatalf("preflightTokenWritePersistence() error = %v, want unresolved-sibling protection", err) + } + if err := SaveTokenData(fixture.configDir, freshUnknown); err == nil || + !strings.Contains(err.Error(), "refusing to save UID-less token") { + t.Fatalf("SaveTokenData() error = %v, want unresolved-sibling protection", err) + } + + assertOrganizationTokenAccessForTest(t, fixture.corpID, fixture.blank.AccessToken, "") + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.alpha.UserID, fixture.alpha.AccessToken) + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.beta.UserID, fixture.beta.AccessToken) + + // The same fresh authorization is also ambiguous without --profile. It must + // not inherit or overwrite whichever same-corp account happens to be current. + SetRuntimeProfile("") + freshUnknown.LegacyOrgScopedProfile = "" + if err := preflightTokenWritePersistence(fixture.configDir, freshUnknown); err == nil || + !strings.Contains(err.Error(), "fresh UID-less token") { + t.Fatalf("implicit preflight error = %v, want multi-account unresolved protection", err) + } + if err := SaveLoginTokenData(fixture.configDir, freshUnknown); err == nil || + !strings.Contains(err.Error(), "fresh UID-less token") { + t.Fatalf("implicit SaveLoginTokenData() error = %v, want multi-account unresolved protection", err) + } + assertOrganizationTokenAccessForTest(t, fixture.corpID, fixture.blank.AccessToken, "") + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.alpha.UserID, fixture.alpha.AccessToken) + + // Explicitly targeting the unresolved account remains a supported reauth + // path for external workers whose contact response still has no userId. + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + unresolved := unresolvedProfileForCorp(cfg, fixture.corpID) + if unresolved == nil { + t.Fatal("unresolved profile missing") + } + unresolvedSelector := storedProfileSelector(cfg, unresolved) + SetRuntimeProfile(unresolvedSelector) + freshUnknown.LegacyOrgScopedProfile = unresolvedSelector + if err := SaveTokenData(fixture.configDir, freshUnknown); err != nil { + t.Fatalf("SaveTokenData(explicit unresolved reauth) error = %v", err) + } + assertOrganizationTokenAccessForTest(t, fixture.corpID, freshUnknown.AccessToken, "") +} + +func TestCanonicalStoredSelectorKeepsCorpIDPriorityOverLegacyLocalName(t *testing.T) { + const ( + organizationCorpID = "corp_selector_priority" + legacyCorpID = "corp_legacy_local_name" + ) + cfg := &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{ + { + Name: "Organization Account", + CorpID: organizationCorpID, + CorpName: "Organization", + UserID: "organization-user", + }, + { + Name: organizationCorpID, + CorpID: legacyCorpID, + CorpName: "Legacy Organization", + }, + { + Name: "Legacy Exact Account", + CorpID: legacyCorpID, + CorpName: "Legacy Organization", + UserID: "legacy-exact-user", + }, + }, + } + + want := profileSelector(organizationCorpID, "organization-user") + if got := canonicalStoredSelector(cfg, organizationCorpID); got != want { + t.Fatalf("canonicalStoredSelector(CorpID conflict) = %q, want organization selector %q", got, want) + } +} + +func TestCanonicalStoredSelectorKeepsCorpNamePriorityOverLegacyLocalName(t *testing.T) { + const ( + organizationCorpID = "corp_name_priority" + legacyCorpID = "corp_legacy_corp_name" + organizationName = "Shared Organization Name" + ) + cfg := &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{ + { + Name: "Organization Account", + CorpID: organizationCorpID, + CorpName: organizationName, + UserID: "organization-user", + }, + { + Name: organizationName, + CorpID: legacyCorpID, + CorpName: "Legacy Organization", + }, + { + Name: "Legacy Exact Account", + CorpID: legacyCorpID, + CorpName: "Legacy Organization", + UserID: "legacy-exact-user", + }, + }, + } + + want := profileSelector(organizationCorpID, "organization-user") + if got := canonicalStoredSelector(cfg, organizationName); got != want { + t.Fatalf("canonicalStoredSelector(CorpName conflict) = %q, want organization selector %q", got, want) + } +} + +func TestCanonicalStoredSelectorRecoversUncontestedLegacyLocalName(t *testing.T) { + const ( + corpID = "corp_uncontested_local_name" + localName = "Historical External Worker" + ) + cfg := &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{ + { + Name: localName, + CorpID: corpID, + CorpName: "Local Name Organization", + }, + { + Name: "Exact Account", + CorpID: corpID, + CorpName: "Local Name Organization", + UserID: "exact-user", + }, + }, + } + + got := canonicalStoredSelector(cfg, localName) + if got != localName { + t.Fatalf("canonicalStoredSelector(uncontested local name) = %q, want %q", got, localName) + } + selected, exact, err := resolveProfileSelection("", cfg, got) + if err != nil { + t.Fatalf("resolveProfileSelection(recovered local name) error = %v", err) + } + if !exact || selected == nil || selected.CorpID != corpID || selected.UserID != "" { + t.Fatalf("recovered local selector resolved to %#v exact=%v, want unresolved profile", selected, exact) + } +} + +func TestSetCurrentProfileRejectsUnreadableExactIdentityBesideBlankProfile(t *testing.T) { + fixture := installUnreadableExactProfileSelectionFixture(t, false) + + selected, err := setCurrentProfileLocked(fixture.configDir, fixture.unreadableSelector) + if !errors.Is(err, fixture.unreadableErr) { + t.Fatalf("setCurrentProfileLocked() = %#v, %v; want unreadable token error", selected, err) + } + fixture.assertUnchanged(t) +} + +func TestUsePreviousProfileRejectsUnreadableExactIdentityBesideBlankProfile(t *testing.T) { + fixture := installUnreadableExactProfileSelectionFixture(t, true) + + selected, err := usePreviousProfileLocked(fixture.configDir) + if !errors.Is(err, fixture.unreadableErr) { + t.Fatalf("usePreviousProfileLocked() = %#v, %v; want unreadable token error", selected, err) + } + fixture.assertUnchanged(t) +} + +func TestSetCurrentProfileRejectsMismatchedExactIdentitySlotBesideBlankProfile(t *testing.T) { + fixture := installUnreadableExactProfileSelectionFixture(t, false) + fixture.unreadableToken = &TokenData{ + AccessToken: "readable-but-wrong-identity-token", + CorpID: fixture.corpID, + UserID: "another-exact-user", + } + + selected, err := setCurrentProfileLocked(fixture.configDir, fixture.unreadableSelector) + if err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("setCurrentProfileLocked() = %#v, %v; want identity mismatch error", selected, err) + } + fixture.assertUnchanged(t) +} + +func TestReservedUnresolvedSelectorPromotesSchemaToDowngradeGuardVersion(t *testing.T) { + const wantDowngradeGuardVersion = 3 + configDir := t.TempDir() + corpID := "corp_reserved_schema_guard" + reserved := unresolvedProfileSelector(corpID) + cfg := &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: reserved, + Profiles: []Profile{ + {Name: "Reserved Organization", CorpID: corpID, CorpName: "Reserved Organization"}, + {Name: "Exact Account", CorpID: corpID, CorpName: "Reserved Organization", UserID: "exact-user"}, + }, + } + + if err := SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + loaded, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if loaded.Version != wantDowngradeGuardVersion { + t.Fatalf("profiles version = %d, want downgrade guard version %d", loaded.Version, wantDowngradeGuardVersion) + } + if loaded.CurrentProfile != reserved { + t.Fatalf("current profile = %q, want %q", loaded.CurrentProfile, reserved) + } + if err := ensureProfilesWritable(loaded); err != nil { + t.Fatalf("current client rejected reserved-selector schema: %v", err) + } +} + +func TestV2ProfilesWithoutReservedSelectorRemainDowngradeReadable(t *testing.T) { + configDir := t.TempDir() + cfg := &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: "corp_v2_safe:user_v2_safe", + Profiles: []Profile{{ + Name: "V2 Safe Account", + CorpID: "corp_v2_safe", + UserID: "user_v2_safe", + }}, + } + + if err := SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + loaded, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if loaded.Version != profilesVersion { + t.Fatalf("ordinary profiles version = %d, want v2 compatibility version %d", loaded.Version, profilesVersion) + } +} + +func TestInvalidReservedOrgCurrentSelectorNormalizesBackToV2(t *testing.T) { + configDir := t.TempDir() + corpID := "corp_reserved_org_current_guard" + cfg := &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: profileSelector(corpID, "exact-user"), + OrgCurrentProfiles: map[string]string{ + corpID: unresolvedProfileSelector(corpID), + }, + Profiles: []Profile{ + {Name: "Reserved Organization", CorpID: corpID, CorpName: "Reserved Organization"}, + {Name: "Exact Account", CorpID: corpID, CorpName: "Reserved Organization", UserID: "exact-user"}, + }, + } + + if err := SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + raw, err := os.ReadFile(ProfilesPath(configDir)) + if err != nil { + t.Fatalf("ReadFile(profiles.json) error = %v", err) + } + var persisted struct { + Version int `json:"version"` + OrgCurrentProfiles map[string]string `json:"orgCurrentProfiles"` + } + if err := json.Unmarshal(raw, &persisted); err != nil { + t.Fatalf("json.Unmarshal(profiles.json) error = %v", err) + } + if persisted.Version != profilesVersion { + t.Fatalf("persisted profiles version = %d, want %d", persisted.Version, profilesVersion) + } + if _, exists := persisted.OrgCurrentProfiles[corpID]; exists { + t.Fatalf("invalid organization-current selector survived normalization: %#v", persisted.OrgCurrentProfiles) + } +} + +func TestEnsureMigrationPersistsDowngradeGuardForRawV2ReservedSelector(t *testing.T) { + const wantDowngradeGuardVersion = 3 + t.Setenv(keychain.DisableKeychainEnv, "1") + cleanupKeychain(t) + configDir := t.TempDir() + corpID := "corp_raw_v2_reserved_guard" + cfg := &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: unresolvedProfileSelector(corpID), + Profiles: []Profile{ + {Name: "Raw V2 Organization", CorpID: corpID, CorpName: "Raw V2 Organization"}, + {Name: "Exact Account", CorpID: corpID, CorpName: "Raw V2 Organization", UserID: "exact-user"}, + }, + } + writeRawProfilesFixture(t, configDir, cfg) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if got := readRawProfilesVersion(t, configDir); got != wantDowngradeGuardVersion { + t.Fatalf("persisted profiles version = %d, want %d", got, wantDowngradeGuardVersion) + } +} + +func TestV1ReservedSelectorMigrationStillRunsBeforeDowngradeGuardPromotion(t *testing.T) { + const wantDowngradeGuardVersion = 3 + t.Setenv(keychain.DisableKeychainEnv, "1") + cleanupKeychain(t) + configDir := t.TempDir() + corpID := "corp_raw_v1_reserved_guard" + cfg := &ProfilesConfig{ + Version: 1, + CurrentProfile: unresolvedProfileSelector(corpID), + Profiles: []Profile{ + {Name: "Raw V1 Organization", CorpID: corpID, CorpName: "Raw V1 Organization"}, + {Name: "Exact Account", CorpID: corpID, CorpName: "Raw V1 Organization", UserID: "exact-user"}, + }, + } + writeRawProfilesFixture(t, configDir, cfg) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + if got := readRawProfilesVersion(t, configDir); got != wantDowngradeGuardVersion { + t.Fatalf("persisted profiles version = %d, want %d", got, wantDowngradeGuardVersion) + } +} + +func TestV1MigrationStillPromotesOnlyToIdentitySchema(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + cleanupKeychain(t) + configDir := t.TempDir() + token := testToken("legacy-v1-token", "corp_v1_schema", "V1 Schema Organization") + token.UserID = "legacy-v1-user" + if err := SaveTokenDataKeychainForCorpID(token.CorpID, token); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) + } + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: 1, + CurrentProfile: profileSelector(token.CorpID, token.UserID), + Profiles: []Profile{{ + Name: token.CorpName, + CorpID: token.CorpID, + CorpName: token.CorpName, + UserID: token.UserID, + }}, + }); err != nil { + t.Fatalf("SaveProfiles(v1 fixture) error = %v", err) + } + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + loaded, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if loaded.Version != profilesVersion { + t.Fatalf("migrated profiles version = %d, want identity schema %d", loaded.Version, profilesVersion) + } + if !TokenDataExistsKeychainForIdentity(token.CorpID, token.UserID) { + t.Fatal("v1 migration did not create the exact identity token slot") + } +} + +func TestExplicitReauthUpgradingSoleBlankProfilePublishesAllTokenMirrors(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + cleanupKeychain(t) + configDir := t.TempDir() + legacy := testToken("legacy-blank-access", "corp_explicit_reauth", "Explicit Reauth Organization") + legacy.UserID = "" + legacy.UserName = "" + if err := SaveTokenData(configDir, legacy); err != nil { + t.Fatalf("SaveTokenData(legacy blank) error = %v", err) + } + + SetRuntimeProfile(legacy.CorpID) + t.Cleanup(func() { SetRuntimeProfile("") }) + reauthorized := *legacy + reauthorized.AccessToken = "reauthorized-exact-access" + reauthorized.RefreshToken = "reauthorized-exact-refresh" + reauthorized.UserID = "resolved-external-worker" + reauthorized.UserName = "Resolved External Worker" + if err := preflightTokenWritePersistence(configDir, &reauthorized); err != nil { + t.Fatalf("preflightTokenWritePersistence(explicit reauth) error = %v", err) + } + if err := SaveTokenData(configDir, &reauthorized); err != nil { + t.Fatalf("SaveTokenData(explicit reauth) error = %v", err) + } + + assertIdentityTokenAccessForTest(t, reauthorized.CorpID, reauthorized.UserID, reauthorized.AccessToken) + assertOrganizationTokenAccessForTest(t, reauthorized.CorpID, reauthorized.AccessToken, reauthorized.UserID) + global, err := LoadTokenDataKeychain() + if err != nil { + t.Fatalf("LoadTokenDataKeychain() error = %v", err) + } + if global.AccessToken != reauthorized.AccessToken || global.UserID != reauthorized.UserID { + t.Fatalf("global token after explicit reauth = %#v, want reauthorized exact token", global) + } + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if len(cfg.Profiles) != 1 || cfg.Profiles[0].UserID != reauthorized.UserID { + t.Fatalf("profiles after explicit reauth = %#v, want one resolved identity", cfg.Profiles) + } +} + +func TestExplicitReauthLegacyUpgradeRejectsUnreadableOrganizationMirror(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + cleanupKeychain(t) + configDir := t.TempDir() + legacy := testToken("legacy-corrupt-org-access", "corp_explicit_reauth_corrupt", "Corrupt Reauth Organization") + legacy.UserID = "" + legacy.UserName = "" + if err := SaveTokenData(configDir, legacy); err != nil { + t.Fatalf("SaveTokenData(legacy blank) error = %v", err) + } + if err := keychain.Set(keychain.Service, TokenAccountForCorpID(legacy.CorpID), "{unreadable"); err != nil { + t.Fatalf("write unreadable organization mirror: %v", err) + } + + SetRuntimeProfile(legacy.CorpID) + t.Cleanup(func() { SetRuntimeProfile("") }) + reauthorized := *legacy + reauthorized.AccessToken = "must-not-persist" + reauthorized.RefreshToken = "must-not-persist-refresh" + reauthorized.UserID = "resolved-external-worker" + reauthorized.UserName = "Resolved External Worker" + err := preflightTokenWritePersistence(configDir, &reauthorized) + if err == nil || !strings.Contains(err.Error(), "profile token slot") || + !strings.Contains(err.Error(), "parse token data") { + t.Fatalf("preflightTokenWritePersistence() error = %v, want unreadable organization mirror", err) + } + if TokenDataExistsKeychainForIdentity(reauthorized.CorpID, reauthorized.UserID) { + t.Fatal("rejected explicit reauth created an identity token") + } + global, loadErr := LoadTokenDataKeychain() + if loadErr != nil { + t.Fatalf("LoadTokenDataKeychain() error = %v", loadErr) + } + if global.AccessToken != legacy.AccessToken || global.UserID != "" { + t.Fatalf("rejected explicit reauth changed global token: %#v", global) + } +} + +func TestV3DowngradesAfterBlankProfileIdentityCompletion(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + cleanupKeychain(t) + configDir := t.TempDir() + corpID := "corp_v3_identity_completion" + resolved := testToken("resolved-org-access", corpID, "V3 Completion Organization") + resolved.UserID = "resolved-user" + resolved.UserName = "Resolved User" + if err := SaveTokenDataKeychainForCorpID(corpID, resolved); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) + } + writeRawProfilesFixture(t, configDir, &ProfilesConfig{ + Version: profilesUnresolvedSelectorVersion, + CurrentProfile: unresolvedProfileSelector(corpID), + Profiles: []Profile{{ + Name: "Historical External Worker", + CorpID: corpID, + CorpName: resolved.CorpName, + }}, + }) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.Version != profilesVersion { + t.Fatalf("profiles version after identity completion = %d, want %d", cfg.Version, profilesVersion) + } + if len(cfg.Profiles) != 1 || cfg.Profiles[0].UserID != resolved.UserID { + t.Fatalf("profiles after identity completion = %#v", cfg.Profiles) + } + if strings.Contains(string(readRawProfilesForTest(t, configDir)), unresolvedProfileSelectorPrefix) { + t.Fatal("identity completion left reserved selector grammar on disk") + } +} + +func TestV3DowngradesAfterUnresolvedProfileDeletion(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + fixture := seedBlankProfileSelectorFixture(t, "Deletion Organization", "Deletion Organization", true) + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + cfg.Version = profilesUnresolvedSelectorVersion + if err := SaveProfiles(fixture.configDir, cfg); err != nil { + t.Fatalf("SaveProfiles(v3 fixture) error = %v", err) + } + if err := DeleteTokenDataForProfile(fixture.configDir, fixture.blankSelector); err != nil { + t.Fatalf("DeleteTokenDataForProfile(blank) error = %v", err) + } + + cfg, err = LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles(after delete) error = %v", err) + } + if cfg.Version != profilesVersion { + t.Fatalf("profiles version after blank delete = %d, want %d", cfg.Version, profilesVersion) + } + if len(cfg.Profiles) != 1 || cfg.Profiles[0].UserID != fixture.exactUserID { + t.Fatalf("profiles after blank delete = %#v, want exact identity only", cfg.Profiles) + } +} + +func TestV3DowngradesWhenExactConflictDeletionLeavesSoleBlankProfile(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + fixture := seedBlankProfileSelectorFixture( + t, + "Conflict Organization", + "Conflict Organization", + true, + ) + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + cfg.Version = profilesUnresolvedSelectorVersion + cfg.CurrentProfile = fixture.blankSelector + cfg.PreviousProfile = fixture.exactSelector + if err := SaveProfiles(fixture.configDir, cfg); err != nil { + t.Fatalf("SaveProfiles(v3 conflict fixture) error = %v", err) + } + if got := readRawProfilesVersion(t, fixture.configDir); got != profilesUnresolvedSelectorVersion { + t.Fatalf("fixture profiles version = %d, want %d", got, profilesUnresolvedSelectorVersion) + } + + if err := DeleteTokenDataForProfile(fixture.configDir, fixture.exactSelector); err != nil { + t.Fatalf("DeleteTokenDataForProfile(exact conflict) error = %v", err) + } + + cfg, err = LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles(after exact delete) error = %v", err) + } + if cfg.Version != profilesVersion { + t.Fatalf("profiles version after exact conflict delete = %d, want %d", cfg.Version, profilesVersion) + } + if cfg.CurrentProfile != fixture.corpID { + t.Fatalf("current profile after exact conflict delete = %q, want corpId %q", cfg.CurrentProfile, fixture.corpID) + } + if len(cfg.Profiles) != 1 || + cfg.Profiles[0].CorpID != fixture.corpID || + cfg.Profiles[0].UserID != "" { + t.Fatalf("profiles after exact conflict delete = %#v, want sole unresolved profile", cfg.Profiles) + } + if strings.Contains(string(readRawProfilesForTest(t, fixture.configDir)), unresolvedProfileSelectorPrefix) { + t.Fatal("exact conflict deletion left reserved selector grammar on disk") + } + loaded, err := LoadTokenDataForProfile(fixture.configDir, "") + if err != nil { + t.Fatalf("LoadTokenDataForProfile(current blank) error = %v", err) + } + if loaded.AccessToken != fixture.blankToken.AccessToken || loaded.UserID != "" { + t.Fatalf("current token after exact conflict delete = %#v, want unresolved token %#v", loaded, fixture.blankToken) + } +} + +func TestV3DowngradesWhenReservedSelectorCanonicalizesToSafeLocalName(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + cleanupKeychain(t) + configDir := t.TempDir() + corpID := "corp_v3_safe_selector" + const safeName = "Historical External Worker" + writeRawProfilesFixture(t, configDir, &ProfilesConfig{ + Version: profilesUnresolvedSelectorVersion, + CurrentProfile: unresolvedProfileSelector(corpID), + Profiles: []Profile{ + {Name: safeName, CorpID: corpID, CorpName: "Safe Selector Organization"}, + {Name: "Exact Account", CorpID: corpID, CorpName: "Safe Selector Organization", UserID: "exact-user"}, + }, + }) + + if err := EnsureProfilesMigration(configDir); err != nil { + t.Fatalf("EnsureProfilesMigration() error = %v", err) + } + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.Version != profilesVersion || cfg.CurrentProfile != safeName { + t.Fatalf("normalized profiles = version %d current %q, want v%d current %q", cfg.Version, cfg.CurrentProfile, profilesVersion, safeName) + } + if strings.Contains(string(readRawProfilesForTest(t, configDir)), unresolvedProfileSelectorPrefix) { + t.Fatal("safe selector normalization left reserved grammar on disk") + } +} + +type unreadableExactProfileSelectionFixture struct { + configDir string + corpID string + unreadableSelector string + unreadableErr error + unreadableToken *TokenData + state *ProfilesConfig + before *ProfilesConfig + organizationToken *TokenData + globalToken *TokenData + beforeOrganization *TokenData + beforeGlobal *TokenData +} + +func installUnreadableExactProfileSelectionFixture(t *testing.T, unreadableIsPrevious bool) *unreadableExactProfileSelectionFixture { + t.Helper() + + originalEnsureMigration := profilesEnsureMigration + originalLoad := profilesLoad + originalSave := profilesSave + originalLoadIdentity := profilesLoadIdentity + originalLoadOrganization := profilesLoadCorp + originalSaveOrganization := profilesSaveCorp + originalDeleteOrganization := profilesDeleteCorp + originalLoadGlobal := profilesLoadLegacy + originalSaveGlobal := profilesSaveLegacy + originalDeleteGlobal := profilesDeleteLegacy + t.Cleanup(func() { + profilesEnsureMigration = originalEnsureMigration + profilesLoad = originalLoad + profilesSave = originalSave + profilesLoadIdentity = originalLoadIdentity + profilesLoadCorp = originalLoadOrganization + profilesSaveCorp = originalSaveOrganization + profilesDeleteCorp = originalDeleteOrganization + profilesLoadLegacy = originalLoadGlobal + profilesSaveLegacy = originalSaveGlobal + profilesDeleteLegacy = originalDeleteGlobal + }) + + const ( + corpID = "corp_profile_switch_guard" + unreadableUID = "unreadable-exact-user" + currentUID = "readable-current-user" + ) + unreadableSelector := profileSelector(corpID, unreadableUID) + currentSelector := profileSelector(corpID, currentUID) + previousSelector := "Legacy Contractor" + if unreadableIsPrevious { + previousSelector = unreadableSelector + } + state := &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: currentSelector, + PreviousProfile: previousSelector, + OrgCurrentProfiles: map[string]string{ + corpID: currentSelector, + }, + Profiles: []Profile{ + {Name: "Legacy Contractor", CorpID: corpID}, + {Name: "Unreadable Account", CorpID: corpID, UserID: unreadableUID}, + {Name: "Readable Account", CorpID: corpID, UserID: currentUID}, + }, + } + organizationToken := &TokenData{AccessToken: "legacy-organization-token", CorpID: corpID} + globalToken := &TokenData{AccessToken: "readable-global-token", CorpID: corpID, UserID: currentUID} + fixture := &unreadableExactProfileSelectionFixture{ + configDir: t.TempDir(), + corpID: corpID, + unreadableSelector: unreadableSelector, + unreadableErr: errors.New("decrypt exact identity token: key mismatch"), + state: cloneProfilesConfig(state), + before: cloneProfilesConfig(state), + organizationToken: cloneTokenDataForTest(organizationToken), + globalToken: cloneTokenDataForTest(globalToken), + beforeOrganization: cloneTokenDataForTest(organizationToken), + beforeGlobal: cloneTokenDataForTest(globalToken), + } + + profilesEnsureMigration = func(string) error { return nil } + profilesLoad = func(string) (*ProfilesConfig, error) { + return cloneProfilesConfig(fixture.state), nil + } + profilesSave = func(_ string, cfg *ProfilesConfig) error { + fixture.state = cloneProfilesConfig(cfg) + return nil + } + profilesLoadIdentity = func(_ string, userID string) (*TokenData, error) { + switch userID { + case unreadableUID: + if fixture.unreadableToken != nil { + return cloneTokenDataForTest(fixture.unreadableToken), nil + } + return nil, fixture.unreadableErr + case currentUID: + return cloneTokenDataForTest(fixture.globalToken), nil + default: + return nil, ErrTokenDataNotFound + } + } + profilesLoadCorp = func(string) (*TokenData, error) { + return cloneTokenDataForTest(fixture.organizationToken), nil + } + profilesSaveCorp = func(_ string, data *TokenData) error { + fixture.organizationToken = cloneTokenDataForTest(data) + return nil + } + profilesDeleteCorp = func(string) error { + fixture.organizationToken = nil + return nil + } + profilesLoadLegacy = func() (*TokenData, error) { + if fixture.globalToken == nil { + return nil, ErrTokenDataNotFound + } + return cloneTokenDataForTest(fixture.globalToken), nil + } + profilesSaveLegacy = func(data *TokenData) error { + fixture.globalToken = cloneTokenDataForTest(data) + return nil + } + profilesDeleteLegacy = func() error { + fixture.globalToken = nil + return nil + } + + return fixture +} + +func (f *unreadableExactProfileSelectionFixture) assertUnchanged(t *testing.T) { + t.Helper() + if !reflect.DeepEqual(f.state, f.before) { + t.Fatalf("profiles changed after rejected switch:\n got: %#v\nwant: %#v", f.state, f.before) + } + if !reflect.DeepEqual(f.organizationToken, f.beforeOrganization) { + t.Fatalf("organization mirror changed after rejected switch: got %#v want %#v", f.organizationToken, f.beforeOrganization) + } + if !reflect.DeepEqual(f.globalToken, f.beforeGlobal) { + t.Fatalf("global mirror changed after rejected switch: got %#v want %#v", f.globalToken, f.beforeGlobal) + } +} + +func cloneTokenDataForTest(data *TokenData) *TokenData { + if data == nil { + return nil + } + clone := *data + return &clone +} + +func writeRawProfilesFixture(t *testing.T, configDir string, cfg *ProfilesConfig) { + t.Helper() + raw, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("json.MarshalIndent(profiles fixture) error = %v", err) + } + if err := os.WriteFile(ProfilesPath(configDir), append(raw, '\n'), 0o600); err != nil { + t.Fatalf("WriteFile(profiles fixture) error = %v", err) + } +} + +func readRawProfilesVersion(t *testing.T, configDir string) int { + t.Helper() + raw, err := os.ReadFile(ProfilesPath(configDir)) + if err != nil { + t.Fatalf("ReadFile(profiles.json) error = %v", err) + } + var persisted struct { + Version int `json:"version"` + } + if err := json.Unmarshal(raw, &persisted); err != nil { + t.Fatalf("json.Unmarshal(profiles.json) error = %v", err) + } + return persisted.Version +} + +func readRawProfilesForTest(t *testing.T, configDir string) []byte { + t.Helper() + raw, err := os.ReadFile(ProfilesPath(configDir)) + if err != nil { + t.Fatalf("ReadFile(profiles.json) error = %v", err) + } + return raw +} diff --git a/internal/auth/keychain_store.go b/internal/auth/keychain_store.go index 74309c07..408933d6 100644 --- a/internal/auth/keychain_store.go +++ b/internal/auth/keychain_store.go @@ -138,11 +138,177 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) { return &data, nil } -// preflightTokenPersistence verifies that every registered token slot can be -// read before an OAuth login or exchange can target any profile. -// A missing slot is safe (first login or a legacy fallback); any other error -// stops the remote operation when existing ciphertext is already known to be -// unreadable and therefore unsafe to update. +// prepareLoginPersistence rejects profile registries written by a newer client +// and protects the legacy global mirror before a new authorization flow +// performs remote work. Version-1 registries keep using the existing full +// migration in saveTokenDataLocked before any compatibility mirror is +// overwritten. +// +// For v2/v3, only the organization referenced by the readable global mirror is +// inspected. A uniquely matching half-migrated profile is repaired from that +// mirror under the profiles lock. Missing or damaged slots in unrelated +// organizations and orphan inventory are deliberately not scanned. +func prepareLoginPersistence(configDir string) error { + if h := edition.Get(); h.SaveToken != nil { + return nil + } + return withProfilesLock(configDir, func() error { + cfg, err := profilesLoad(configDir) + if err != nil { + return fmt.Errorf("load token profiles: %w", err) + } + if err := ensureProfilesWritable(cfg); err != nil { + return err + } + return repairHalfMigratedGlobalTokenLocked(cfg) + }) +} + +// repairHalfMigratedGlobalTokenLocked preserves the only readable copy left by +// an interrupted v1.0.53 migration. The caller must hold the profiles lock. +func repairHalfMigratedGlobalTokenLocked(cfg *ProfilesConfig) error { + if cfg == nil || cfg.Version < profilesVersion || len(cfg.Profiles) == 0 { + return nil + } + + global, err := profilesLoadLegacy() + if errors.Is(err, ErrTokenDataNotFound) { + return nil + } + if err != nil { + return fmt.Errorf( + "legacy token slot %q is unreadable; refusing to overwrite a potentially unique old login: %w", + keychain.AccountToken, + err, + ) + } + if global == nil { + return nil + } + corpID := strings.TrimSpace(global.CorpID) + if corpID == "" { + return nil + } + profiles := profilesForCorpID(cfg, corpID) + if len(profiles) == 0 { + // A readable global token for an unregistered organization is an orphan, + // not a profile credential that this registry still promises to retain. + return nil + } + + orgToken, orgErr := profilesLoadCorp(corpID) + allCanonical := true + for _, profile := range profiles { + if !loginProfileHasUsableCanonicalToken(profile, orgToken, orgErr) { + allCanonical = false + break + } + } + if allCanonical { + return nil + } + + profile := uniqueV2GlobalRepairProfile(cfg, corpID) + if profile == nil { + return fmt.Errorf( + "legacy token slot %q may be the only recoverable login for one of %d accounts in organization %q; refusing to overwrite it until each account has a usable identity slot", + keychain.AccountToken, + len(profiles), + corpID, + ) + } + userID := strings.TrimSpace(profile.UserID) + if userID != "" && + orgErr == nil && + loginTokenHasCredentialMaterial(orgToken) && + legacyTokenMatchesV2RepairProfile(orgToken, profile) { + if err := repairLoginIdentityToken(profile, orgToken); err != nil { + return err + } + return nil + } + if !legacyTokenMatchesV2RepairProfile(global, profile) { + return fmt.Errorf( + "legacy token slot %q does not safely match the only profile in organization %q; refusing to overwrite a potentially unique old login", + keychain.AccountToken, + corpID, + ) + } + if !loginTokenHasCredentialMaterial(global) { + return fmt.Errorf( + "legacy token slot %q has no recoverable credential material for organization %q; refusing to overwrite a potentially unique old login", + keychain.AccountToken, + corpID, + ) + } + + // The matching global token is the only recoverable copy. Overwrite a + // damaged organization slot as well as filling a missing one. + if err := profilesSaveCorp(corpID, global); err != nil { + return fmt.Errorf("repair organization token slot %q: %w", TokenAccountForCorpID(corpID), err) + } + if userID == "" { + return nil + } + return repairLoginIdentityToken(profile, global) +} + +func loginProfileHasUsableCanonicalToken( + profile *Profile, + orgToken *TokenData, + orgErr error, +) bool { + if profile == nil { + return false + } + corpID := strings.TrimSpace(profile.CorpID) + userID := strings.TrimSpace(profile.UserID) + if userID == "" { + return orgErr == nil && + loginTokenHasCredentialMaterial(orgToken) && + strings.TrimSpace(orgToken.CorpID) == corpID && + strings.TrimSpace(orgToken.UserID) == "" + } + identity, err := profilesLoadIdentity(corpID, userID) + if err == nil && + loginTokenHasCredentialMaterial(identity) && + strings.TrimSpace(identity.CorpID) == corpID && + strings.TrimSpace(identity.UserID) == userID { + return true + } + return false +} + +func loginTokenHasCredentialMaterial(data *TokenData) bool { + return data != nil && + (strings.TrimSpace(data.AccessToken) != "" || + strings.TrimSpace(data.RefreshToken) != "" || + strings.TrimSpace(data.PersistentCode) != "") +} + +func repairLoginIdentityToken(profile *Profile, source *TokenData) error { + corpID := strings.TrimSpace(profile.CorpID) + userID := strings.TrimSpace(profile.UserID) + identityToken := source + if strings.TrimSpace(source.UserID) == "" { + enriched := *source + enriched.UserID = userID + if strings.TrimSpace(enriched.UserName) == "" { + enriched.UserName = strings.TrimSpace(profile.UserName) + } + identityToken = &enriched + } + if err := profilesSaveIdentity(corpID, userID, identityToken); err != nil { + return fmt.Errorf("repair identity token slot %q: %w", TokenAccountForIdentity(corpID, userID), err) + } + return nil +} + +// preflightTokenPersistence verifies every persisted token slot, including +// unregistered/orphan ciphertext. Keep this full-inventory validator for +// migration, export and explicit storage diagnostics; login must use the +// schema-only and target-only preflights instead so an unrelated damaged +// account cannot block reauthorization. func preflightTokenPersistence(configDir string) error { if h := edition.Get(); h.SaveToken != nil { return nil @@ -191,10 +357,11 @@ func preflightTokenPersistence(configDir string) error { return nil } -// preflightTokenRefreshPersistence checks only the slots a refresh can write. -// An unrelated broken profile must not prevent the current profile from using -// its still-valid credentials. -func preflightTokenRefreshPersistence(configDir string, data *TokenData) error { +// preflightTokenWritePersistence checks only the slots SaveTokenData can write +// for data under the current runtime selector. It is shared by login and +// refresh so both paths stay aligned with the same identity/org/global mirror +// isolation rules. +func preflightTokenWritePersistence(configDir string, data *TokenData) error { if h := edition.Get(); h.SaveToken != nil { return nil } @@ -206,41 +373,46 @@ func preflightTokenRefreshPersistence(configDir string, data *TokenData) error { return err } - if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) { - return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err) + plan := planTokenPersistenceWrites(cfg, data, RuntimeProfile()) + if err := validateTokenPersistenceWritePlan(cfg, data, plan); err != nil { + return err } - if data == nil || strings.TrimSpace(data.CorpID) == "" { - return nil - } - corpID := strings.TrimSpace(data.CorpID) - userID := strings.TrimSpace(data.UserID) - if userID != "" { - if _, err := LoadTokenDataKeychainForIdentity(corpID, userID); err != nil && !errors.Is(err, ErrTokenDataNotFound) { - return fmt.Errorf("identity token slot %q is unreadable: %w", TokenAccountForIdentity(corpID, userID), err) + if plan.WriteGlobal { + if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) { + return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err) } } - runtimeSelector := strings.TrimSpace(RuntimeProfile()) - persistenceSelector := runtimeSelector - if persistenceSelector == "" && userID == "" { - persistenceSelector = strings.TrimSpace(data.LegacyOrgScopedProfile) + if plan.CorpID == "" { + return nil } - makeCurrent := persistenceSelector == "" - existingIdentity := profileIndexByIdentity(cfg, corpID, userID) >= 0 - upgradesLegacyProfile := !existingIdentity && userID != "" && - len(profilesForCorpID(cfg, corpID)) == 1 && legacyProfileIndexByCorpID(cfg, corpID) >= 0 - preserveUnresolvedOrg := userID != "" && - unresolvedProfileForCorp(cfg, corpID) != nil && !upgradesLegacyProfile - checkOrganizationMirror := userID == "" || (!preserveUnresolvedOrg && (makeCurrent || - exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) == - profileSelector(corpID, userID))) - if checkOrganizationMirror { - if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) { - return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err) + if plan.WriteIdentity { + if _, err := LoadTokenDataKeychainForIdentity(plan.CorpID, plan.UserID); err != nil && !errors.Is(err, ErrTokenDataNotFound) { + return fmt.Errorf( + "identity token slot %q is unreadable: %w", + TokenAccountForIdentity(plan.CorpID, plan.UserID), + err, + ) + } + } + if plan.WriteOrganization { + if _, err := LoadTokenDataKeychainForCorpID(plan.CorpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) { + return fmt.Errorf( + "profile token slot %q is unreadable: %w", + TokenAccountForCorpID(plan.CorpID), + err, + ) } } return nil } +// preflightTokenRefreshPersistence checks only the slots a refresh can write. +// An unrelated broken profile must not prevent the current profile from using +// its still-valid credentials. +func preflightTokenRefreshPersistence(configDir string, data *TokenData) error { + return preflightTokenWritePersistence(configDir, data) +} + // DeleteTokenDataKeychain removes TokenData from the platform keychain. func DeleteTokenDataKeychain() error { return authKeychainRemove(keychain.Service, keychain.AccountToken) diff --git a/internal/auth/login_persistence_preflight_test.go b/internal/auth/login_persistence_preflight_test.go new file mode 100644 index 00000000..d82fa429 --- /dev/null +++ b/internal/auth/login_persistence_preflight_test.go @@ -0,0 +1,1392 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package auth + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type loginPreflightRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f loginPreflightRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +// loginPreflightMemoryListener keeps OAuth callback tests hermetic. The +// production flow still sees a TCP-shaped address, while HTTP travels through +// net.Pipe and therefore does not require permission to bind a loopback port. +type loginPreflightMemoryListener struct { + connections chan net.Conn + closed chan struct{} + closeOnce sync.Once +} + +func newLoginPreflightMemoryListener() *loginPreflightMemoryListener { + return &loginPreflightMemoryListener{ + connections: make(chan net.Conn, 1), + closed: make(chan struct{}), + } +} + +func (l *loginPreflightMemoryListener) Accept() (net.Conn, error) { + select { + case connection := <-l.connections: + return connection, nil + case <-l.closed: + return nil, net.ErrClosed + } +} + +func (l *loginPreflightMemoryListener) Close() error { + l.closeOnce.Do(func() { close(l.closed) }) + return nil +} + +func (l *loginPreflightMemoryListener) Addr() net.Addr { + return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 43819} +} + +func (l *loginPreflightMemoryListener) Dial() (net.Conn, error) { + client, server := net.Pipe() + select { + case l.connections <- server: + return client, nil + case <-l.closed: + _ = client.Close() + _ = server.Close() + return nil, net.ErrClosed + } +} + +type loginPersistenceMemoryKeychain struct { + mu sync.Mutex + values map[string]string + readErrors map[string]error + writeErrors map[string]error + reads map[string]int +} + +func installLoginPersistenceMemoryKeychain(t *testing.T) *loginPersistenceMemoryKeychain { + t.Helper() + store := &loginPersistenceMemoryKeychain{ + values: make(map[string]string), + readErrors: make(map[string]error), + writeErrors: make(map[string]error), + reads: make(map[string]int), + } + oldGet := authKeychainGet + oldSet := authKeychainSet + oldRemove := authKeychainRemove + oldExists := authKeychainExists + t.Cleanup(func() { + authKeychainGet = oldGet + authKeychainSet = oldSet + authKeychainRemove = oldRemove + authKeychainExists = oldExists + }) + authKeychainGet = func(_, account string) (string, error) { + store.mu.Lock() + defer store.mu.Unlock() + store.reads[account]++ + if err := store.readErrors[account]; err != nil { + return "", err + } + return store.values[account], nil + } + authKeychainSet = func(_, account, value string) error { + store.mu.Lock() + defer store.mu.Unlock() + if err := store.writeErrors[account]; err != nil { + return err + } + store.values[account] = value + delete(store.readErrors, account) + return nil + } + authKeychainRemove = func(_, account string) error { + store.mu.Lock() + defer store.mu.Unlock() + delete(store.values, account) + delete(store.readErrors, account) + return nil + } + authKeychainExists = func(_, account string) bool { + store.mu.Lock() + defer store.mu.Unlock() + _, exists := store.values[account] + return exists && store.readErrors[account] == nil + } + return store +} + +func (s *loginPersistenceMemoryKeychain) putToken(t *testing.T, account string, token *TokenData) { + t.Helper() + raw, err := json.MarshalIndent(token, "", " ") + if err != nil { + t.Fatalf("json.MarshalIndent(token) error = %v", err) + } + s.mu.Lock() + defer s.mu.Unlock() + s.values[account] = string(raw) + delete(s.readErrors, account) +} + +func (s *loginPersistenceMemoryKeychain) token(account string) (*TokenData, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.readErrors[account]; err != nil { + return nil, err + } + raw := s.values[account] + if raw == "" { + return nil, ErrTokenDataNotFound + } + var token TokenData + if err := json.Unmarshal([]byte(raw), &token); err != nil { + return nil, err + } + return &token, nil +} + +func (s *loginPersistenceMemoryKeychain) setReadError(account string, err error) { + s.mu.Lock() + defer s.mu.Unlock() + s.readErrors[account] = err +} + +func (s *loginPersistenceMemoryKeychain) setWriteError(account string, err error) { + s.mu.Lock() + defer s.mu.Unlock() + if err == nil { + delete(s.writeErrors, account) + return + } + s.writeErrors[account] = err +} + +func (s *loginPersistenceMemoryKeychain) readCount(account string) int { + s.mu.Lock() + defer s.mu.Unlock() + return s.reads[account] +} + +func setLoginPreflightCredentials(t *testing.T) { + t.Helper() + SetClientID("login-preflight-client") + SetClientSecret("login-preflight-secret") + resetClientIDFromMCP() + t.Cleanup(func() { + SetClientID("") + SetClientSecret("") + resetClientIDFromMCP() + }) +} + +func writeFutureProfilesForLoginPreflight(t *testing.T, configDir string) { + t.Helper() + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatalf("MkdirAll(configDir) error = %v", err) + } + raw, err := json.Marshal(&ProfilesConfig{Version: profilesMaxVersion + 1}) + if err != nil { + t.Fatalf("json.Marshal(future profiles) error = %v", err) + } + if err := os.WriteFile(ProfilesPath(configDir), raw, 0o600); err != nil { + t.Fatalf("WriteFile(future profiles) error = %v", err) + } +} + +type halfMigratedLoginFixture struct { + configDir string + store *loginPersistenceMemoryKeychain + corpA string + userA string + tokenA *TokenData + corpB string + userB string + tokenB *TokenData +} + +func newHalfMigratedLoginFixture(t *testing.T) *halfMigratedLoginFixture { + t.Helper() + store := installLoginPersistenceMemoryKeychain(t) + configDir := t.TempDir() + previousRuntimeProfile := RuntimeProfile() + SetRuntimeProfile("") + t.Cleanup(func() { SetRuntimeProfile(previousRuntimeProfile) }) + suffix := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name()) + corpA := "corp-half-migrated-" + suffix + userA := "user-half-migrated-" + suffix + corpB := "corp-new-login-" + suffix + userB := "user-new-login-" + suffix + tokenA := testToken("access-half-migrated-"+suffix, corpA, "Half Migrated Organization") + tokenA.UserID = "" + tokenA.UserName = "" + tokenB := testToken("access-new-login-"+suffix, corpB, "New Login Organization") + tokenB.UserID = userB + tokenB.UserName = "New Login Account" + + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + PrimaryProfile: profileSelector(corpA, userA), + CurrentProfile: profileSelector(corpA, userA), + OrgCurrentProfiles: map[string]string{ + corpA: profileSelector(corpA, userA), + }, + Profiles: []Profile{{ + Name: "Half Migrated Account", + CorpID: corpA, + CorpName: tokenA.CorpName, + UserID: userA, + UserName: "Half Migrated Account", + Status: ProfileStatusActive, + }}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + store.putToken(t, keychain.AccountToken, tokenA) + return &halfMigratedLoginFixture{ + configDir: configDir, + store: store, + corpA: corpA, + userA: userA, + tokenA: tokenA, + corpB: corpB, + userB: userB, + tokenB: tokenB, + } +} + +func (f *halfMigratedLoginFixture) repairedError() error { + org, err := f.store.token(TokenAccountForCorpID(f.corpA)) + if err != nil { + return fmt.Errorf("organization repair is unavailable: %w", err) + } + if org.AccessToken != f.tokenA.AccessToken || org.CorpID != f.corpA || org.UserID != "" { + return fmt.Errorf("organization repair = %#v", org) + } + identity, err := f.store.token(TokenAccountForIdentity(f.corpA, f.userA)) + if err != nil { + return fmt.Errorf("identity repair is unavailable: %w", err) + } + if identity.AccessToken != f.tokenA.AccessToken || + identity.CorpID != f.corpA || + identity.UserID != f.userA { + return fmt.Errorf("identity repair = %#v", identity) + } + return nil +} + +func (f *halfMigratedLoginFixture) assertNewLoginPersisted(t *testing.T) { + t.Helper() + if err := f.repairedError(); err != nil { + t.Fatal(err) + } + global, err := f.store.token(keychain.AccountToken) + if err != nil { + t.Fatalf("global token after new login: %v", err) + } + if global.AccessToken != f.tokenB.AccessToken || + global.CorpID != f.corpB || + global.UserID != f.userB { + t.Fatalf("global token after new login = %#v, want B", global) + } +} + +func installHalfMigratedBrowserLogin( + t *testing.T, + beforeRemote func() error, + token *TokenData, +) { + t.Helper() + oldListen := oauthListen + oldOpenBrowser := oauthOpenBrowser + oldExchange := oauthExchange + oldCheckStatus := oauthCheckStatus + oldSave := oauthSaveToken + oldLoginTimeout := oauthLoginTimeout + t.Cleanup(func() { + oauthListen = oldListen + oauthOpenBrowser = oldOpenBrowser + oauthExchange = oldExchange + oauthCheckStatus = oldCheckStatus + oauthSaveToken = oldSave + oauthLoginTimeout = oldLoginTimeout + }) + oauthLoginTimeout = 2 * time.Second + listener := newLoginPreflightMemoryListener() + oauthListen = func(string, string) (net.Listener, error) { return listener, nil } + oauthOpenBrowser = func(authURL string) error { + parsed, err := url.Parse(authURL) + if err != nil { + return err + } + callbackURL, err := url.Parse(parsed.Query().Get("redirect_uri")) + if err != nil { + return err + } + query := callbackURL.Query() + query.Set("code", "half-migrated-repair") + callbackURL.RawQuery = query.Encode() + request, err := http.NewRequest(http.MethodGet, callbackURL.String(), nil) + if err != nil { + return err + } + connection, err := listener.Dial() + if err != nil { + return err + } + defer connection.Close() + if err := request.Write(connection); err != nil { + return err + } + response, err := http.ReadResponse(bufio.NewReader(connection), request) + if err != nil { + return err + } + _, _ = io.Copy(io.Discard, response.Body) + return response.Body.Close() + } + oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) { + if err := beforeRemote(); err != nil { + return nil, err + } + return token, nil + } + oauthCheckStatus = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) { + return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil + } + oauthSaveToken = SaveTokenData +} + +func TestExchangeCodeForTokenPreparesBeforeRemoteAndMarksFresh(t *testing.T) { + cleanupKeychain(t) + setLoginPreflightCredentials(t) + configDir := t.TempDir() + writeFutureProfilesForLoginPreflight(t, configDir) + + oldClient := oauthHTTPClient + oldSaveSecret := oauthSaveClientSecret + oldGet := authKeychainGet + oldValidate := authValidateEntries + t.Cleanup(func() { + oauthHTTPClient = oldClient + oauthSaveClientSecret = oldSaveSecret + authKeychainGet = oldGet + authValidateEntries = oldValidate + }) + + var httpCalls atomic.Int32 + var localReads atomic.Int32 + var inventoryCalls atomic.Int32 + oauthHTTPClient = &http.Client{Transport: loginPreflightRoundTripFunc(func(*http.Request) (*http.Response, error) { + httpCalls.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"accessToken":"standalone-access","refreshToken":"standalone-refresh","expiresIn":7200,"corpId":"corp_standalone"}`, + )), + }, nil + })} + oauthSaveClientSecret = func(string, string) error { return nil } + authKeychainGet = func(string, string) (string, error) { + localReads.Add(1) + return "", errors.New("unrelated local token is unreadable") + } + authValidateEntries = func(string) error { + inventoryCalls.Add(1) + return errors.New("unrelated orphan token is unreadable") + } + + data, err := ExchangeCodeForToken(context.Background(), configDir, "blocked-code") + if err == nil || !strings.Contains(err.Error(), "newer than supported") { + t.Fatalf("ExchangeCodeForToken(future schema) = %#v, %v; want pre-exchange rejection", data, err) + } + if got := httpCalls.Load(); got != 0 { + t.Fatalf("HTTP calls before rejected prepare = %d, want 0", got) + } + if got := localReads.Load(); got != 0 { + t.Fatalf("keychain reads before future-schema rejection = %d, want 0", got) + } + if got := inventoryCalls.Load(); got != 0 { + t.Fatalf("inventory validation calls = %d, want 0", got) + } + + // A safe registry proceeds to the remote exchange and marks the result so + // PAT persistence cannot mistake it for a refresh of an existing account. + safeConfigDir := t.TempDir() + authKeychainGet = func(string, string) (string, error) { + localReads.Add(1) + return "", nil + } + data, err = ExchangeCodeForToken(context.Background(), safeConfigDir, "standalone-code") + if err != nil { + t.Fatalf("ExchangeCodeForToken(safe) error = %v", err) + } + if data == nil || data.AccessToken != "standalone-access" || !data.FreshAuthorization { + t.Fatalf("ExchangeCodeForToken(safe) data = %#v", data) + } + if got := httpCalls.Load(); got != 1 { + t.Fatalf("HTTP calls after safe prepare = %d, want 1", got) + } +} + +func TestPersistingLoginFlowsRejectFutureProfilesBeforeRemoteWork(t *testing.T) { + t.Run("external auth-code exchange", func(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + writeFutureProfilesForLoginPreflight(t, configDir) + + oldExchange := oauthExchange + t.Cleanup(func() { oauthExchange = oldExchange }) + var calls atomic.Int32 + oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) { + calls.Add(1) + return &TokenData{AccessToken: "must-not-exchange"}, nil + } + + _, err := NewOAuthProvider(configDir, nil).ExchangeAuthCode(context.Background(), "code", "user") + if err == nil || !strings.Contains(err.Error(), "newer than supported") { + t.Fatalf("ExchangeAuthCode() error = %v, want future profiles rejection", err) + } + if got := calls.Load(); got != 0 { + t.Fatalf("remote exchange calls = %d, want 0", got) + } + }) + + t.Run("browser OAuth", func(t *testing.T) { + for _, force := range []bool{false, true} { + t.Run(fmt.Sprintf("force=%t", force), func(t *testing.T) { + cleanupKeychain(t) + setLoginPreflightCredentials(t) + configDir := t.TempDir() + writeFutureProfilesForLoginPreflight(t, configDir) + + oldListen := oauthListen + t.Cleanup(func() { oauthListen = oldListen }) + var calls atomic.Int32 + oauthListen = func(string, string) (net.Listener, error) { + calls.Add(1) + return nil, errors.New("must not start callback listener") + } + + _, err := NewOAuthProvider(configDir, nil).Login(context.Background(), force) + if err == nil || !strings.Contains(err.Error(), "newer than supported") { + t.Fatalf("OAuthProvider.Login(force=%t) error = %v, want future profiles rejection", force, err) + } + if got := calls.Load(); got != 0 { + t.Fatalf("callback listener calls = %d, want 0", got) + } + }) + } + }) + + t.Run("device flow", func(t *testing.T) { + cleanupKeychain(t) + setLoginPreflightCredentials(t) + configDir := t.TempDir() + writeFutureProfilesForLoginPreflight(t, configDir) + + oldLoginOnce := deviceLoginOnce + t.Cleanup(func() { deviceLoginOnce = oldLoginOnce }) + var calls atomic.Int32 + deviceLoginOnce = func(*DeviceFlowProvider, context.Context, int) (*TokenData, error) { + calls.Add(1) + return &TokenData{AccessToken: "must-not-start"}, nil + } + + _, err := NewDeviceFlowProvider(configDir, nil).Login(context.Background()) + if err == nil || !strings.Contains(err.Error(), "newer than supported") { + t.Fatalf("DeviceFlowProvider.Login() error = %v, want future profiles rejection", err) + } + if got := calls.Load(); got != 0 { + t.Fatalf("device login calls = %d, want 0", got) + } + }) +} + +func TestPersistingLoginFlowsRepairHalfMigratedGlobalBeforeRemoteAndSave(t *testing.T) { + for _, force := range []bool{false, true} { + t.Run(fmt.Sprintf("browser OAuth force=%t", force), func(t *testing.T) { + setLoginPreflightCredentials(t) + fixture := newHalfMigratedLoginFixture(t) + installHalfMigratedBrowserLogin(t, fixture.repairedError, fixture.tokenB) + + oldLoad := oauthLoadToken + t.Cleanup(func() { oauthLoadToken = oldLoad }) + loadCalls := 0 + if !force { + oauthLoadToken = func(string) (*TokenData, error) { + loadCalls++ + return nil, ErrTokenDataNotFound + } + } + + provider := NewOAuthProvider(fixture.configDir, nil) + provider.Output = io.Discard + data, err := provider.Login(context.Background(), force) + if err != nil { + t.Fatalf("OAuthProvider.Login(force=%t) error = %v", force, err) + } + if data != fixture.tokenB { + t.Fatalf("OAuthProvider.Login(force=%t) data = %#v, want token B", force, data) + } + if want := map[bool]int{false: 1, true: 0}[force]; loadCalls != want { + t.Fatalf("OAuthProvider.Login(force=%t) silent load calls = %d, want %d", force, loadCalls, want) + } + fixture.assertNewLoginPersisted(t) + }) + } + + t.Run("external auth-code exchange", func(t *testing.T) { + fixture := newHalfMigratedLoginFixture(t) + oldExchange := oauthExchange + oldSave := oauthSaveToken + t.Cleanup(func() { + oauthExchange = oldExchange + oauthSaveToken = oldSave + }) + remoteObservedRepair := false + oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) { + if err := fixture.repairedError(); err != nil { + return nil, err + } + remoteObservedRepair = true + return fixture.tokenB, nil + } + oauthSaveToken = SaveTokenData + + data, err := NewOAuthProvider(fixture.configDir, nil).ExchangeAuthCode( + context.Background(), + "new-login-code", + fixture.userB, + ) + if err != nil { + t.Fatalf("ExchangeAuthCode() error = %v", err) + } + if data != fixture.tokenB || !remoteObservedRepair { + t.Fatalf("ExchangeAuthCode() data = %#v, repair observed = %t", data, remoteObservedRepair) + } + fixture.assertNewLoginPersisted(t) + }) + + t.Run("device flow", func(t *testing.T) { + setLoginPreflightCredentials(t) + fixture := newHalfMigratedLoginFixture(t) + oldLoginOnce := deviceLoginOnce + oldRequest := deviceRequestCode + oldWait := deviceWaitAuth + oldExchange := deviceExchangeCode + oldCheck := deviceCheckCLIAuth + oldSave := deviceSaveToken + t.Cleanup(func() { + deviceLoginOnce = oldLoginOnce + deviceRequestCode = oldRequest + deviceWaitAuth = oldWait + deviceExchangeCode = oldExchange + deviceCheckCLIAuth = oldCheck + deviceSaveToken = oldSave + }) + deviceLoginOnce = func(p *DeviceFlowProvider, ctx context.Context, attempt int) (*TokenData, error) { + return p.loginOnce(ctx, attempt) + } + deviceRequestCode = func(*DeviceFlowProvider, context.Context) (*DeviceAuthResponse, error) { + return &DeviceAuthResponse{DeviceCode: "device", UserCode: "user", Interval: 1}, nil + } + deviceWaitAuth = func(*DeviceFlowProvider, context.Context, *DeviceAuthResponse) (*DeviceTokenResponse, error) { + return &DeviceTokenResponse{AuthCode: "new-login-code"}, nil + } + remoteObservedRepair := false + deviceExchangeCode = func(*OAuthProvider, context.Context, string) (*TokenData, error) { + if err := fixture.repairedError(); err != nil { + return nil, err + } + remoteObservedRepair = true + return fixture.tokenB, nil + } + deviceCheckCLIAuth = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) { + return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil + } + deviceSaveToken = SaveTokenData + + provider := NewDeviceFlowProvider(fixture.configDir, nil) + provider.Output = io.Discard + provider.NoBrowser = true + data, err := provider.Login(context.Background()) + if err != nil { + t.Fatalf("DeviceFlowProvider.Login() error = %v", err) + } + if data != fixture.tokenB || !remoteObservedRepair { + t.Fatalf("DeviceFlowProvider.Login() data = %#v, repair observed = %t", data, remoteObservedRepair) + } + fixture.assertNewLoginPersisted(t) + }) +} + +func TestPrepareLoginPersistenceRepairsOnlySafeGlobalOwner(t *testing.T) { + t.Run("matching global overwrites damaged canonical slots", func(t *testing.T) { + fixture := newHalfMigratedLoginFixture(t) + corruptErr := errors.New("ciphertext key mismatch") + fixture.store.setReadError(TokenAccountForCorpID(fixture.corpA), corruptErr) + fixture.store.setReadError(TokenAccountForIdentity(fixture.corpA, fixture.userA), corruptErr) + + if err := prepareLoginPersistence(fixture.configDir); err != nil { + t.Fatalf("prepareLoginPersistence() error = %v", err) + } + if err := fixture.repairedError(); err != nil { + t.Fatal(err) + } + }) + + t.Run("partial global repair retries identity from repaired organization", func(t *testing.T) { + fixture := newHalfMigratedLoginFixture(t) + identityAccount := TokenAccountForIdentity(fixture.corpA, fixture.userA) + writeErr := errors.New("identity storage is temporarily unavailable") + fixture.store.setWriteError(identityAccount, writeErr) + + err := prepareLoginPersistence(fixture.configDir) + if !errors.Is(err, writeErr) { + t.Fatalf("first prepareLoginPersistence() error = %v, want identity write failure", err) + } + org, orgErr := fixture.store.token(TokenAccountForCorpID(fixture.corpA)) + if orgErr != nil || org.AccessToken != fixture.tokenA.AccessToken || org.UserID != "" { + t.Fatalf("organization repair after partial failure = %#v, %v", org, orgErr) + } + + fixture.store.setWriteError(identityAccount, nil) + if err := prepareLoginPersistence(fixture.configDir); err != nil { + t.Fatalf("second prepareLoginPersistence() error = %v", err) + } + if err := fixture.repairedError(); err != nil { + t.Fatal(err) + } + }) + + t.Run("different global user cannot be attached to sole profile", func(t *testing.T) { + fixture := newHalfMigratedLoginFixture(t) + mismatched := *fixture.tokenA + mismatched.UserID = "different-synthetic-user" + fixture.store.putToken(t, keychain.AccountToken, &mismatched) + + err := prepareLoginPersistence(fixture.configDir) + if err == nil || !strings.Contains(err.Error(), "does not safely match") { + t.Fatalf("prepareLoginPersistence() error = %v, want owner mismatch", err) + } + if _, loadErr := fixture.store.token(TokenAccountForCorpID(fixture.corpA)); !errors.Is(loadErr, ErrTokenDataNotFound) { + t.Fatalf("organization slot after rejected repair error = %v, want missing", loadErr) + } + if _, loadErr := fixture.store.token(TokenAccountForIdentity(fixture.corpA, fixture.userA)); !errors.Is(loadErr, ErrTokenDataNotFound) { + t.Fatalf("identity slot after rejected repair error = %v, want missing", loadErr) + } + }) + + t.Run("ambiguous same-corp profiles fail before global overwrite", func(t *testing.T) { + fixture := newHalfMigratedLoginFixture(t) + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + cfg.Profiles = append(cfg.Profiles, Profile{ + Name: "Second Synthetic Account", + CorpID: fixture.corpA, + CorpName: fixture.tokenA.CorpName, + UserID: fixture.userA + "-second", + UserName: "Second Synthetic Account", + Status: ProfileStatusActive, + }) + if err := SaveProfiles(fixture.configDir, cfg); err != nil { + t.Fatalf("SaveProfiles(ambiguous) error = %v", err) + } + + err = prepareLoginPersistence(fixture.configDir) + if err == nil || !strings.Contains(err.Error(), "one of 2 accounts") { + t.Fatalf("prepareLoginPersistence() error = %v, want ambiguous-owner protection", err) + } + }) + + t.Run("damaged unrelated organization is not inspected", func(t *testing.T) { + fixture := newHalfMigratedLoginFixture(t) + existing := *fixture.tokenA + existing.UserID = fixture.userA + existing.UserName = "Half Migrated Account" + fixture.store.putToken(t, TokenAccountForIdentity(fixture.corpA, fixture.userA), &existing) + + cfg, err := LoadProfiles(fixture.configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + unrelatedCorp := fixture.corpA + "-unrelated" + unrelatedUser := fixture.userA + "-unrelated" + cfg.Profiles = append(cfg.Profiles, Profile{ + Name: "Unreadable Unrelated Account", + CorpID: unrelatedCorp, + UserID: unrelatedUser, + Status: ProfileStatusActive, + }) + if err := SaveProfiles(fixture.configDir, cfg); err != nil { + t.Fatalf("SaveProfiles(unrelated) error = %v", err) + } + unrelatedErr := errors.New("unrelated ciphertext is unreadable") + unrelatedOrg := TokenAccountForCorpID(unrelatedCorp) + unrelatedIdentity := TokenAccountForIdentity(unrelatedCorp, unrelatedUser) + fixture.store.setReadError(unrelatedOrg, unrelatedErr) + fixture.store.setReadError(unrelatedIdentity, unrelatedErr) + + if err := prepareLoginPersistence(fixture.configDir); err != nil { + t.Fatalf("prepareLoginPersistence() error = %v", err) + } + if got := fixture.store.readCount(unrelatedOrg); got != 0 { + t.Fatalf("unrelated organization reads = %d, want 0", got) + } + if got := fixture.store.readCount(unrelatedIdentity); got != 0 { + t.Fatalf("unrelated identity reads = %d, want 0", got) + } + }) + + t.Run("version one remains on existing migration path", func(t *testing.T) { + store := installLoginPersistenceMemoryKeychain(t) + configDir := t.TempDir() + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: 1, + Profiles: []Profile{{ + Name: "Version One Fixture", + CorpID: "corp-version-one-fixture", + UserID: "user-version-one-fixture", + }}, + }); err != nil { + t.Fatalf("SaveProfiles(v1) error = %v", err) + } + store.setReadError(keychain.AccountToken, errors.New("v1 global is intentionally untouched")) + + if err := prepareLoginPersistence(configDir); err != nil { + t.Fatalf("prepareLoginPersistence(v1) error = %v", err) + } + if got := store.readCount(keychain.AccountToken); got != 0 { + t.Fatalf("v1 global reads = %d, want 0", got) + } + }) +} + +func TestPrepareLoginPersistenceUnreadableGlobalFailsClosedBeforeRemote(t *testing.T) { + fixture := newHalfMigratedLoginFixture(t) + globalErr := errors.New("global ciphertext is unreadable") + fixture.store.setReadError(keychain.AccountToken, globalErr) + + err := prepareLoginPersistence(fixture.configDir) + if !errors.Is(err, globalErr) || !strings.Contains(err.Error(), "refusing to overwrite") { + t.Fatalf("prepareLoginPersistence() error = %v, want unreadable-global protection", err) + } +} + +func TestPrepareLoginPersistenceV3UnresolvedProfileRepair(t *testing.T) { + for _, tc := range []struct { + name string + globalUID string + wantSuccess bool + }{ + {name: "blank global identity repairs organization", wantSuccess: true}, + {name: "nonblank global identity is rejected", globalUID: "different-synthetic-user"}, + } { + t.Run(tc.name, func(t *testing.T) { + store := installLoginPersistenceMemoryKeychain(t) + configDir := t.TempDir() + corpID := "corp-v3-unresolved-" + strings.ReplaceAll(t.Name(), "/", "-") + reserved := unresolvedProfileSelector(corpID) + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesUnresolvedSelectorVersion, + CurrentProfile: reserved, + Profiles: []Profile{{ + Name: "Synthetic External Account", + CorpID: corpID, + CorpName: "Synthetic V3 Organization", + Status: ProfileStatusActive, + }}, + }); err != nil { + t.Fatalf("SaveProfiles(v3 unresolved) error = %v", err) + } + global := testToken("access-v3-unresolved", corpID, "Synthetic V3 Organization") + global.UserID = tc.globalUID + global.UserName = "" + store.putToken(t, keychain.AccountToken, global) + + err := prepareLoginPersistence(configDir) + if tc.wantSuccess { + if err != nil { + t.Fatalf("prepareLoginPersistence() error = %v", err) + } + org, loadErr := store.token(TokenAccountForCorpID(corpID)) + if loadErr != nil || org.AccessToken != global.AccessToken || org.UserID != "" { + t.Fatalf("repaired organization = %#v, %v", org, loadErr) + } + cfg, loadErr := LoadProfiles(configDir) + if loadErr != nil || cfg.Version != profilesUnresolvedSelectorVersion || cfg.CurrentProfile != reserved { + t.Fatalf("profiles after repair = %#v, %v", cfg, loadErr) + } + return + } + if err == nil || !strings.Contains(err.Error(), "does not safely match") { + t.Fatalf("prepareLoginPersistence() error = %v, want unresolved owner rejection", err) + } + if _, loadErr := store.token(TokenAccountForCorpID(corpID)); !errors.Is(loadErr, ErrTokenDataNotFound) { + t.Fatalf("organization slot after rejected repair error = %v, want missing", loadErr) + } + }) + } +} + +func TestPrepareLoginPersistenceMultiAccountProtectionIsTargeted(t *testing.T) { + for _, tc := range []struct { + name string + secondPresent bool + wantSuccess bool + }{ + {name: "one canonical missing fails closed"}, + {name: "all canonicals present allows overwrite", secondPresent: true, wantSuccess: true}, + } { + t.Run(tc.name, func(t *testing.T) { + store := installLoginPersistenceMemoryKeychain(t) + configDir := t.TempDir() + corpID := "corp-multi-" + strings.ReplaceAll(t.Name(), "/", "-") + firstUser := "first-" + strings.ReplaceAll(t.Name(), "/", "-") + secondUser := "second-" + strings.ReplaceAll(t.Name(), "/", "-") + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + CurrentProfile: profileSelector(corpID, firstUser), + OrgCurrentProfiles: map[string]string{ + corpID: profileSelector(corpID, firstUser), + }, + Profiles: []Profile{ + {Name: "First Synthetic Account", CorpID: corpID, UserID: firstUser}, + {Name: "Second Synthetic Account", CorpID: corpID, UserID: secondUser}, + }, + }); err != nil { + t.Fatalf("SaveProfiles(multi) error = %v", err) + } + global := testToken("access-multi-global", corpID, "Synthetic Multi Organization") + global.UserID = firstUser + store.putToken(t, keychain.AccountToken, global) + first := testToken("access-multi-first", corpID, "Synthetic Multi Organization") + first.UserID = firstUser + store.putToken(t, TokenAccountForIdentity(corpID, firstUser), first) + if tc.secondPresent { + second := testToken("access-multi-second", corpID, "Synthetic Multi Organization") + second.UserID = secondUser + store.putToken(t, TokenAccountForIdentity(corpID, secondUser), second) + } + + err := prepareLoginPersistence(configDir) + if tc.wantSuccess { + if err != nil { + t.Fatalf("prepareLoginPersistence() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), "one of 2 accounts") { + t.Fatalf("prepareLoginPersistence() error = %v, want multi-account protection", err) + } + }) + } +} + +func TestPrepareLoginPersistenceRequiresCredentialMaterialButNotValidity(t *testing.T) { + t.Run("empty token cannot become canonical", func(t *testing.T) { + fixture := newHalfMigratedLoginFixture(t) + empty := *fixture.tokenA + empty.AccessToken = "" + empty.RefreshToken = "" + empty.PersistentCode = "" + fixture.store.putToken(t, keychain.AccountToken, &empty) + + err := prepareLoginPersistence(fixture.configDir) + if err == nil || !strings.Contains(err.Error(), "no recoverable credential material") { + t.Fatalf("prepareLoginPersistence() error = %v, want empty-token rejection", err) + } + }) + + t.Run("expired credential material is still recoverable", func(t *testing.T) { + fixture := newHalfMigratedLoginFixture(t) + expired := *fixture.tokenA + expired.ExpiresAt = time.Now().Add(-48 * time.Hour) + expired.RefreshExpAt = time.Now().Add(-24 * time.Hour) + fixture.store.putToken(t, keychain.AccountToken, &expired) + + if err := prepareLoginPersistence(fixture.configDir); err != nil { + t.Fatalf("prepareLoginPersistence() error = %v", err) + } + if err := fixture.repairedError(); err != nil { + t.Fatal(err) + } + }) +} + +func TestPrepareLoginPersistenceCustomSaveHookDoesNotTouchBuiltInState(t *testing.T) { + oldHooks := edition.Get() + oldAcquire := profilesAcquireDualLock + oldLoad := profilesLoad + oldGet := authKeychainGet + t.Cleanup(func() { + edition.Override(oldHooks) + profilesAcquireDualLock = oldAcquire + profilesLoad = oldLoad + authKeychainGet = oldGet + }) + edition.Override(&edition.Hooks{SaveToken: func(string, []byte) error { return nil }}) + + var lockCalls atomic.Int32 + var profileCalls atomic.Int32 + var keychainCalls atomic.Int32 + profilesAcquireDualLock = func(context.Context, string) (*DualLock, error) { + lockCalls.Add(1) + return nil, errors.New("built-in lock must not be acquired") + } + profilesLoad = func(string) (*ProfilesConfig, error) { + profileCalls.Add(1) + return nil, errors.New("built-in profiles must not be loaded") + } + authKeychainGet = func(string, string) (string, error) { + keychainCalls.Add(1) + return "", errors.New("built-in keychain must not be read") + } + + if err := prepareLoginPersistence(t.TempDir()); err != nil { + t.Fatalf("prepareLoginPersistence(custom hook) error = %v", err) + } + if lockCalls.Load() != 0 || profileCalls.Load() != 0 || keychainCalls.Load() != 0 { + t.Fatalf( + "built-in calls with custom hook = lock:%d profiles:%d keychain:%d, want all zero", + lockCalls.Load(), + profileCalls.Load(), + keychainCalls.Load(), + ) + } +} + +func TestSaveLoginTokenDataRepairsHalfMigratedStateBeforeManualGlobalOverwrite(t *testing.T) { + fixture := newHalfMigratedLoginFixture(t) + manual := &TokenData{ + AccessToken: "manual-login-token", + ExpiresAt: time.Now().Add(time.Hour), + } + + if err := SaveLoginTokenData(fixture.configDir, manual); err != nil { + t.Fatalf("SaveLoginTokenData(manual) error = %v", err) + } + if err := fixture.repairedError(); err != nil { + t.Fatalf("legacy profile was not repaired before manual overwrite: %v", err) + } + global, err := fixture.store.token(keychain.AccountToken) + if err != nil { + t.Fatalf("load manual global token: %v", err) + } + if global.AccessToken != manual.AccessToken || global.CorpID != "" { + t.Fatalf("global token after manual login = %#v", global) + } +} + +func TestOAuthPersistLoginTokenMarksFreshBeforeUIDLessIsolationCheck(t *testing.T) { + oldSave := oauthSaveToken + oauthSaveToken = SaveTokenData + t.Cleanup(func() { oauthSaveToken = oldSave }) + + for _, selectorKind := range []string{"implicit", "explicit-exact"} { + t.Run(selectorKind, func(t *testing.T) { + fixture := seedLegacyBlankAndExactIdentitySlots(t) + previousRuntimeProfile := RuntimeProfile() + if selectorKind == "explicit-exact" { + SetRuntimeProfile(profileSelector(fixture.corpID, fixture.alpha.UserID)) + } else { + SetRuntimeProfile("") + } + t.Cleanup(func() { SetRuntimeProfile(previousRuntimeProfile) }) + + fresh := testToken("oauth-fresh-unknown-"+selectorKind, fixture.corpID, fixture.blank.CorpName) + fresh.UserID = "" + fresh.UserName = "" + provider := NewOAuthProvider(fixture.configDir, nil) + err := provider.persistLoginToken(context.Background(), fresh) + if err == nil || !strings.Contains(err.Error(), "UID-less token") { + t.Fatalf("persistLoginToken() error = %v, want unresolved-sibling protection", err) + } + if !fresh.FreshAuthorization { + t.Fatal("persistLoginToken did not mark exchanged token as a fresh authorization") + } + assertOrganizationTokenAccessForTest(t, fixture.corpID, fixture.blank.AccessToken, "") + assertIdentityTokenAccessForTest(t, fixture.corpID, fixture.alpha.UserID, fixture.alpha.AccessToken) + }) + } +} + +func TestOAuthLoginValidExactTokenDoesNotInspectUnreadableGlobal(t *testing.T) { + fixture := newHalfMigratedLoginFixture(t) + fixture.store.setReadError(keychain.AccountToken, errors.New("unreadable compatibility mirror")) + validExact := *fixture.tokenA + validExact.UserID = fixture.userA + validExact.UserName = "Half Migrated Account" + fixture.store.putToken(t, TokenAccountForIdentity(fixture.corpA, fixture.userA), &validExact) + + oldLoad := oauthLoadToken + oldListen := oauthListen + t.Cleanup(func() { + oauthLoadToken = oldLoad + oauthListen = oldListen + }) + oauthLoadToken = LoadTokenData + listenCalls := 0 + oauthListen = func(string, string) (net.Listener, error) { + listenCalls++ + return nil, errors.New("must not start browser authorization") + } + + data, err := NewOAuthProvider(fixture.configDir, nil).Login(context.Background(), false) + if err != nil { + t.Fatalf("OAuthProvider.Login() error = %v", err) + } + if data.AccessToken != validExact.AccessToken || + data.CorpID != fixture.corpA || + data.UserID != fixture.userA || + listenCalls != 0 { + t.Fatalf("OAuthProvider.Login() data=%#v listen=%d", data, listenCalls) + } + if got := fixture.store.readCount(keychain.AccountToken); got != 0 { + t.Fatalf("global mirror reads = %d, want 0 on valid-token fast path", got) + } +} + +func TestExchangeAuthCodeChecksResolvedTargetBeforeSave(t *testing.T) { + for _, tc := range []struct { + name string + explicitID string + wantEnrich int32 + }{ + {name: "identity enricher supplies user", wantEnrich: 1}, + {name: "external host supplies user", explicitID: "user_target", wantEnrich: 0}, + } { + t.Run(tc.name, func(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + if err := SaveProfiles(configDir, &ProfilesConfig{Version: profilesVersion}); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + targetErr := errors.New("target identity ciphertext is unreadable") + oldGet := authKeychainGet + oldExchange := oauthExchange + oldSave := oauthSaveToken + t.Cleanup(func() { + authKeychainGet = oldGet + oauthExchange = oldExchange + oauthSaveToken = oldSave + }) + + authKeychainGet = func(_ string, account string) (string, error) { + if account == TokenAccountForIdentity("corp_target", "user_target") { + return "", targetErr + } + return "", nil + } + var exchangeCalls atomic.Int32 + oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) { + exchangeCalls.Add(1) + return &TokenData{ + AccessToken: "target-access", + RefreshToken: "target-refresh", + CorpID: "corp_target", + }, nil + } + var saveCalls atomic.Int32 + oauthSaveToken = func(string, *TokenData) error { + saveCalls.Add(1) + return nil + } + var enrichCalls atomic.Int32 + provider := NewOAuthProvider(configDir, nil) + provider.IdentityEnricher = func(_ context.Context, data *TokenData) error { + enrichCalls.Add(1) + data.UserID = "user_target" + return nil + } + + _, err := provider.ExchangeAuthCode(context.Background(), "target-code", tc.explicitID) + if !errors.Is(err, targetErr) { + t.Fatalf("ExchangeAuthCode() error = %v, want target ciphertext error", err) + } + if got := exchangeCalls.Load(); got != 1 { + t.Fatalf("remote exchange calls = %d, want 1", got) + } + if got := enrichCalls.Load(); got != tc.wantEnrich { + t.Fatalf("identity enrichment calls = %d, want %d", got, tc.wantEnrich) + } + if got := saveCalls.Load(); got != 0 { + t.Fatalf("SaveTokenData calls = %d, want 0", got) + } + }) + } +} + +func TestDeviceFlowChecksResolvedTargetBeforeSave(t *testing.T) { + cleanupKeychain(t) + setLoginPreflightCredentials(t) + configDir := t.TempDir() + if err := SaveProfiles(configDir, &ProfilesConfig{Version: profilesVersion}); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + targetErr := errors.New("target identity ciphertext is unreadable") + oldGet := authKeychainGet + oldLoginOnce := deviceLoginOnce + oldRequest := deviceRequestCode + oldWait := deviceWaitAuth + oldExchange := deviceExchangeCode + oldCheck := deviceCheckCLIAuth + oldSave := deviceSaveToken + t.Cleanup(func() { + authKeychainGet = oldGet + deviceLoginOnce = oldLoginOnce + deviceRequestCode = oldRequest + deviceWaitAuth = oldWait + deviceExchangeCode = oldExchange + deviceCheckCLIAuth = oldCheck + deviceSaveToken = oldSave + }) + + authKeychainGet = func(_ string, account string) (string, error) { + if account == TokenAccountForIdentity("corp_target", "user_target") { + return "", targetErr + } + return "", nil + } + deviceLoginOnce = func(p *DeviceFlowProvider, ctx context.Context, attempt int) (*TokenData, error) { + return p.loginOnce(ctx, attempt) + } + deviceRequestCode = func(*DeviceFlowProvider, context.Context) (*DeviceAuthResponse, error) { + return &DeviceAuthResponse{DeviceCode: "device", UserCode: "user", Interval: 1}, nil + } + deviceWaitAuth = func(*DeviceFlowProvider, context.Context, *DeviceAuthResponse) (*DeviceTokenResponse, error) { + return &DeviceTokenResponse{AuthCode: "target-code"}, nil + } + var exchangeCalls atomic.Int32 + deviceExchangeCode = func(*OAuthProvider, context.Context, string) (*TokenData, error) { + exchangeCalls.Add(1) + return &TokenData{ + AccessToken: "target-access", + RefreshToken: "target-refresh", + CorpID: "corp_target", + }, nil + } + deviceCheckCLIAuth = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) { + return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil + } + var saveCalls atomic.Int32 + deviceSaveToken = func(string, *TokenData) error { + saveCalls.Add(1) + return nil + } + var enrichCalls atomic.Int32 + provider := NewDeviceFlowProvider(configDir, nil) + provider.Output = io.Discard + provider.NoBrowser = true + provider.IdentityEnricher = func(_ context.Context, data *TokenData) error { + enrichCalls.Add(1) + data.UserID = "user_target" + return nil + } + + _, err := provider.Login(context.Background()) + if !errors.Is(err, targetErr) { + t.Fatalf("DeviceFlowProvider.Login() error = %v, want target ciphertext error", err) + } + if got := exchangeCalls.Load(); got != 1 { + t.Fatalf("remote exchange calls = %d, want 1", got) + } + if got := enrichCalls.Load(); got != 1 { + t.Fatalf("identity enrichment calls = %d, want 1", got) + } + if got := saveCalls.Load(); got != 0 { + t.Fatalf("SaveTokenData calls = %d, want 0", got) + } +} + +func TestLoginTargetPreflightIgnoresUnrelatedProfileSlots(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + cfg := &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{{ + Name: "unrelated", + CorpID: "corp_unrelated", + UserID: "user_unrelated", + }}, + } + if err := SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + unrelatedErr := errors.New("unrelated profile ciphertext is unreadable") + oldGet := authKeychainGet + oldValidate := authValidateEntries + oldExchange := oauthExchange + oldSave := oauthSaveToken + t.Cleanup(func() { + authKeychainGet = oldGet + authValidateEntries = oldValidate + oauthExchange = oldExchange + oauthSaveToken = oldSave + }) + authKeychainGet = func(_ string, account string) (string, error) { + if account == TokenAccountForCorpID("corp_unrelated") || + account == TokenAccountForIdentity("corp_unrelated", "user_unrelated") { + return "", unrelatedErr + } + return "", nil + } + authValidateEntries = func(string) error { + return errors.New("unrelated orphan ciphertext is unreadable") + } + var exchangeCalls atomic.Int32 + oauthExchange = func(*OAuthProvider, context.Context, string) (*TokenData, error) { + exchangeCalls.Add(1) + return &TokenData{AccessToken: "target-access", CorpID: "corp_target"}, nil + } + var saveCalls atomic.Int32 + oauthSaveToken = func(string, *TokenData) error { + saveCalls.Add(1) + return nil + } + + data, err := NewOAuthProvider(configDir, nil).ExchangeAuthCode(context.Background(), "target-code", "user_target") + if err != nil { + t.Fatalf("ExchangeAuthCode() error = %v", err) + } + if data == nil || data.UserID != "user_target" { + t.Fatalf("ExchangeAuthCode() data = %#v", data) + } + if got := exchangeCalls.Load(); got != 1 { + t.Fatalf("remote exchange calls = %d, want 1", got) + } + if got := saveCalls.Load(); got != 1 { + t.Fatalf("SaveTokenData calls = %d, want 1", got) + } +} + +func TestDeviceLoginIgnoresUnreadableUnrelatedProfile(t *testing.T) { + cleanupKeychain(t) + setLoginPreflightCredentials(t) + configDir := t.TempDir() + if err := SaveProfiles(configDir, &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{{ + Name: "unrelated", + CorpID: "corp_unrelated", + UserID: "user_unrelated", + }}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + unrelatedErr := errors.New("unrelated profile ciphertext is unreadable") + oldGet := authKeychainGet + oldValidate := authValidateEntries + oldLoginOnce := deviceLoginOnce + oldRequest := deviceRequestCode + oldWait := deviceWaitAuth + oldExchange := deviceExchangeCode + oldCheck := deviceCheckCLIAuth + oldSave := deviceSaveToken + t.Cleanup(func() { + authKeychainGet = oldGet + authValidateEntries = oldValidate + deviceLoginOnce = oldLoginOnce + deviceRequestCode = oldRequest + deviceWaitAuth = oldWait + deviceExchangeCode = oldExchange + deviceCheckCLIAuth = oldCheck + deviceSaveToken = oldSave + }) + + var unrelatedReads atomic.Int32 + var inventoryCalls atomic.Int32 + authKeychainGet = func(_ string, account string) (string, error) { + if account == TokenAccountForCorpID("corp_unrelated") || + account == TokenAccountForIdentity("corp_unrelated", "user_unrelated") { + unrelatedReads.Add(1) + return "", unrelatedErr + } + return "", nil + } + authValidateEntries = func(string) error { + inventoryCalls.Add(1) + return errors.New("unrelated orphan ciphertext is unreadable") + } + deviceLoginOnce = func(p *DeviceFlowProvider, ctx context.Context, attempt int) (*TokenData, error) { + return p.loginOnce(ctx, attempt) + } + deviceRequestCode = func(*DeviceFlowProvider, context.Context) (*DeviceAuthResponse, error) { + return &DeviceAuthResponse{DeviceCode: "device", UserCode: "user", Interval: 1}, nil + } + deviceWaitAuth = func(*DeviceFlowProvider, context.Context, *DeviceAuthResponse) (*DeviceTokenResponse, error) { + return &DeviceTokenResponse{AuthCode: "target-code"}, nil + } + var exchangeCalls atomic.Int32 + deviceExchangeCode = func(*OAuthProvider, context.Context, string) (*TokenData, error) { + exchangeCalls.Add(1) + return &TokenData{ + AccessToken: "target-access", + RefreshToken: "target-refresh", + CorpID: "corp_target", + UserID: "user_target", + }, nil + } + deviceCheckCLIAuth = func(*OAuthProvider, context.Context, string) (*CLIAuthStatus, error) { + return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil + } + var saveCalls atomic.Int32 + deviceSaveToken = func(_ string, data *TokenData) error { + if !data.FreshAuthorization { + t.Error("device flow did not mark exchanged token as a fresh authorization") + } + saveCalls.Add(1) + return nil + } + provider := NewDeviceFlowProvider(configDir, nil) + provider.Output = io.Discard + provider.NoBrowser = true + + data, err := provider.Login(context.Background()) + if err != nil { + t.Fatalf("DeviceFlowProvider.Login() error = %v", err) + } + if data == nil || data.UserID != "user_target" { + t.Fatalf("DeviceFlowProvider.Login() data = %#v", data) + } + if got := exchangeCalls.Load(); got != 1 { + t.Fatalf("remote exchange calls = %d, want 1", got) + } + if got := saveCalls.Load(); got != 1 { + t.Fatalf("SaveTokenData calls = %d, want 1", got) + } + if got := unrelatedReads.Load(); got != 0 { + t.Fatalf("unrelated profile token reads = %d, want 0", got) + } + if got := inventoryCalls.Load(); got != 0 { + t.Fatalf("full inventory validation calls = %d, want 0", got) + } +} diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index 08ba949f..cc30a4a3 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -27,7 +27,6 @@ import ( "strings" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) @@ -39,10 +38,6 @@ var ( ) func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) { - if err := preflightTokenPersistence(p.configDir); err != nil { - return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) - } - // Use MCP mode if clientID is from MCP server if IsClientIDFromMCP() { return p.exchangeCodeViaMCP(ctx, code) @@ -79,13 +74,21 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa // the currently configured client credentials. This is a convenience wrapper // around OAuthProvider.exchangeCode for callers outside the auth package. func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenData, error) { + if err := prepareLoginPersistence(configDir); err != nil { + return nil, fmt.Errorf("local login state cannot be safely updated before token exchange: %w", err) + } p := &OAuthProvider{ configDir: configDir, clientID: ClientID(), Output: io.Discard, httpClient: oauthHTTPClient, } - return p.exchangeCode(ctx, code) + data, err := p.exchangeCode(ctx, code) + if err != nil { + return nil, err + } + data.FreshAuthorization = true + return data, nil } // exchangeCodeViaMCP exchanges auth code for token via MCP proxy. diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 90912bfd..2396f650 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -118,10 +118,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro if !force { data, err := oauthLoadToken(p.configDir) if err != nil && !errors.Is(err, ErrTokenDataNotFound) && !os.IsNotExist(err) { - if preflightErr := preflightTokenPersistence(p.configDir); preflightErr != nil { - return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), preflightErr) + // A damaged selected slot must not make browser reauthorization + // impossible. The target identity is unknown until token exchange, so + // continue into the full flow and let the target-only preflight reject + // an unsafe overwrite after identity enrichment. + if p.logger != nil { + p.logger.Warn(i18n.T("读取现有登录态失败,将尝试扫码登录"), "error", err) } - return nil, fmt.Errorf("load existing access token: %w", err) } if err == nil { // Case 1: access_token still valid — no action needed. @@ -151,7 +154,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro } } } - if err := preflightTokenPersistence(p.configDir); err != nil { + if err := prepareLoginPersistence(p.configDir); err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) } @@ -687,13 +690,9 @@ func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error } var exchangeErr *MCPTokenExchangeError if errors.As(rErr, &exchangeErr) && exchangeErr.requiresReauthorization() { - command := "dws auth login" - if profileSelector != "" { - command += " --profile " + strconv.Quote(profileSelector) - } return nil, fmt.Errorf( - "旧版登录态已无法由当前认证服务刷新;本地 profile 已保留,请运行 %s 完成一次重新授权: %w", - command, + "%s: %w", + legacyRefreshReauthorizationGuidance(profileSelector), rErr, ) } @@ -705,6 +704,19 @@ func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound) } +func legacyRefreshReauthorizationGuidance(profileSelector string) string { + guidance := "旧版登录态已无法由当前认证服务刷新;本地 profile 已保留,请重新运行 dws auth login 完成一次重新授权" + profileSelector = strings.TrimSpace(profileSelector) + if profileSelector == "" { + return guidance + } + return fmt.Sprintf( + "%s;为保留原身份,请把 --profile 参数设置为下方 profile 标识(标识仅作数据展示,不是可执行命令):\nprofile: %s", + guidance, + strconv.Quote(profileSelector), + ) +} + // GetAccessToken returns a valid access token, auto-refreshing if needed. // Uses a file lock with double-check pattern to prevent concurrent refresh // from multiple CLI processes. @@ -777,6 +789,9 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) { // ExchangeAuthCode takes an AuthCode and an optional UserID provided by an // external host, exchanges it for tokens, and persists them. func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) { + if err := prepareLoginPersistence(p.configDir); err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) + } tokenData, err := oauthExchange(p, ctx, authCode) if err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err) @@ -816,6 +831,9 @@ func (p *OAuthProvider) persistLoginToken(ctx context.Context, tokenData *TokenD "user_id", strings.TrimSpace(tokenData.UserID), "user_name", strings.TrimSpace(tokenData.UserName), ) + if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil { + return fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) + } if err := oauthSaveToken(p.configDir, tokenData); err != nil { return err } @@ -826,6 +844,7 @@ func (p *OAuthProvider) prepareLoginToken(ctx context.Context, tokenData *TokenD if tokenData == nil { return fmt.Errorf("token data is empty") } + tokenData.FreshAuthorization = true if p != nil && p.IdentityEnricher != nil { if err := p.IdentityEnricher(ctx, tokenData); err != nil { return fmt.Errorf("resolve login identity: %w", err) @@ -847,6 +866,9 @@ func (p *OAuthProvider) persistKnownLoginToken(tokenData *TokenData) error { if strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" { return fmt.Errorf("resolve login identity: userId is required for corpId %q", tokenData.CorpID) } + if err := preflightTokenWritePersistence(p.configDir, tokenData); err != nil { + return fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) + } return oauthSaveToken(p.configDir, tokenData) } diff --git a/internal/auth/oauth_provider_coverage_test.go b/internal/auth/oauth_provider_coverage_test.go index d1de7ea1..8448affc 100644 --- a/internal/auth/oauth_provider_coverage_test.go +++ b/internal/auth/oauth_provider_coverage_test.go @@ -303,6 +303,72 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) { } } +func TestOAuthForcedLoginIgnoresUnreadableUnrelatedProfile(t *testing.T) { + f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { + return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}} + }) + if err := SaveProfiles(f.configDir, &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{{ + Name: "unrelated", + CorpID: "corp-unrelated", + UserID: "user-unrelated", + }}, + }); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + oldGet := authKeychainGet + oldValidate := authValidateEntries + t.Cleanup(func() { + authKeychainGet = oldGet + authValidateEntries = oldValidate + }) + var unrelatedReads atomic.Int32 + var inventoryCalls atomic.Int32 + authKeychainGet = func(service, account string) (string, error) { + if account == TokenAccountForCorpID("corp-unrelated") || + account == TokenAccountForIdentity("corp-unrelated", "user-unrelated") { + unrelatedReads.Add(1) + return "", errors.New("unrelated profile ciphertext is unreadable") + } + return oldGet(service, account) + } + authValidateEntries = func(string) error { + inventoryCalls.Add(1) + return errors.New("unrelated orphan ciphertext is unreadable") + } + + loginDone := startOAuthLogin(t, context.Background(), f) + callbackDone := make(chan oauthHTTPResult, 1) + go func() { + callbackDone <- getHTTPBody(f.callbackBase + CallbackPath + "?code=unrelated-safe") + }() + waitOAuthSignal(t, f.exchangeEntered, loginDone, "token exchange") + closeOAuthRelease(f.exchangeRelease) + waitOAuthSignal(t, f.statusEntered, loginDone, "CLI auth status check") + closeOAuthRelease(f.statusRelease) + + select { + case callback := <-callbackDone: + if callback.err != nil || !strings.Contains(callback.body, " profilesVersion { + if cfg.Version > profilesMaxVersion { return nil } if len(cfg.Profiles) == 0 { // Version 2 with no profiles is an intentional logged-out tombstone. // Never resurrect a stale legacy mirror after logout/reset. if cfg.Version >= profilesVersion { + if normalizeProfilesVersionForSelectors(cfg) { + return profilesSave(configDir, cfg) + } return nil } if !profilesTokenExists() { @@ -417,6 +423,9 @@ func ensureProfilesMigrationLocked(configDir string) error { cfg.Version = profilesVersion changed = true } + if normalizeProfilesVersionForSelectors(cfg) { + changed = true + } if changed { return profilesSave(configDir, cfg) } @@ -479,7 +488,9 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa return err } normalizeProfilesConfig(cfg) - cfg.Version = profilesVersion + if cfg.Version < profilesVersion { + cfg.Version = profilesVersion + } now := time.Now().Format(time.RFC3339) userID := strings.TrimSpace(data.UserID) var previousCurrent *Profile @@ -835,6 +846,11 @@ func setCurrentProfileLocked(configDir, selector string) (*Profile, error) { } originalCfg := cloneProfilesConfig(cfg) syncOrganization := shouldSyncOrganizationMirror(cfg, *p) + if !syncOrganization { + if err := validateIdentityOnlyProfileToken(*p); err != nil { + return nil, err + } + } mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization) if err != nil { return nil, err @@ -902,6 +918,11 @@ func usePreviousProfileLocked(configDir string) (*Profile, error) { } originalCfg := cloneProfilesConfig(cfg) syncOrganization := shouldSyncOrganizationMirror(cfg, *p) + if !syncOrganization { + if err := validateIdentityOnlyProfileToken(*p); err != nil { + return nil, err + } + } mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization) if err != nil { return nil, err @@ -1053,6 +1074,19 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) { } } } + // Removing the exact identity that forced a blank sibling to use the v3 + // reserved selector can make that blank profile the sole account in its + // organization. Re-canonicalize every surviving pointer against the final + // profile set before SaveProfiles derives the schema version, so the pointer + // collapses back to the v2 corpId grammar instead of pinning the file at v3. + for _, pointer := range pointers { + if canonical := canonicalStoredSelector(cfg, *pointer); canonical != "" { + *pointer = canonical + } + } + if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile { + cfg.PreviousProfile = "" + } if len(cfg.Profiles) == 0 { cfg.PrimaryProfile = "" cfg.CurrentProfile = "" @@ -1111,16 +1145,56 @@ func markProfileStatusLocked(configDir, selector, status string) error { } func ensureProfilesWritable(cfg *ProfilesConfig) error { - if cfg != nil && cfg.Version > profilesVersion { + if cfg != nil && cfg.Version > profilesMaxVersion { return fmt.Errorf( "profiles.json version %d is newer than supported version %d; upgrade dws before changing profiles", cfg.Version, - profilesVersion, + profilesMaxVersion, ) } return nil } +// normalizeProfilesVersionForSelectors derives the persisted schema version +// from the final normalized selector grammar. Keep v3 only while a legal +// reserved unresolved-identity selector remains on disk; once completion, +// deletion, or canonicalization removes that grammar, the file is again safe +// for v2 clients and should downgrade to v2. +func normalizeProfilesVersionForSelectors(cfg *ProfilesConfig) bool { + if cfg == nil || cfg.Version > profilesMaxVersion { + return false + } + + target := cfg.Version + if profilesConfigContainsUnresolvedSelector(cfg) { + target = profilesUnresolvedSelectorVersion + } else if cfg.Version >= profilesVersion { + target = profilesVersion + } + if target == cfg.Version { + return false + } + cfg.Version = target + return true +} + +func profilesConfigContainsUnresolvedSelector(cfg *ProfilesConfig) bool { + if cfg == nil { + return false + } + for _, selector := range []string{cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile} { + if _, unresolved := parseUnresolvedProfileSelector(selector); unresolved { + return true + } + } + for _, selector := range cfg.OrgCurrentProfiles { + if _, unresolved := parseUnresolvedProfileSelector(selector); unresolved { + return true + } + } + return false +} + type profileSelectionMirrorSnapshot struct { organization tokenSlotSnapshot legacy tokenSlotSnapshot @@ -1675,10 +1749,14 @@ func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string { return "" } // Older multi-account writers stored the unresolved profile's local name. - // Recover that intent before the organization grammar can capture a - // CorpName-like name, then persist the safe selector. - if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil { - return storedProfileSelector(cfg, profile) + // Recover it only when no profile gives the same text organization-selector + // meaning. CorpId and CorpName have always outranked local names in the + // public resolver; migration must preserve that precedence instead of + // silently redirecting one organization's selector to another blank profile. + if !selectorConflictsWithOrganizationGrammar(cfg, selector) { + if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil { + return storedProfileSelector(cfg, profile) + } } if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 { if exact := exactProfileSelectorForCorp(cfg, selector, cfg.OrgCurrentProfiles[selector]); exact != "" { @@ -1696,6 +1774,20 @@ func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string { return storedProfileSelector(cfg, p) } +func selectorConflictsWithOrganizationGrammar(cfg *ProfilesConfig, selector string) bool { + if cfg == nil { + return false + } + selector = strings.TrimSpace(selector) + for i := range cfg.Profiles { + if strings.TrimSpace(cfg.Profiles[i].CorpID) == selector || + strings.TrimSpace(cfg.Profiles[i].CorpName) == selector { + return true + } + } + return false +} + func setOrgCurrentProfile(cfg *ProfilesConfig, corpID, selector string) { if cfg == nil { return @@ -1823,6 +1915,34 @@ func shouldSyncOrganizationMirror(cfg *ProfilesConfig, profile Profile) bool { return strings.TrimSpace(profile.UserID) == "" || unresolvedProfileForCorp(cfg, profile.CorpID) == nil } +// validateIdentityOnlyProfileToken verifies the canonical token slot before a +// profile selection is persisted. This path is used only when an unresolved +// profile owns the organization slot, so an exact identity must not fall back +// to that slot. It is deliberately read-only: a rejected switch leaves every +// selection pointer and compatibility mirror untouched. +func validateIdentityOnlyProfileToken(profile Profile) error { + corpID := strings.TrimSpace(profile.CorpID) + userID := strings.TrimSpace(profile.UserID) + if corpID == "" || userID == "" { + return ErrTokenDataNotFound + } + data, err := profilesLoadIdentity(corpID, userID) + if err != nil { + return fmt.Errorf("load token for profile %q: %w", ProfileSelector(profile), err) + } + if data == nil { + return fmt.Errorf("load token for profile %q: %w", ProfileSelector(profile), ErrTokenDataNotFound) + } + if !sameProfileIdentity(data.CorpID, data.UserID, corpID, userID) { + return fmt.Errorf( + "token in profile slot %q belongs to %q; identity does not match selected profile", + ProfileSelector(profile), + profileSelector(data.CorpID, data.UserID), + ) + } + return nil +} + func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool { if cfg == nil { return false diff --git a/internal/auth/refresh_failure_test.go b/internal/auth/refresh_failure_test.go index 243dd24c..5281270f 100644 --- a/internal/auth/refresh_failure_test.go +++ b/internal/auth/refresh_failure_test.go @@ -161,7 +161,10 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR } } _, err := provider.GetTokenSnapshot(context.Background()) - if err == nil || !strings.Contains(err.Error(), `dws auth login --profile "corp:user"`) || + if err == nil || !strings.Contains(err.Error(), "dws auth login") || + !strings.Contains(err.Error(), "--profile") || + !strings.Contains(err.Error(), `profile: "corp:user"`) || + strings.Contains(err.Error(), `dws auth login --profile "corp:user"`) || !strings.Contains(err.Error(), legacyMCPRefreshRejectedCode) { t.Fatalf("legacy MCP refresh guidance = %v", err) } @@ -176,8 +179,10 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR SetRuntimeProfile("External Worker") _, err = provider.GetTokenSnapshot(context.Background()) SetRuntimeProfile("") - if err == nil || !strings.Contains(err.Error(), `dws auth login --profile "External Worker"`) { - t.Fatalf("legacy MCP refresh guidance did not quote spaced selector: %v", err) + if err == nil || !strings.Contains(err.Error(), "dws auth login") || + !strings.Contains(err.Error(), `profile: "External Worker"`) || + strings.Contains(err.Error(), `dws auth login --profile "External Worker"`) { + t.Fatalf("legacy MCP refresh guidance did not isolate spaced selector as display data: %v", err) } if markCalls != 3 { t.Fatalf("spaced legacy MCP rejection marked profile expired %d times, want 3", markCalls) @@ -222,7 +227,10 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola provider := NewOAuthProvider(fixture.configDir, nil) _, err := provider.GetTokenSnapshot(context.Background()) - if err == nil || !strings.Contains(err.Error(), "--profile "+strconv.Quote(fixture.blankSelector)) { + if err == nil || !strings.Contains(err.Error(), "dws auth login") || + !strings.Contains(err.Error(), "--profile") || + !strings.Contains(err.Error(), "profile: "+strconv.Quote(fixture.blankSelector)) || + strings.Contains(err.Error(), "dws auth login --profile") { t.Fatalf("legacy blank refresh guidance = %v, want selector %q", err, fixture.blankSelector) } if markedSelector != fixture.blankSelector { diff --git a/internal/auth/remaining_coverage_test.go b/internal/auth/remaining_coverage_test.go index 60b2c8b1..dd49f436 100644 --- a/internal/auth/remaining_coverage_test.go +++ b/internal/auth/remaining_coverage_test.go @@ -43,6 +43,9 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing }) edition.Override(&edition.Hooks{SaveToken: func(string, []byte) error { return nil }}) + if err := prepareLoginPersistence(t.TempDir()); err != nil { + t.Fatal(err) + } if err := preflightTokenPersistence(t.TempDir()); err != nil { t.Fatal(err) } @@ -55,6 +58,9 @@ func TestCrossPlatformCoverageTokenPersistencePreflightRemainingEdges(t *testing authValidateEntries = func(string) error { return nil } profileFail := errors.New("profile load failed") profilesReadFile = func(string) ([]byte, error) { return nil, profileFail } + if err := prepareLoginPersistence(t.TempDir()); !errors.Is(err, profileFail) { + t.Fatalf("schema preflight profile load error = %v", err) + } if err := preflightTokenPersistence(t.TempDir()); !errors.Is(err, profileFail) { t.Fatalf("profile load error = %v", err) } diff --git a/internal/auth/token.go b/internal/auth/token.go index 70e964c7..b4d02294 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -101,6 +101,92 @@ type TokenData struct { // matched historical profile whose userId was never resolved. It is never // persisted as token material. LegacyOrgScopedProfile string `json:"-"` + // FreshAuthorization distinguishes a new OAuth/device exchange from a + // refresh of the credential already selected locally. Persistence uses this + // transient marker to reject ambiguous UID-less logins without breaking + // legitimate refreshes of unresolved accounts. + FreshAuthorization bool `json:"-"` +} + +// tokenPersistenceWritePlan is the single source of truth for deciding which +// credential slots a token publication can touch. Both the write path and its +// read-only preflight must use this plan so a slot cannot be newly written +// without first being checked for unreadable data. +// +// The plan is intentionally pure: callers supply the already-loaded profile +// registry and process-local runtime selector, and no storage is read or +// mutated while the decision is made. +type tokenPersistenceWritePlan struct { + CorpID string + UserID string + RuntimeSelector string + PersistenceSelector string + ExactSelector string + MakeCurrent bool + ExistingIdentity bool + UpgradesLegacyProfile bool + PreserveUnresolvedOrganization bool + WriteIdentity bool + WriteOrganization bool + WriteGlobal bool +} + +func planTokenPersistenceWrites( + cfg *ProfilesConfig, + data *TokenData, + runtimeSelector string, +) tokenPersistenceWritePlan { + plan := tokenPersistenceWritePlan{ + RuntimeSelector: strings.TrimSpace(runtimeSelector), + // Manual and organization-bound publications both snapshot the global + // compatibility slot before they can replace or resynchronize it. + WriteGlobal: true, + } + if data == nil { + return plan + } + + plan.CorpID = strings.TrimSpace(data.CorpID) + plan.UserID = strings.TrimSpace(data.UserID) + if plan.CorpID == "" { + return plan + } + + plan.PersistenceSelector = plan.RuntimeSelector + if plan.PersistenceSelector == "" && plan.UserID == "" { + plan.PersistenceSelector = strings.TrimSpace(data.LegacyOrgScopedProfile) + } + plan.MakeCurrent = plan.PersistenceSelector == "" + plan.ExactSelector = profileSelector(plan.CorpID, plan.UserID) + plan.ExistingIdentity = profileIndexByIdentity(cfg, plan.CorpID, plan.UserID) >= 0 + plan.UpgradesLegacyProfile = !plan.ExistingIdentity && + plan.UserID != "" && + len(profilesForCorpID(cfg, plan.CorpID)) == 1 && + legacyProfileIndexByCorpID(cfg, plan.CorpID) >= 0 + plan.PreserveUnresolvedOrganization = plan.UserID != "" && + unresolvedProfileForCorp(cfg, plan.CorpID) != nil && + !plan.UpgradesLegacyProfile + plan.WriteIdentity = plan.UserID != "" + orgCurrentSelector := "" + if cfg != nil { + orgCurrentSelector = cfg.OrgCurrentProfiles[plan.CorpID] + } + + // A sole unresolved profile is being completed in place during explicit + // reauthorization. Its organization slot must move with the newly exact + // identity even when an explicit runtime selector keeps it from becoming + // process-global current. + plan.WriteOrganization = plan.UserID == "" || + plan.UpgradesLegacyProfile || + (!plan.PreserveUnresolvedOrganization && + (plan.MakeCurrent || + exactProfileSelectorForCorp( + cfg, + plan.CorpID, + orgCurrentSelector, + ) == plan.ExactSelector)) + + return plan } // IsAccessTokenValid returns true if the access token has not expired. @@ -224,6 +310,30 @@ func SaveTokenData(configDir string, data *TokenData) error { }) } +// SaveLoginTokenData is the safe persistence boundary for credentials produced +// by a new login entry point (OAuth/device/PAT authorization or --token). It +// repairs a uniquely recoverable half-migrated legacy login before any global +// mirror can be replaced, marks the incoming credential as a fresh +// authorization for UID-less account isolation, and preflights every slot the +// write plan can touch. +// +// Refresh paths must continue to use SaveTokenData after their refresh-specific +// preflight; treating a refresh as a fresh authorization would incorrectly +// reject the selected unresolved account in a multi-account organization. +func SaveLoginTokenData(configDir string, data *TokenData) error { + if data == nil { + return fmt.Errorf("token data is empty") + } + if err := prepareLoginPersistence(configDir); err != nil { + return fmt.Errorf("local login state cannot be safely updated: %w", err) + } + data.FreshAuthorization = true + if err := preflightTokenWritePersistence(configDir, data); err != nil { + return fmt.Errorf("local login state cannot be safely updated: %w", err) + } + return SaveTokenData(configDir, data) +} + // saveTokenDataLocked performs the keychain + profiles.json + legacy mirror // writes assuming the auth dual-layer lock is already held. Callers that // already hold the lock (OAuthProvider refresh path, the legacy secure->keychain @@ -259,45 +369,37 @@ func saveTokenDataLocked(configDir string, data *TokenData) error { if err := ensureProfilesWritable(cfg); err != nil { return err } - runtimeSelector := strings.TrimSpace(RuntimeProfile()) - persistenceSelector := runtimeSelector - if persistenceSelector == "" && userID == "" { - persistenceSelector = strings.TrimSpace(data.LegacyOrgScopedProfile) + plan := planTokenPersistenceWrites(cfg, data, RuntimeProfile()) + if err := validateTokenPersistenceWritePlan(cfg, data, plan); err != nil { + return err } - makeCurrent := persistenceSelector == "" - exactSelector := profileSelector(corpID, userID) - existingIdentity := profileIndexByIdentity(cfg, corpID, userID) >= 0 - upgradesLegacyProfile := !existingIdentity && userID != "" && - len(profilesForCorpID(cfg, corpID)) == 1 && legacyProfileIndexByCorpID(cfg, corpID) >= 0 - preserveUnresolvedOrg := userID != "" && unresolvedProfileForCorp(cfg, corpID) != nil && !upgradesLegacyProfile - // An unresolved legacy identity has no exact slot by definition. Its - // organization slot is therefore the canonical credential even when an - // explicit runtime profile means this login should not become global - // current. When it coexists with exact accounts, those accounts remain - // identity-slot-only so profile switching or refresh cannot overwrite the - // unresolved account's sole credential. - mirrorOrg := userID == "" || (!preserveUnresolvedOrg && (makeCurrent || - exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) == exactSelector)) logging.AuthDebug( "auth.token.persist.plan", "corp_id", corpID, "user_id", userID, "user_name", strings.TrimSpace(data.UserName), - "identity_selector", exactSelector, - "existing_identity", existingIdentity, - "upgrades_legacy_profile", upgradesLegacyProfile, + "identity_selector", plan.ExactSelector, + "existing_identity", plan.ExistingIdentity, + "upgrades_legacy_profile", plan.UpgradesLegacyProfile, "profiles_before", len(cfg.Profiles), - "runtime_profile", runtimeSelector, - "persistence_profile", persistenceSelector, - "write_identity_slot", userID != "", - "write_org_mirror", mirrorOrg, - "write_global_mirror", makeCurrent, + "runtime_profile", plan.RuntimeSelector, + "persistence_profile", plan.PersistenceSelector, + "write_identity_slot", plan.WriteIdentity, + "write_org_mirror", plan.WriteOrganization, + "write_global_mirror", plan.WriteGlobal, + "publish_incoming_global", plan.MakeCurrent, + ) + snapshot, err := snapshotTokenPersistence( + configDir, + cfg, + plan.CorpID, + plan.UserID, + plan.WriteOrganization, ) - snapshot, err := snapshotTokenPersistence(configDir, cfg, corpID, userID, mirrorOrg) if err != nil { return err } - preserveManualDefault := !makeCurrent && + preserveManualDefault := !plan.MakeCurrent && snapshot.marker.known && snapshot.marker.exists && snapshot.marker.manual @@ -307,26 +409,28 @@ func saveTokenDataLocked(configDir string, data *TokenData) error { } return operationErr } - if userID != "" { + if plan.WriteIdentity { if err := tokenSaveKeychainForIdentity(corpID, userID, data); err != nil { return rollback(err) } } - if err := tokenUpsertProfile(configDir, data, makeCurrent); err != nil { + if err := tokenUpsertProfile(configDir, data, plan.MakeCurrent); err != nil { return rollback(err) } - if mirrorOrg { + if plan.WriteOrganization { if err := tokenSaveKeychainForCorpID(corpID, data); err != nil { return rollback(err) } } - if makeCurrent { - if err := tokenSaveKeychain(data); err != nil { - return rollback(err) - } - } else if !preserveManualDefault { - if err := tokenSyncLegacyMirror(configDir); err != nil { - return rollback(err) + if plan.WriteGlobal { + if plan.MakeCurrent { + if err := tokenSaveKeychain(data); err != nil { + return rollback(err) + } + } else if !preserveManualDefault { + if err := tokenSyncLegacyMirror(configDir); err != nil { + return rollback(err) + } } } if preserveManualDefault { @@ -341,10 +445,11 @@ func saveTokenDataLocked(configDir string, data *TokenData) error { "corp_id", corpID, "user_id", userID, "user_name", strings.TrimSpace(data.UserName), - "identity_selector", exactSelector, - "write_identity_slot", userID != "", - "write_org_mirror", mirrorOrg, - "write_global_mirror", makeCurrent, + "identity_selector", plan.ExactSelector, + "write_identity_slot", plan.WriteIdentity, + "write_org_mirror", plan.WriteOrganization, + "write_global_mirror", plan.WriteGlobal && !preserveManualDefault, + "publish_incoming_global", plan.MakeCurrent, ) return nil } @@ -456,7 +561,7 @@ func loadTokenDataForProfileLocked(configDir, profile string) (*TokenData, error // as a different organization (the legacy mirror may have drifted). if legacy, lerr := tokenLoadKeychain(); lerr == nil && legacy != nil && strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) && - (strings.TrimSpace(selected.UserID) == "" || strings.TrimSpace(legacy.UserID) == strings.TrimSpace(selected.UserID)) { + strings.TrimSpace(legacy.UserID) == strings.TrimSpace(selected.UserID) { return legacy, nil } else if lerr != nil && !errors.Is(lerr, ErrTokenDataNotFound) { return nil, lerr @@ -486,50 +591,129 @@ func loadTokenDataForProfileLocked(configDir, profile string) (*TokenData, error } func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) { + corpID := strings.TrimSpace(profile.CorpID) + userID := strings.TrimSpace(profile.UserID) if strings.TrimSpace(profile.UserID) == "" { - data, err := tokenLoadKeychainForCorpID(profile.CorpID) + data, err := tokenLoadKeychainForCorpID(corpID) if err != nil { return nil, err } if data == nil { return nil, ErrTokenDataNotFound } + if strings.TrimSpace(data.CorpID) != corpID { + return nil, fmt.Errorf( + "organization token mirror for corpId %q contains token for corpId %q; cannot use it for unresolved profile %q", + corpID, + data.CorpID, + profile.Name, + ) + } if strings.TrimSpace(data.UserID) != "" { return nil, fmt.Errorf( "organization token mirror for corpId %q belongs to userId %q; cannot use it for unresolved profile %q", - profile.CorpID, + corpID, data.UserID, profile.Name, ) } return data, nil } - data, err := tokenLoadKeychainIdentity(profile.CorpID, profile.UserID) + data, err := tokenLoadKeychainIdentity(corpID, userID) if err == nil { + if data == nil { + return nil, ErrTokenDataNotFound + } + if strings.TrimSpace(data.CorpID) != corpID || strings.TrimSpace(data.UserID) != userID { + return nil, fmt.Errorf( + "identity token slot %q contains token for %q; cannot use it for profile %q", + TokenAccountForIdentity(corpID, userID), + profileSelector(data.CorpID, data.UserID), + ProfileSelector(profile), + ) + } return data, nil } if !errors.Is(err, ErrTokenDataNotFound) { return nil, err } - orgData, orgErr := tokenLoadKeychainForCorpID(profile.CorpID) + orgData, orgErr := tokenLoadKeychainForCorpID(corpID) if orgErr != nil { if errors.Is(orgErr, ErrTokenDataNotFound) { return nil, err } return nil, orgErr } - if strings.TrimSpace(orgData.UserID) == "" { - return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", profile.CorpID, ProfileSelector(profile)) - } - if strings.TrimSpace(orgData.UserID) != strings.TrimSpace(profile.UserID) { + if orgData == nil { return nil, err } - if saveErr := tokenSaveKeychainForIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil { + if strings.TrimSpace(orgData.CorpID) != corpID { + return nil, fmt.Errorf( + "organization token mirror for corpId %q contains token for corpId %q; cannot use it for profile %q", + corpID, + orgData.CorpID, + ProfileSelector(profile), + ) + } + if strings.TrimSpace(orgData.UserID) == "" { + return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", corpID, ProfileSelector(profile)) + } + if strings.TrimSpace(orgData.UserID) != userID { + return nil, err + } + if saveErr := tokenSaveKeychainForIdentity(corpID, userID, orgData); saveErr != nil { return nil, saveErr } return orgData, nil } +// validateTokenPersistenceWritePlan prevents a freshly authorized UID-less +// credential from being attached to an existing unresolved sibling merely +// because both accounts share a corpId. An explicit selector is a storage +// boundary, but it is not proof that an exact account completed OAuth. The only +// safe overwrite is when that selector itself resolves to the existing +// unresolved profile. A blank selector remains allowed for ordinary refreshes +// of the already-selected unresolved token. +func validateTokenPersistenceWritePlan( + cfg *ProfilesConfig, + data *TokenData, + plan tokenPersistenceWritePlan, +) error { + if data == nil || plan.CorpID == "" || plan.UserID != "" { + return nil + } + unresolved := unresolvedProfileForCorp(cfg, plan.CorpID) + if unresolved == nil { + return nil + } + targetSelector := plan.RuntimeSelector + if targetSelector == "" { + targetSelector = strings.TrimSpace(data.LegacyOrgScopedProfile) + } + if targetSelector == "" { + if data.FreshAuthorization && len(profilesForCorpID(cfg, plan.CorpID)) > 1 { + return fmt.Errorf( + "refusing to save a fresh UID-less token over existing unresolved profile %q in multi-account organization %q; retry with that unresolved profile selector or require server-provided userId", + storedProfileSelector(cfg, unresolved), + plan.CorpID, + ) + } + return nil + } + selected, _, err := resolveProfileSelection("", cfg, targetSelector) + if err == nil && selected != nil && + strings.TrimSpace(selected.CorpID) == plan.CorpID && + strings.TrimSpace(selected.UserID) == "" { + return nil + } + return fmt.Errorf( + "refusing to save UID-less token selected as profile %q over existing unresolved profile %q in organization %q; retry with the unresolved profile selector or require server-provided userId", + targetSelector, + storedProfileSelector(cfg, unresolved), + plan.CorpID, + ) +} + // DeleteTokenData removes token data. Edition hooks and the default keychain // path are both serialized with refresh through the auth dual lock. func DeleteTokenData(configDir string) error { diff --git a/internal/auth/token_preflight_test.go b/internal/auth/token_preflight_test.go index c2540932..276c1eb0 100644 --- a/internal/auth/token_preflight_test.go +++ b/internal/auth/token_preflight_test.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "io" + "net" "net/http" "net/http/httptest" "os" @@ -249,10 +250,9 @@ func TestCrossPlatformCoverageExactRefreshAndSwitchIgnoreUnreadableReservedBlank } } -func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) { +func TestFullTokenPersistenceInventoryDetectsOrphanProfileCiphertext(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") - setPreflightTestCredentials(t) configDir := t.TempDir() data := testToken("at_orphan", "corp_orphan", "Orphan Org") @@ -269,21 +269,12 @@ func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing. t.Fatalf("WriteFile(replacement DEK) error = %v", err) } - var calls atomic.Int32 - provider := NewOAuthProvider(configDir, nil) - provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) { - calls.Add(1) - return nil, errors.New("unexpected HTTP request") - })} - _, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "") + err := preflightTokenPersistence(configDir) if err == nil || !strings.Contains(err.Error(), "auth token ciphertext inventory") { - t.Fatalf("ExchangeAuthCode() error = %v, want orphan ciphertext preflight error", err) + t.Fatalf("preflightTokenPersistence() error = %v, want orphan ciphertext inventory error", err) } if !keychain.IsCiphertextKeyMismatch(err) { - t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err) - } - if got := calls.Load(); got != 0 { - t.Fatalf("HTTP calls = %d, want 0", got) + t.Fatalf("preflightTokenPersistence() error = %v, want ciphertext key mismatch in error chain", err) } } @@ -343,7 +334,7 @@ func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) { } } -func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) { +func TestOAuthLoginUnreadableGlobalFailsClosedBeforeAuthorizationStart(t *testing.T) { setPreflightTestCredentials(t) for _, force := range []bool{false, true} { t.Run("force="+map[bool]string{false: "false", true: "true"}[force], func(t *testing.T) { @@ -351,33 +342,60 @@ func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) { configDir := t.TempDir() seedUnreadableTokenStorage(t, configDir, testToken("at_login", "corp_login", "Login Org")) - ctx, cancel := context.WithCancel(context.Background()) - cancel() + listenErr := errors.New("authorization listener reached") + var calls atomic.Int32 + oldListen := oauthListen + oauthListen = func(string, string) (net.Listener, error) { + calls.Add(1) + return nil, listenErr + } + t.Cleanup(func() { oauthListen = oldListen }) + provider := NewOAuthProvider(configDir, nil) provider.NoBrowser = true - _, err := provider.Login(ctx, force) - if err == nil || !strings.Contains(err.Error(), "legacy token slot") { - t.Fatalf("Login(force=%v) error = %v, want token persistence preflight error", force, err) + _, err := provider.Login(context.Background(), force) + if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") { + t.Fatalf("Login(force=%v) error = %v, want unreadable-global protection", force, err) + } + if errors.Is(err, listenErr) { + t.Fatalf("Login(force=%v) reached authorization listener: %v", force, err) + } + if got := calls.Load(); got != 0 { + t.Fatalf("Login(force=%v) listener calls = %d, want 0", force, got) } }) } } -func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) { +func TestExchangeAuthCodeRejectsUnreadableGlobalBeforeHTTP(t *testing.T) { cleanupKeychain(t) setPreflightTestCredentials(t) configDir := t.TempDir() - seedUnreadableTokenStorage(t, configDir, testToken("at_exchange", "corp_exchange", "Exchange Org")) + existing := testToken("at_exchange", "corp_exchange", "Exchange Org") + seedUnreadableTokenStorage(t, configDir, existing) var calls atomic.Int32 + var saveCalls atomic.Int32 + oldSave := oauthSaveToken + oauthSaveToken = func(string, *TokenData) error { + saveCalls.Add(1) + return nil + } + t.Cleanup(func() { oauthSaveToken = oldSave }) provider := NewOAuthProvider(configDir, nil) provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) { calls.Add(1) - return nil, errors.New("unexpected HTTP request") + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_exchange"}`, + )), + }, nil })} - _, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "") + _, err := provider.ExchangeAuthCode(context.Background(), "auth-code", existing.UserID) if err == nil || !strings.Contains(err.Error(), "legacy token slot") { - t.Fatalf("ExchangeAuthCode() error = %v, want token persistence preflight error", err) + t.Fatalf("ExchangeAuthCode() error = %v, want target token persistence error", err) } if !keychain.IsCiphertextKeyMismatch(err) { t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err) @@ -385,9 +403,12 @@ func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) { if got := calls.Load(); got != 0 { t.Fatalf("HTTP calls = %d, want 0", got) } + if got := saveCalls.Load(); got != 0 { + t.Fatalf("SaveTokenData calls = %d, want 0", got) + } } -func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) { +func TestDeviceFlowLoginRejectsUnreadableGlobalBeforeDeviceCodeRequest(t *testing.T) { cleanupKeychain(t) setPreflightTestCredentials(t) configDir := t.TempDir() @@ -405,7 +426,7 @@ func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) { provider.SetBaseURL(server.URL) _, err := provider.Login(context.Background()) if err == nil || !strings.Contains(err.Error(), "legacy token slot") { - t.Fatalf("DeviceFlowProvider.Login() error = %v, want token persistence preflight error", err) + t.Fatalf("DeviceFlowProvider.Login() error = %v, want unreadable-global protection", err) } if got := calls.Load(); got != 0 { t.Fatalf("device code requests = %d, want 0", got) @@ -457,7 +478,7 @@ func TestLockedRefreshRejectsFutureProfilesVersionBeforeHTTP(t *testing.T) { if err != nil { t.Fatalf("LoadProfiles() error = %v", err) } - cfg.Version = profilesVersion + 1 + cfg.Version = profilesMaxVersion + 1 raw, err := json.Marshal(cfg) if err != nil { t.Fatalf("json.Marshal() error = %v", err) diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go index d186fefd..780d729f 100644 --- a/internal/auth/token_test.go +++ b/internal/auth/token_test.go @@ -747,8 +747,8 @@ func assertProfileSwitchRolledBack(t *testing.T, configDir string, before *Profi func TestFutureProfilesVersionIsNotDowngradedOrOverwritten(t *testing.T) { cleanupKeychain(t) configDir := t.TempDir() - future := []byte(`{ - "version": 3, + future := []byte(fmt.Sprintf(`{ + "version": %d, "currentProfile": "corp_future:user_1", "orgCurrentProfiles": {"corp_future": "corp_future:user_1"}, "futureField": {"preserve": true}, @@ -759,7 +759,7 @@ func TestFutureProfilesVersionIsNotDowngradedOrOverwritten(t *testing.T) { "futureProfileField": "preserve" }] } -`) +`, profilesMaxVersion+1)) if err := os.WriteFile(ProfilesPath(configDir), future, 0o600); err != nil { t.Fatalf("write future profiles fixture: %v", err) } diff --git a/internal/keychain/auth_entries_windows.go b/internal/keychain/auth_entries_windows.go index c41bf669..ad0b26d3 100644 --- a/internal/keychain/auth_entries_windows.go +++ b/internal/keychain/auth_entries_windows.go @@ -15,9 +15,51 @@ package keychain -// Windows stores token entries in the DPAPI-protected user registry rather -// than auth-token*.enc files, so there is no file inventory to validate. -func platformValidateAuthTokenEntries(string) error { +import ( + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/sys/windows/registry" +) + +// Windows stores token entries in the DPAPI-protected user registry. Enumerate +// every auth-token account so orphaned slots that are not present in +// profiles.json are still validated before a login can overwrite credentials. +func platformValidateAuthTokenEntries(service string) error { + keyPath := registryPathForService(service) + k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return nil + } + return fmt.Errorf("registry open for auth token validation failed: %w", err) + } + defer k.Close() + + names, err := k.ReadValueNames(-1) + if err != nil { + return fmt.Errorf("registry list values for auth token validation failed: %w", err) + } + for _, name := range names { + accountBytes, decodeErr := base64.RawURLEncoding.DecodeString(name) + if decodeErr != nil { + continue + } + account := string(accountBytes) + if account != AccountToken && !strings.HasPrefix(account, AccountToken+":") { + continue + } + if _, found, err := registryGetFromKey(k, service, account); err != nil { + return fmt.Errorf("validate registry auth token account %q: %w", account, err) + } else if !found { + // The value was returned by ReadValueNames but disappeared before it + // could be read. Fail closed so a concurrent mutation cannot make the + // preflight silently skip an existing credential slot. + return fmt.Errorf("validate registry auth token account %q: value disappeared during validation", account) + } + } return nil } diff --git a/internal/keychain/keychain_windows.go b/internal/keychain/keychain_windows.go index 00c35767..48de3d73 100644 --- a/internal/keychain/keychain_windows.go +++ b/internal/keychain/keychain_windows.go @@ -159,7 +159,10 @@ func freeDataBlob(b *windows.DataBlob) { } func platformGet(service, account string) (string, error) { - v, ok := registryGet(service, account) + v, ok, err := registryGet(service, account) + if err != nil { + return "", err + } if !ok { return "", nil // Not found is not an error } @@ -179,28 +182,40 @@ func platformRemove(service, account string) error { return registryRemove(service, account) } -func registryGet(service, account string) (string, bool) { +func registryGet(service, account string) (string, bool, error) { keyPath := registryPathForService(service) k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE) if err != nil { - return "", false + if errors.Is(err, registry.ErrNotExist) { + return "", false, nil + } + return "", false, fmt.Errorf("registry open for read failed: %w", err) } defer k.Close() + return registryGetFromKey(k, service, account) +} +func registryGetFromKey(k registry.Key, service, account string) (string, bool, error) { b64, _, err := k.GetStringValue(valueNameForAccount(account)) - if err != nil || b64 == "" { - return "", false + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return "", false, nil + } + return "", false, fmt.Errorf("registry read account %q failed: %w", account, err) + } + if b64 == "" { + return "", false, fmt.Errorf("registry account %q contains empty DPAPI ciphertext", account) } blob, err := base64.StdEncoding.DecodeString(b64) if err != nil { - return "", false + return "", false, fmt.Errorf("decode registry account %q DPAPI ciphertext: %w", account, err) } entropy := dpapiEntropy(service, account) plain, err := dpapiUnprotect(blob, entropy) if err != nil { - return "", false + return "", false, fmt.Errorf("dpapi unprotect registry account %q failed: %w", account, err) } - return string(plain), true + return string(plain), true, nil } func registrySet(service, account string, protected []byte) error { diff --git a/internal/keychain/keychain_windows_validation_test.go b/internal/keychain/keychain_windows_validation_test.go new file mode 100644 index 00000000..ffb90c6b --- /dev/null +++ b/internal/keychain/keychain_windows_validation_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package keychain + +import ( + "encoding/base64" + "strings" + "testing" + + "golang.org/x/sys/windows/registry" +) + +func writeRawRegistryString(t *testing.T, service, account, value string) { + t.Helper() + writeRawRegistryNamedString(t, service, valueNameForAccount(account), value) +} + +func writeRawRegistryNamedString(t *testing.T, service, name, value string) { + t.Helper() + k, _, err := registry.CreateKey( + registry.CURRENT_USER, + registryPathForService(service), + registry.SET_VALUE, + ) + if err != nil { + t.Fatalf("registry.CreateKey() error = %v", err) + } + defer k.Close() + if err := k.SetStringValue(name, value); err != nil { + t.Fatalf("SetStringValue(%q) error = %v", name, err) + } +} + +func writeRawRegistryDWORD(t *testing.T, service, account string, value uint32) { + t.Helper() + k, _, err := registry.CreateKey( + registry.CURRENT_USER, + registryPathForService(service), + registry.SET_VALUE, + ) + if err != nil { + t.Fatalf("registry.CreateKey() error = %v", err) + } + defer k.Close() + if err := k.SetDWordValue(valueNameForAccount(account), value); err != nil { + t.Fatalf("SetDWordValue(%q) error = %v", account, err) + } +} + +func TestCrossPlatformCoverageWindowsRegistryDistinguishesMissingFromUnreadable(t *testing.T) { + service := "test-service-" + t.Name() + account := AccountToken + ":legacy-corp" + t.Cleanup(func() { _ = Remove(service, account) }) + + got, err := Get(service, account) + if err != nil || got != "" { + t.Fatalf("missing registry account = %q, %v; want empty and nil", got, err) + } + + writeRawRegistryString(t, service, account, "") + if _, err := Get(service, account); err == nil || !strings.Contains(err.Error(), "empty DPAPI ciphertext") { + t.Fatalf("empty registry value error = %v", err) + } + missingAccount := AccountToken + ":missing-value" + if got, err := Get(service, missingAccount); err != nil || got != "" { + t.Fatalf("missing value in existing registry key = %q, %v; want empty and nil", got, err) + } + + writeRawRegistryString(t, service, account, "%%%not-base64%%") + if _, err := Get(service, account); err == nil || !strings.Contains(err.Error(), "decode registry account") { + t.Fatalf("invalid base64 error = %v", err) + } + + writeRawRegistryString(t, service, account, base64.StdEncoding.EncodeToString([]byte("not a DPAPI blob"))) + if _, err := Get(service, account); err == nil || !strings.Contains(err.Error(), "dpapi unprotect") { + t.Fatalf("invalid DPAPI blob error = %v", err) + } + + writeRawRegistryDWORD(t, service, account, 1) + if _, err := Get(service, account); err == nil || !strings.Contains(err.Error(), "registry read account") { + t.Fatalf("unexpected registry value type error = %v", err) + } +} + +func TestCrossPlatformCoverageWindowsAuthTokenInventoryValidatesOrphanSlots(t *testing.T) { + service := "test-service-" + t.Name() + const unrelated = "app-secret:client" + orphan := AccountToken + ":orphan-corp" + t.Cleanup(func() { + _ = Remove(service, unrelated) + _ = Remove(service, orphan) + }) + + if err := ValidateAuthTokenEntries(service); err != nil { + t.Fatalf("missing registry key validation error = %v", err) + } + + // Corruption in unrelated keychain accounts is outside the auth inventory. + writeRawRegistryString(t, service, unrelated, "%%%not-base64%%") + writeRawRegistryNamedString(t, service, "%%%not-an-account-name%%", "%%%not-base64%%") + if err := ValidateAuthTokenEntries(service); err != nil { + t.Fatalf("unrelated registry value validation error = %v", err) + } + + // The orphan is deliberately not represented in profiles.json. Inventory + // discovery must still find it by its historical auth-token account prefix. + writeRawRegistryString(t, service, orphan, "%%%not-base64%%") + if err := ValidateAuthTokenEntries(service); err == nil || !strings.Contains(err.Error(), orphan) { + t.Fatalf("orphan auth token validation error = %v", err) + } + + if err := Set(service, orphan, "legacy token"); err != nil { + t.Fatalf("Set(orphan) error = %v", err) + } + if err := ValidateAuthTokenEntries(service); err != nil { + t.Fatalf("valid orphan auth token validation error = %v", err) + } + got, err := Get(service, orphan) + if err != nil || got != "legacy token" { + t.Fatalf("round trip = %q, %v; want legacy token", got, err) + } +} diff --git a/scripts/policy/check-auth-legacy-compat.sh b/scripts/policy/check-auth-legacy-compat.sh new file mode 100755 index 00000000..196d4226 --- /dev/null +++ b/scripts/policy/check-auth-legacy-compat.sh @@ -0,0 +1,90 @@ +#!/bin/sh +set -eu + +# Keep the old-token migration contract reviewable as one stable, zero-argument +# policy check. Every group is enumerated explicitly: discovery must find the +# expected number of top-level tests before any selected test is executed. + +if [ "$#" -ne 0 ]; then + printf '%s\n' "usage: $0" >&2 + exit 2 +fi + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +GO="${GO:-go}" +cd "$ROOT" +. "$ROOT/scripts/policy/policy-runtime.sh" +policy_prepare_runtime "$ROOT" + +TMP_ROOT="$(policy_runtime_mktemp_dir dws-auth-legacy-compat)" +trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM +export DWS_DISABLE_KEYCHAIN=1 +export DWS_KEYCHAIN_DIR="$TMP_ROOT/keychain" +mkdir -p "$DWS_KEYCHAIN_DIR" +DISCOVERED_TOTAL=0 +EXPECTED_TOTAL=39 + +MIGRATION_PATTERN='^(TestCrossPlatformCoverageV1044GlobalSlotWithoutProfilesMigrates|TestCrossPlatformCoverageV1050AndV1051GlobalSlotWithV1ProfilesMigratesIdentity|TestCrossPlatformCoverageV1052RawMultiOrganizationSlotsMigrateEveryIdentity|TestCrossPlatformCoverageV1052UnresolvedMultiOrganizationProfilesRemainUsable|TestCrossPlatformCoverageV1052FirstSaveMigratesAllOrganizationsBeforeV2Commit|TestCrossPlatformCoverageV1053PartialV2RegistryRepairsFromMatchingGlobalSlot|TestPersistingLoginFlowsRepairHalfMigratedGlobalBeforeRemoteAndSave|TestExchangeCodeForTokenPreparesBeforeRemoteAndMarksFresh|TestSaveLoginTokenDataRepairsHalfMigratedStateBeforeManualGlobalOverwrite|TestManualLoginSaveRepairsHalfMigratedGlobalBeforeOverwrite|TestCLISmoke_LegacyAuthSlotsMigrateThroughStatus)$' +ISOLATION_PATTERN='^(TestDefaultBlankCurrentRejectsSameCorpGlobalMirrorOwnedByExactIdentity|TestTokenLoadIsolationMatrix|TestTokenPersistenceWritePlanKeepsOrganizationOwnershipIsolated|TestFreshUIDLessExactLoginCannotOverwriteExistingUnresolvedProfile|TestOAuthPersistLoginTokenMarksFreshBeforeUIDLessIsolationCheck|TestDeviceLoginIgnoresUnreadableUnrelatedProfile|TestPATFreshAuthorizationSaveUsesLoginIsolationBoundary|TestSetCurrentProfileRejectsUnreadableExactIdentityBesideBlankProfile|TestUsePreviousProfileRejectsUnreadableExactIdentityBesideBlankProfile|TestExplicitReauthUpgradingSoleBlankProfilePublishesAllTokenMirrors|TestCrossPlatformCoverageLegacyBlankAndExactIdentitySlotsPersistAcrossRepeatedSaves|TestCrossPlatformCoverageExactIdentityRefreshDoesNotOverwriteLegacyBlankOrganizationSlot|TestCrossPlatformCoverageOAuthAndDeviceKeepFreshUnknownIdentityIsolatedFromExactHistory)$' +PARENT_CHILD_PATTERN='^(TestPersonalBusProfileSelectorUsesDefaultBlankCurrentBeforeRuntimeEnrichedIdentity|TestPersonalBusProfileSelectorUsesDefaultExactCurrent|TestPersonalBusProfileSelectorPrefersExplicitRuntimeSelector|TestPersonalBusSpawnArgs_ForwardsProfile|TestCrossPlatformCoveragePersonalBusSpawnArgsPreservesReservedBlankProfile)$' +SELECTOR_PATTERN='^(TestReservedUnresolvedSelectorPromotesSchemaToDowngradeGuardVersion|TestV2ProfilesWithoutReservedSelectorRemainDowngradeReadable|TestInvalidReservedOrgCurrentSelectorNormalizesBackToV2|TestEnsureMigrationPersistsDowngradeGuardForRawV2ReservedSelector|TestV3DowngradesAfterBlankProfileIdentityCompletion|TestV3DowngradesAfterUnresolvedProfileDeletion|TestV3DowngradesWhenExactConflictDeletionLeavesSoleBlankProfile|TestV3DowngradesWhenReservedSelectorCanonicalizesToSafeLocalName)$' +GUIDANCE_PATTERN='^(TestLegacyRefreshReauthorizationGuidanceTreatsProfileAsDisplayData|TestLegacyRefreshReauthorizationGuidanceWithoutProfileStillExplainsLogin)$' + +validate_group() { + group="$1" + expected="$2" + pattern="$3" + shift 3 + list_file="$TMP_ROOT/$group.list" + + if ! "$GO" test -list "$pattern" "$@" >"$list_file"; then + printf 'AUTH_LEGACY_COMPAT phase=discovery group=%s state=FAIL reason=list-error\n' "$group" >&2 + exit 1 + fi + actual="$(awk '/^Test[[:alnum:]_]+$/ { count++ } END { print count + 0 }' "$list_file")" + if [ "$expected" -le 0 ] || [ "$actual" -ne "$expected" ]; then + printf 'AUTH_LEGACY_COMPAT phase=discovery group=%s state=FAIL expected=%s actual=%s\n' \ + "$group" "$expected" "$actual" >&2 + cat "$list_file" >&2 + exit 1 + fi + DISCOVERED_TOTAL=$((DISCOVERED_TOTAL + actual)) + printf 'AUTH_LEGACY_COMPAT phase=discovery group=%s state=PASS top_level_tests=%s\n' \ + "$group" "$actual" +} + +run_group() { + group="$1" + expected="$2" + pattern="$3" + shift 3 + + if ! "$GO" test -p=1 -count=1 -run "$pattern" "$@"; then + printf 'AUTH_LEGACY_COMPAT phase=execution group=%s state=FAIL top_level_tests=%s\n' \ + "$group" "$expected" >&2 + exit 1 + fi + printf 'AUTH_LEGACY_COMPAT phase=execution group=%s state=PASS top_level_tests=%s\n' \ + "$group" "$expected" +} + +# Discovery is intentionally completed for every group before execution starts. +validate_group migration 11 "$MIGRATION_PATTERN" ./internal/auth ./internal/app ./test/smoke +validate_group isolation 13 "$ISOLATION_PATTERN" ./internal/auth ./internal/app +validate_group parent-child 5 "$PARENT_CHILD_PATTERN" ./internal/app +validate_group selector 8 "$SELECTOR_PATTERN" ./internal/auth +validate_group guidance 2 "$GUIDANCE_PATTERN" ./internal/auth +if [ "$DISCOVERED_TOTAL" -ne "$EXPECTED_TOTAL" ]; then + printf 'AUTH_LEGACY_COMPAT phase=discovery group=summary state=FAIL expected=%s actual=%s\n' \ + "$EXPECTED_TOTAL" "$DISCOVERED_TOTAL" >&2 + exit 1 +fi + +run_group migration 11 "$MIGRATION_PATTERN" ./internal/auth ./internal/app ./test/smoke +run_group isolation 13 "$ISOLATION_PATTERN" ./internal/auth ./internal/app +run_group parent-child 5 "$PARENT_CHILD_PATTERN" ./internal/app +run_group selector 8 "$SELECTOR_PATTERN" ./internal/auth +run_group guidance 2 "$GUIDANCE_PATTERN" ./internal/auth + +printf 'AUTH_LEGACY_COMPAT summary state=PASS groups=5 top_level_tests=%s isolation_matrix_cells=18 states=migration:PASS,isolation:PASS,parent-child:PASS,selector:PASS,guidance:PASS\n' \ + "$DISCOVERED_TOTAL" diff --git a/test/smoke/auth_legacy_slots_smoke_test.go b/test/smoke/auth_legacy_slots_smoke_test.go index 6993fd9d..e01a7807 100644 --- a/test/smoke/auth_legacy_slots_smoke_test.go +++ b/test/smoke/auth_legacy_slots_smoke_test.go @@ -2,6 +2,7 @@ package smoke_test import ( "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "os" @@ -167,6 +168,69 @@ func TestCLISmoke_LegacyAuthSlotsMigrateThroughStatus(t *testing.T) { }) } +// TestCLISmoke_ReservedUnresolvedAndExactProfilesStayIsolated protects the +// schema-v3 selector boundary through real CLI subprocesses. A blank external +// identity and a resolved account may legitimately coexist in one +// organization; selecting either must never read or rewrite the other's slot. +func TestCLISmoke_ReservedUnresolvedAndExactProfilesStayIsolated(t *testing.T) { + env, configDir := isolatedLegacyAuthCLIEnv(t) + corpID, exactUserID := legacyFixtureIdentity(t, "schema-v3-blank-exact") + reservedSelector := "@legacy/" + base64.RawURLEncoding.EncodeToString([]byte(corpID)) + exactSelector := corpID + ":" + exactUserID + blankAccessToken := "fixture-access-schema-v3-blank" + exactAccessToken := "fixture-access-schema-v3-exact" + blankRawToken := legacyRawToken( + t, blankAccessToken, corpID, "Schema V3 Fixture Org", "", "", + ) + exactRawToken := legacyRawToken( + t, exactAccessToken, corpID, "Schema V3 Fixture Org", exactUserID, "Exact Fixture Account", + ) + + writeCoexistingProfilesV3(t, configDir, corpID, exactUserID, reservedSelector, exactSelector) + organizationAccount := legacyOrganizationAccount(corpID) + identityAccount := legacyIdentityAccount(corpID, exactUserID) + seedRawLegacyKeychainSlot(t, organizationAccount, blankRawToken) + seedRawLegacyKeychainSlot(t, identityAccount, exactRawToken) + + defaultStatus := runLegacyAuthStatus(t, env) + assertAuthenticatedStatus(t, defaultStatus, corpID, "") + exactStatus := runLegacyAuthStatus(t, env, exactSelector) + assertAuthenticatedStatus(t, exactStatus, corpID, exactUserID) + + assertRawKeychainSlotUnchanged(t, organizationAccount, blankRawToken) + assertRawKeychainSlotUnchanged(t, identityAccount, exactRawToken) + + var profiles legacyProfilesDocument + readJSONFile(t, filepath.Join(configDir, "profiles.json"), &profiles) + if profiles.Version != 3 { + t.Fatalf("profiles version = %d, want 3", profiles.Version) + } + if profiles.CurrentProfile != reservedSelector { + t.Fatalf("current profile = %q, want reserved blank selector %q", profiles.CurrentProfile, reservedSelector) + } + if profiles.OrgCurrentProfiles[corpID] != exactSelector { + t.Fatalf( + "orgCurrentProfiles[%q] = %q, want exact selector %q", + corpID, + profiles.OrgCurrentProfiles[corpID], + exactSelector, + ) + } + if len(profiles.Profiles) != 2 { + t.Fatalf("profiles = %#v, want one blank and one exact identity", profiles.Profiles) + } + identities := map[string]int{} + for _, profile := range profiles.Profiles { + if profile.CorpID != corpID { + t.Fatalf("profile corpId = %q, want %q", profile.CorpID, corpID) + } + identities[profile.UserID]++ + } + if identities[""] != 1 || identities[exactUserID] != 1 { + t.Fatalf("profile identities = %#v, want one blank and one exact %q", identities, exactUserID) + } +} + type legacySmokeProfile struct { Name string CorpID string @@ -309,6 +373,54 @@ func writeHalfMigratedProfilesV2(t *testing.T, configDir string, profile legacyS } } +func writeCoexistingProfilesV3( + t *testing.T, + configDir string, + corpID string, + exactUserID string, + reservedSelector string, + exactSelector string, +) { + t.Helper() + document := map[string]any{ + "version": 3, + "primaryProfile": exactSelector, + "currentProfile": reservedSelector, + "previousProfile": exactSelector, + "orgCurrentProfiles": map[string]string{corpID: exactSelector}, + "profiles": []map[string]any{ + { + "name": "Schema V3 Fixture Org", + "corpId": corpID, + "corpName": "Schema V3 Fixture Org", + "clientId": "fixture-legacy-client", + "status": "active", + "expiresAt": "2099-01-02T03:04:05Z", + }, + { + "name": "Schema V3 Exact Fixture", + "corpId": corpID, + "corpName": "Schema V3 Fixture Org", + "userId": exactUserID, + "userName": "Exact Fixture Account", + "clientId": "fixture-legacy-client", + "status": "active", + "expiresAt": "2099-01-02T03:04:05Z", + }, + }, + } + data, err := json.MarshalIndent(document, "", " ") + if err != nil { + t.Fatalf("marshal raw coexisting v3 profiles fixture: %v", err) + } + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatalf("create raw v3 config directory: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "profiles.json"), append(data, '\n'), 0o600); err != nil { + t.Fatalf("write raw coexisting v3 profiles fixture: %v", err) + } +} + func legacyRawToken(t *testing.T, accessToken, corpID, corpName, userID, userName string) string { t.Helper() document := map[string]any{ @@ -344,6 +456,17 @@ func seedRawLegacyKeychainSlot(t *testing.T, account, rawToken string) { } } +func assertRawKeychainSlotUnchanged(t *testing.T, account, want string) { + t.Helper() + got, err := keychain.Get(keychain.Service, account) + if err != nil { + t.Fatalf("read raw keychain account %q: %v", account, err) + } + if got != want { + t.Fatalf("raw keychain account %q changed\nwant:\n%s\ngot:\n%s", account, want, got) + } +} + func runLegacyAuthStatus(t *testing.T, env []string, profile ...string) legacyAuthStatus { t.Helper() if len(profile) > 1 { From a5a6a0f2cedcd9d239e83f5eedfc450d67a79014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E5=AE=98=E7=8E=84?= Date: Thu, 23 Jul 2026 13:01:10 +0800 Subject: [PATCH 3/6] test(auth): close legacy compatibility coverage gaps --- .../app/event_profile_selector_compat_test.go | 21 ++ internal/auth/access_token_snapshot_test.go | 3 +- .../auth_legacy_platform_coverage_test.go | 297 ++++++++++++++++++ internal/auth/oauth_provider_coverage_test.go | 4 + 4 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 internal/auth/auth_legacy_platform_coverage_test.go diff --git a/internal/app/event_profile_selector_compat_test.go b/internal/app/event_profile_selector_compat_test.go index 267af36d..5a99c882 100644 --- a/internal/app/event_profile_selector_compat_test.go +++ b/internal/app/event_profile_selector_compat_test.go @@ -72,6 +72,27 @@ func TestPersonalBusProfileSelectorPrefersExplicitRuntimeSelector(t *testing.T) } } +func TestCrossPlatformCoveragePersonalBusProfileSelectorFallsBackToMatchingIdentity(t *testing.T) { + configDir, cfg, _, exactSelector := seedPersonalBusProfileSelectorConfig(t) + cfg.Profiles = append(cfg.Profiles, authpkg.Profile{ + Name: "Other Current", + CorpID: "corp_event_other_fixture", + CorpName: "Other Fixture Organization", + UserID: "identity_event_other_fixture", + }) + cfg.CurrentProfile = authpkg.ProfileSelectionSelector(cfg.Profiles[2], cfg) + if err := authpkg.SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + authpkg.SetRuntimeProfile("") + t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) + + identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID} + if got := personalBusProfileSelector(configDir, identity); got != exactSelector { + t.Fatalf("personalBusProfileSelector() = %q, want identity fallback %q", got, exactSelector) + } +} + func seedPersonalBusProfileSelectorConfig(t *testing.T) (string, *authpkg.ProfilesConfig, string, string) { t.Helper() configDir := t.TempDir() diff --git a/internal/auth/access_token_snapshot_test.go b/internal/auth/access_token_snapshot_test.go index 6595da74..00655b26 100644 --- a/internal/auth/access_token_snapshot_test.go +++ b/internal/auth/access_token_snapshot_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "log/slog" "net/http" "net/url" "testing" @@ -94,7 +95,7 @@ func TestCrossPlatformCoverageOAuthProviderLoginReauthorizesAfterLoadFailureAndR return nil } - provider := NewOAuthProvider(t.TempDir(), nil) + provider := NewOAuthProvider(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil))) provider.Output = io.Discard _, err := provider.Login(context.Background(), false) if !errors.Is(err, targetErr) { diff --git a/internal/auth/auth_legacy_platform_coverage_test.go b/internal/auth/auth_legacy_platform_coverage_test.go new file mode 100644 index 00000000..f23beee0 --- /dev/null +++ b/internal/auth/auth_legacy_platform_coverage_test.go @@ -0,0 +1,297 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "errors" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" +) + +// The native coverage jobs intentionally execute only TestCrossPlatformCoverage +// entry points. Keep the compatibility assertions below as independently named +// regression tests for the stable make target, and exercise the same functions +// here as isolated subtests so their cleanup hooks run between cases. +func TestCrossPlatformCoverageAuthLegacyCompatibilityRegressions(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + tests := []struct { + name string + run func(*testing.T) + }{ + {"prepare unique global owner", TestPrepareLoginPersistenceRepairsOnlySafeGlobalOwner}, + {"token load isolation matrix", TestTokenLoadIsolationMatrix}, + {"reject future profiles before remote work", TestPersistingLoginFlowsRejectFutureProfilesBeforeRemoteWork}, + {"fresh UID-less isolation", TestFreshUIDLessExactLoginCannotOverwriteExistingUnresolvedProfile}, + {"reject mismatched exact switch", TestSetCurrentProfileRejectsMismatchedExactIdentitySlotBesideBlankProfile}, + {"reject unreadable previous identity", TestUsePreviousProfileRejectsUnreadableExactIdentityBesideBlankProfile}, + {"reauthorization guidance without profile", TestLegacyRefreshReauthorizationGuidanceWithoutProfileStillExplainsLogin}, + {"repair v3 unresolved profile", TestPrepareLoginPersistenceV3UnresolvedProfileRepair}, + {"device ignores unrelated unreadable profile", TestDeviceLoginIgnoresUnreadableUnrelatedProfile}, + {"reject unreadable global before login", TestPrepareLoginPersistenceUnreadableGlobalFailsClosedBeforeRemote}, + {"device validates resolved target", TestDeviceFlowChecksResolvedTargetBeforeSave}, + {"accept recoverable credential material", TestPrepareLoginPersistenceRequiresCredentialMaterialButNotValidity}, + {"auth code validates resolved target", TestExchangeAuthCodeChecksResolvedTargetBeforeSave}, + {"standalone exchange prepares and marks fresh", TestExchangeCodeForTokenPreparesBeforeRemoteAndMarksFresh}, + } + for _, test := range tests { + t.Run(test.name, test.run) + } +} + +func TestCrossPlatformCoverageHalfMigratedGlobalRepairRemainingEdges(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + + t.Run("nil global token", func(t *testing.T) { + isolateHalfMigratedRepairHooks(t) + profilesLoadLegacy = func() (*TokenData, error) { return nil, nil } + cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: "corp_nil_global"}}} + if err := repairHalfMigratedGlobalTokenLocked(cfg); err != nil { + t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v", err) + } + }) + + t.Run("global token without organization", func(t *testing.T) { + isolateHalfMigratedRepairHooks(t) + profilesLoadLegacy = func() (*TokenData, error) { + return &TokenData{AccessToken: "legacy"}, nil + } + cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: "corp_other"}}} + if err := repairHalfMigratedGlobalTokenLocked(cfg); err != nil { + t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v", err) + } + }) + + t.Run("orphan global organization", func(t *testing.T) { + isolateHalfMigratedRepairHooks(t) + profilesLoadLegacy = func() (*TokenData, error) { + return &TokenData{AccessToken: "legacy", CorpID: "corp_orphan"}, nil + } + cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: "corp_other"}}} + if err := repairHalfMigratedGlobalTokenLocked(cfg); err != nil { + t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v", err) + } + }) + + t.Run("identity repair write failure", func(t *testing.T) { + isolateHalfMigratedRepairHooks(t) + failure := errors.New("identity repair write failure") + const corpID, userID = "corp_identity_repair", "user_identity_repair" + profilesLoadLegacy = func() (*TokenData, error) { + return &TokenData{AccessToken: "legacy", CorpID: corpID}, nil + } + profilesLoadCorp = func(string) (*TokenData, error) { + return &TokenData{AccessToken: "organization", CorpID: corpID}, nil + } + profilesSaveIdentity = func(string, string, *TokenData) error { return failure } + cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: corpID, UserID: userID}}} + if err := repairHalfMigratedGlobalTokenLocked(cfg); !errors.Is(err, failure) { + t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v, want %v", err, failure) + } + }) + + t.Run("organization repair write failure", func(t *testing.T) { + isolateHalfMigratedRepairHooks(t) + failure := errors.New("organization repair write failure") + const corpID = "corp_organization_repair" + profilesLoadLegacy = func() (*TokenData, error) { + return &TokenData{AccessToken: "legacy", CorpID: corpID}, nil + } + profilesSaveCorp = func(string, *TokenData) error { return failure } + cfg := &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{CorpID: corpID}}} + if err := repairHalfMigratedGlobalTokenLocked(cfg); !errors.Is(err, failure) { + t.Fatalf("repairHalfMigratedGlobalTokenLocked() error = %v, want %v", err, failure) + } + }) + + t.Run("nil profile is not canonical", func(t *testing.T) { + if loginProfileHasUsableCanonicalToken(nil, nil, nil) { + t.Fatal("nil profile was treated as a usable canonical token") + } + }) + + t.Run("global write preflight reports unreadable slot", func(t *testing.T) { + oldGet := authKeychainGet + failure := errors.New("global ciphertext is unreadable") + authKeychainGet = func(_, account string) (string, error) { + if account == keychain.AccountToken { + return "", failure + } + return "", nil + } + t.Cleanup(func() { authKeychainGet = oldGet }) + + err := preflightTokenWritePersistence(t.TempDir(), &TokenData{AccessToken: "fresh"}) + if !errors.Is(err, failure) { + t.Fatalf("preflightTokenWritePersistence() error = %v, want %v", err, failure) + } + }) +} + +func TestCrossPlatformCoverageLegacyProfileGuardRemainingEdges(t *testing.T) { + t.Run("empty v3 registry normalizes and persists", func(t *testing.T) { + oldLoad := profilesLoad + oldSave := profilesSave + cfg := &ProfilesConfig{Version: profilesUnresolvedSelectorVersion} + profilesLoad = func(string) (*ProfilesConfig, error) { return cfg, nil } + saves := 0 + profilesSave = func(string, *ProfilesConfig) error { + saves++ + return nil + } + t.Cleanup(func() { + profilesLoad = oldLoad + profilesSave = oldSave + }) + + if err := ensureProfilesMigrationLocked(t.TempDir()); err != nil { + t.Fatalf("ensureProfilesMigrationLocked() error = %v", err) + } + if cfg.Version != profilesVersion || saves != 1 { + t.Fatalf("normalized registry = version %d saves %d", cfg.Version, saves) + } + }) + + if normalizeProfilesVersionForSelectors(nil) { + t.Fatal("nil profile registry reported a version change") + } + future := &ProfilesConfig{Version: profilesMaxVersion + 1} + if normalizeProfilesVersionForSelectors(future) { + t.Fatal("future profile registry reported a version change") + } + if profilesConfigContainsUnresolvedSelector(nil) { + t.Fatal("nil profile registry contained an unresolved selector") + } + reserved := unresolvedProfileSelector("corp_org_current_guard") + if !profilesConfigContainsUnresolvedSelector(&ProfilesConfig{ + OrgCurrentProfiles: map[string]string{"corp_org_current_guard": reserved}, + }) { + t.Fatal("reserved organization-current selector was not detected") + } + if selectorConflictsWithOrganizationGrammar(nil, "corp") { + t.Fatal("nil profile registry reported an organization-selector conflict") + } + if err := validateIdentityOnlyProfileToken(Profile{}); !errors.Is(err, ErrTokenDataNotFound) { + t.Fatalf("validateIdentityOnlyProfileToken(blank) error = %v", err) + } + + oldLoadIdentity := profilesLoadIdentity + profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, nil } + t.Cleanup(func() { profilesLoadIdentity = oldLoadIdentity }) + if err := validateIdentityOnlyProfileToken(Profile{CorpID: "corp_nil_identity", UserID: "user_nil_identity"}); !errors.Is(err, ErrTokenDataNotFound) { + t.Fatalf("validateIdentityOnlyProfileToken(nil token) error = %v", err) + } +} + +func TestCrossPlatformCoverageTokenPersistenceRemainingEdges(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + + plan := planTokenPersistenceWrites(&ProfilesConfig{}, nil, "") + if !plan.WriteGlobal || plan.CorpID != "" { + t.Fatalf("nil-token write plan = %#v", plan) + } + if err := SaveLoginTokenData(t.TempDir(), nil); err == nil { + t.Fatal("SaveLoginTokenData(nil) succeeded") + } + + futureDir := t.TempDir() + writeFutureProfilesForLoginPreflight(t, futureDir) + err := SaveLoginTokenData(futureDir, &TokenData{AccessToken: "fresh"}) + if err == nil || !strings.Contains(err.Error(), "newer than supported") { + t.Fatalf("SaveLoginTokenData(future schema) error = %v", err) + } + + t.Run("nil identity token", func(t *testing.T) { + isolateTokenProfileLoadHooks(t) + tokenLoadKeychainIdentity = func(string, string) (*TokenData, error) { return nil, nil } + _, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_nil_identity", UserID: "user_nil_identity"}) + if !errors.Is(err, ErrTokenDataNotFound) { + t.Fatalf("tokenLoadProfileIdentity() error = %v", err) + } + }) + + t.Run("nil organization fallback", func(t *testing.T) { + isolateTokenProfileLoadHooks(t) + tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { return nil, nil } + _, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_nil_org", UserID: "user_nil_org"}) + if !errors.Is(err, ErrTokenDataNotFound) { + t.Fatalf("tokenLoadProfileIdentity() error = %v", err) + } + }) + + t.Run("organization fallback belongs to another organization", func(t *testing.T) { + isolateTokenProfileLoadHooks(t) + tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { + return &TokenData{AccessToken: "wrong", CorpID: "corp_other", UserID: "user_wrong_org"}, nil + } + _, err := tokenLoadProfileIdentity(Profile{CorpID: "corp_expected", UserID: "user_wrong_org"}) + if err == nil || !strings.Contains(err.Error(), "contains token for corpId") { + t.Fatalf("tokenLoadProfileIdentity() error = %v", err) + } + }) + + t.Run("identity repair write failure", func(t *testing.T) { + isolateTokenProfileLoadHooks(t) + failure := errors.New("identity repair write failure") + const corpID, userID = "corp_identity_save", "user_identity_save" + tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { + return &TokenData{AccessToken: "organization", CorpID: corpID, UserID: userID}, nil + } + tokenSaveKeychainForIdentity = func(string, string, *TokenData) error { return failure } + _, err := tokenLoadProfileIdentity(Profile{CorpID: corpID, UserID: userID}) + if !errors.Is(err, failure) { + t.Fatalf("tokenLoadProfileIdentity() error = %v, want %v", err, failure) + } + }) +} + +func isolateHalfMigratedRepairHooks(t *testing.T) { + t.Helper() + oldLoadLegacy := profilesLoadLegacy + oldLoadCorp := profilesLoadCorp + oldLoadIdentity := profilesLoadIdentity + oldSaveCorp := profilesSaveCorp + oldSaveIdentity := profilesSaveIdentity + t.Cleanup(func() { + profilesLoadLegacy = oldLoadLegacy + profilesLoadCorp = oldLoadCorp + profilesLoadIdentity = oldLoadIdentity + profilesSaveCorp = oldSaveCorp + profilesSaveIdentity = oldSaveIdentity + }) + profilesLoadLegacy = func() (*TokenData, error) { return nil, ErrTokenDataNotFound } + profilesLoadCorp = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound } + profilesLoadIdentity = func(string, string) (*TokenData, error) { return nil, ErrTokenDataNotFound } + profilesSaveCorp = func(string, *TokenData) error { return nil } + profilesSaveIdentity = func(string, string, *TokenData) error { return nil } +} + +func isolateTokenProfileLoadHooks(t *testing.T) { + t.Helper() + oldLoadIdentity := tokenLoadKeychainIdentity + oldLoadCorp := tokenLoadKeychainForCorpID + oldSaveIdentity := tokenSaveKeychainForIdentity + t.Cleanup(func() { + tokenLoadKeychainIdentity = oldLoadIdentity + tokenLoadKeychainForCorpID = oldLoadCorp + tokenSaveKeychainForIdentity = oldSaveIdentity + }) + tokenLoadKeychainIdentity = func(string, string) (*TokenData, error) { return nil, ErrTokenDataNotFound } + tokenLoadKeychainForCorpID = func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound } + tokenSaveKeychainForIdentity = func(string, string, *TokenData) error { return nil } +} diff --git a/internal/auth/oauth_provider_coverage_test.go b/internal/auth/oauth_provider_coverage_test.go index 8448affc..43213ab4 100644 --- a/internal/auth/oauth_provider_coverage_test.go +++ b/internal/auth/oauth_provider_coverage_test.go @@ -1089,6 +1089,10 @@ func TestCrossPlatformCoverageOAuthHelperRemainingEdges(t *testing.T) { if _, err := p.exchangeCode(context.Background(), "code"); !errors.Is(err, fail) { t.Fatalf("direct exchange request error = %v", err) } + oauthHTTPClient = networkClient + if _, err := ExchangeCodeForToken(context.Background(), p.configDir, "code"); !errors.Is(err, fail) { + t.Fatalf("exchange wrapper request error = %v", err) + } p.httpClient = responseClient("{") if _, err := p.exchangeCode(context.Background(), "code"); err == nil { t.Fatal("malformed direct exchange succeeded") From 0d175c4d530822bf58d7b476d4629334e561d741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E5=AE=98=E7=8E=84?= Date: Thu, 23 Jul 2026 13:20:43 +0800 Subject: [PATCH 4/6] test(auth): isolate Windows credential fixtures --- internal/app/auth_command_full_coverage_test.go | 12 +++++++++++- internal/app/auth_command_test.go | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/app/auth_command_full_coverage_test.go b/internal/app/auth_command_full_coverage_test.go index 5fd5b26d..b572a375 100644 --- a/internal/app/auth_command_full_coverage_test.go +++ b/internal/app/auth_command_full_coverage_test.go @@ -750,9 +750,19 @@ func TestCrossPlatformCoverageOAuthAndDeviceKeepFreshUnknownIdentityIsolatedFrom for _, flow := range []string{"oauth", "device"} { t.Run(flow, func(t *testing.T) { configDir := t.TempDir() + keychainDir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", configDir) t.Setenv(keychain.DisableKeychainEnv, "1") - t.Setenv(keychain.StorageDirEnv, t.TempDir()) + t.Setenv(keychain.StorageDirEnv, keychainDir) + // StorageDirEnv isolates file-backed keychains, while Windows uses + // DPAPI-protected HKCU values. Give every flow its own namespace so + // OAuth/device fixtures cannot leak into each other or later tests. + t.Setenv(keychain.TestNamespaceEnv, keychainDir) + t.Cleanup(func() { + if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil { + t.Errorf("clean auth keychain fixture: %v", err) + } + }) authpkg.SetRuntimeProfile("") const ( diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index a5aef4e2..486cd98a 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -195,6 +195,15 @@ func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T) } t.Setenv("DWS_CONFIG_DIR", configDir) t.Setenv(keychain.StorageDirEnv, keychainDir) + // Windows stores credentials in HKCU rather than StorageDirEnv. Use a + // fresh registry namespace so this zero-state assertion cannot inherit a + // token from an earlier test in the same package binary. + t.Setenv(keychain.TestNamespaceEnv, root) + t.Cleanup(func() { + if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil { + t.Errorf("clean import guard keychain fixture: %v", err) + } + }) importCmd := NewRootCommand() importCmd.SetOut(&bytes.Buffer{}) From 71f90ee45f72b82a56a8f07c48e1846183348fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E5=AE=98=E7=8E=84?= Date: Thu, 23 Jul 2026 13:31:23 +0800 Subject: [PATCH 5/6] test(keychain): cover Windows registry failures --- internal/keychain/auth_entries_windows.go | 6 +- internal/keychain/keychain_windows.go | 4 +- .../keychain_windows_validation_test.go | 59 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/internal/keychain/auth_entries_windows.go b/internal/keychain/auth_entries_windows.go index ad0b26d3..77e5c2c8 100644 --- a/internal/keychain/auth_entries_windows.go +++ b/internal/keychain/auth_entries_windows.go @@ -24,12 +24,14 @@ import ( "golang.org/x/sys/windows/registry" ) +var registryReadValueNames = registry.Key.ReadValueNames + // Windows stores token entries in the DPAPI-protected user registry. Enumerate // every auth-token account so orphaned slots that are not present in // profiles.json are still validated before a login can overwrite credentials. func platformValidateAuthTokenEntries(service string) error { keyPath := registryPathForService(service) - k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE) + k, err := registryOpenReadKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE) if err != nil { if errors.Is(err, registry.ErrNotExist) { return nil @@ -38,7 +40,7 @@ func platformValidateAuthTokenEntries(service string) error { } defer k.Close() - names, err := k.ReadValueNames(-1) + names, err := registryReadValueNames(k, -1) if err != nil { return fmt.Errorf("registry list values for auth token validation failed: %w", err) } diff --git a/internal/keychain/keychain_windows.go b/internal/keychain/keychain_windows.go index 48de3d73..4ec7f47f 100644 --- a/internal/keychain/keychain_windows.go +++ b/internal/keychain/keychain_windows.go @@ -38,6 +38,8 @@ var registryOpenDeleteKey = func(path string, access uint32) (registry.Key, erro return registry.OpenKey(registry.CURRENT_USER, path, access) } +var registryOpenReadKey = registry.OpenKey + // --------------------------------------------------------------------------- // Windows backend: DPAPI + HKCU registry // --------------------------------------------------------------------------- @@ -184,7 +186,7 @@ func platformRemove(service, account string) error { func registryGet(service, account string) (string, bool, error) { keyPath := registryPathForService(service) - k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE) + k, err := registryOpenReadKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE) if err != nil { if errors.Is(err, registry.ErrNotExist) { return "", false, nil diff --git a/internal/keychain/keychain_windows_validation_test.go b/internal/keychain/keychain_windows_validation_test.go index ffb90c6b..92476e27 100644 --- a/internal/keychain/keychain_windows_validation_test.go +++ b/internal/keychain/keychain_windows_validation_test.go @@ -17,9 +17,11 @@ package keychain import ( "encoding/base64" + "errors" "strings" "testing" + "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" ) @@ -133,3 +135,60 @@ func TestCrossPlatformCoverageWindowsAuthTokenInventoryValidatesOrphanSlots(t *t t.Fatalf("round trip = %q, %v; want legacy token", got, err) } } + +func TestCrossPlatformCoverageWindowsRegistryReadFailuresFailClosed(t *testing.T) { + t.Run("open failure", func(t *testing.T) { + originalOpen := registryOpenReadKey + failure := windows.ERROR_ACCESS_DENIED + registryOpenReadKey = func(registry.Key, string, uint32) (registry.Key, error) { + return 0, failure + } + t.Cleanup(func() { registryOpenReadKey = originalOpen }) + + if _, err := Get("unreadable-service", AccountToken); !errors.Is(err, failure) { + t.Fatalf("Get() error = %v, want %v", err, failure) + } + if err := ValidateAuthTokenEntries("unreadable-service"); !errors.Is(err, failure) { + t.Fatalf("ValidateAuthTokenEntries() error = %v, want %v", err, failure) + } + }) + + t.Run("inventory enumeration failure", func(t *testing.T) { + service := "test-service-" + t.Name() + account := AccountToken + ":enumeration-failure" + writeRawRegistryString(t, service, account, "fixture") + t.Cleanup(func() { _ = Remove(service, account) }) + + originalReadNames := registryReadValueNames + failure := windows.ERROR_ACCESS_DENIED + registryReadValueNames = func(registry.Key, int) ([]string, error) { + return nil, failure + } + t.Cleanup(func() { registryReadValueNames = originalReadNames }) + + if err := ValidateAuthTokenEntries(service); !errors.Is(err, failure) { + t.Fatalf("ValidateAuthTokenEntries() error = %v, want %v", err, failure) + } + }) + + t.Run("inventory value disappears", func(t *testing.T) { + service := "test-service-" + t.Name() + account := AccountToken + ":disappearing" + unrelated := "app-secret:keeps-key-present" + writeRawRegistryString(t, service, unrelated, "fixture") + t.Cleanup(func() { _ = Remove(service, unrelated) }) + + originalReadNames := registryReadValueNames + registryReadValueNames = func(registry.Key, int) ([]string, error) { + // Model the real race after enumeration: the account name was + // observed, but GetStringValue no longer finds its value. + return []string{valueNameForAccount(account)}, nil + } + t.Cleanup(func() { registryReadValueNames = originalReadNames }) + + err := ValidateAuthTokenEntries(service) + if err == nil || !strings.Contains(err.Error(), "value disappeared during validation") { + t.Fatalf("ValidateAuthTokenEntries() error = %v, want disappearance error", err) + } + }) +} From 228c62bc0f1146480449479a8a8875f587d0f0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E5=AE=98=E7=8E=84?= Date: Thu, 23 Jul 2026 16:35:52 +0800 Subject: [PATCH 6/6] docs(changelog): note legacy auth compatibility --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d450d1..395c4640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Fixed + +- **Legacy authentication compatibility** (#756) — migrates pre-v1.0.53 global and organization-scoped login state into the identity-aware token store, including all legacy organizations, while keeping unresolved accounts isolated from exact `corpId:userId` credentials so external or no-directory identities can complete login without borrowing another user's token. + ## [1.0.55-beta.1] - 2026-07-23 This beta validates MCP Market URL resolution, the supported Wukong local-file