Merge pull request #756 from shangguanxuan633-lab/codex/auth-legacy-token-compat

fix(auth): migrate legacy tokens and preserve unresolved accounts
This commit is contained in:
修雨
2026-07-23 17:35:47 +08:00
committed by GitHub
40 changed files with 9661 additions and 331 deletions
+4
View File
@@ -10,6 +10,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **Faster guarded releases** — trusts an independently revalidated, exact `CHANGELOG.md`-only successor of an already admitted `main` commit, runs cloud planning alongside governance, and executes sealed-release automation, compatibility, and multi-profile validation in parallel with artifact compilation. Normal cloud publication no longer requires an unshareable local packaging preflight.
### 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
+7 -2
View File
@@ -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
+342 -44
View File
@@ -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
@@ -287,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)
@@ -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,144 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
return nil
}
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
// 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
}
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
if 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_display_only",
"corp_id", corpID,
"user_id", updated.UserID,
"user_name", updated.UserName,
"corp_name", updated.CorpName,
"identity_proven", false,
)
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 +1641,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 +1668,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 {
+596 -7
View File
@@ -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,570 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
}
}
func TestCrossPlatformCoverageContactFailureReusesOnlySameCorpHistoricalDisplayMetadata(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 != "" || 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)
}
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 != "" {
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 TestCrossPlatformCoverageOAuthAndDeviceKeepFreshUnknownIdentityIsolatedFromExactHistory(t *testing.T) {
oldResolve := authResolveProfile
oldLoad := authLoadProfiles
oldDevice := authDeviceLogin
oldOAuth := authOAuthLogin
oldInteractive := authLoginInteractiveTerminal
t.Cleanup(func() {
authResolveProfile = oldResolve
authLoadProfiles = oldLoad
authDeviceLogin = oldDevice
authOAuthLogin = oldOAuth
authLoginInteractiveTerminal = oldInteractive
})
authResolveProfile = authpkg.ResolveProfile
authLoadProfiles = authpkg.LoadProfiles
authLoginInteractiveTerminal = func() bool { return false }
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
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, 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 (
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)
}
})
}
}
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 != "" ||
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"}
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 != "" ||
soleData.CorpName != "Same Corp" || soleData.UserName != "Second" {
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 +1337,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")
}
+9
View File
@@ -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{})
@@ -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)
}
}
@@ -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)
}
}
@@ -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])
}
}
+46 -7
View File
@@ -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,44 @@ 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 {
// 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) &&
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 +916,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)
@@ -0,0 +1,121 @@
// 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 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()
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
}
+99
View File
@@ -17,7 +17,9 @@ 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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
// A bounded run never arms the stdin-EOF watcher, regardless of stdin
@@ -63,3 +65,100 @@ 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{
"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",
},
},
}
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)
}
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 := blankSelector
if selector != want || selector == identity.CorpID {
t.Fatalf("personalBusProfileSelector(blank) = %q, want reserved %q", selector, want)
}
args := personalBusSpawnArgs(identity, "", "", selector)
forwardedSelector := ""
for i := 0; i+1 < len(args); i++ {
if args[i] == "--profile" && args[i+1] == want {
forwardedSelector = args[i+1]
break
}
}
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)
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)
}
}
+39
View File
@@ -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")
+1 -1
View File
@@ -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
+52 -11
View File
@@ -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)
}
+11 -1
View File
@@ -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)
+89 -8
View File
@@ -3,6 +3,10 @@ package auth
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"testing"
"time"
)
@@ -22,15 +26,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(), slog.New(slog.NewTextHandler(io.Discard, 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)
}
}
@@ -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 }
}
@@ -0,0 +1,357 @@
// 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"
"strings"
"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)
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,
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,
},
},
}
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)
}
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: blankSelector,
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)
}
}
+8 -2
View File
@@ -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)
}
}
+4 -1
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
+207 -27
View File
@@ -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,33 +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)
}
}
checkOrganizationMirror := true
if _, _, exact := ParseIdentitySelector(RuntimeProfile()); exact {
checkOrganizationMirror =
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) ==
profileSelector(corpID, userID)
if plan.CorpID == "" {
return nil
}
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)
@@ -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"])
}
}
@@ -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)
}
File diff suppressed because it is too large Load Diff
+28 -7
View File
@@ -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.
@@ -290,6 +293,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 +334,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))
+48 -9
View File
@@ -24,6 +24,7 @@ import (
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
@@ -117,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.
@@ -150,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)
}
@@ -662,6 +666,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,19 +683,40 @@ 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() {
return nil, fmt.Errorf(
"%s: %w",
legacyRefreshReauthorizationGuidance(profileSelector),
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)
}
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.
@@ -763,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)
@@ -802,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
}
@@ -812,14 +844,18 @@ 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)
}
}
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
}
@@ -830,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)
}
+74 -1
View File
@@ -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, "<html") {
t.Fatalf("OAuth callback body = %q, %v", callback.body, callback.err)
}
case <-time.After(oauthTestWaitTimeout):
t.Fatal("timed out waiting for OAuth callback")
}
result := awaitOAuthLogin(t, loginDone)
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
t.Fatalf("OAuthProvider.Login() = %#v, %v", result.token, result.err)
}
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)
}
}
func TestCrossPlatformCoverageOAuthLoginMissingCallbackCode(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
@@ -685,7 +751,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)
}
@@ -1020,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")
@@ -0,0 +1,62 @@
// 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 (
"strconv"
"strings"
"testing"
)
func TestLegacyRefreshReauthorizationGuidanceTreatsProfileAsDisplayData(t *testing.T) {
tests := []struct {
name string
selector string
}{
{name: "command substitution", selector: `external-$(touch marker)`},
{name: "backticks", selector: "external-`touch marker`"},
{name: "newline", selector: "external\ndws auth reset"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
guidance := legacyRefreshReauthorizationGuidance(tt.selector)
if !strings.Contains(guidance, "dws auth login") ||
!strings.Contains(guidance, "--profile") ||
!strings.Contains(guidance, "profile 标识") {
t.Fatalf("guidance lacks stable reauthorization instructions: %q", guidance)
}
if strings.Contains(guidance, "dws auth login --profile") ||
strings.Contains(guidance, "--profile "+strconv.Quote(tt.selector)) {
t.Fatalf("guidance embeds untrusted selector in an executable command: %q", guidance)
}
if !strings.Contains(guidance, "profile: "+strconv.Quote(tt.selector)) {
t.Fatalf("guidance does not preserve selector as display data: %q", guidance)
}
if strings.Contains(tt.selector, "\n") && strings.Contains(guidance, tt.selector) {
t.Fatalf("guidance retained a raw selector newline: %q", guidance)
}
})
}
}
func TestLegacyRefreshReauthorizationGuidanceWithoutProfileStillExplainsLogin(t *testing.T) {
guidance := legacyRefreshReauthorizationGuidance("")
if !strings.Contains(guidance, "dws auth login") {
t.Fatalf("guidance = %q, want login instruction", guidance)
}
if strings.Contains(guidance, "--profile") || strings.Contains(guidance, "profile:") {
t.Fatalf("guidance = %q, should not invent an empty profile value", guidance)
}
}
+572 -68
View File
@@ -15,6 +15,7 @@ package auth
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -75,8 +76,11 @@ func withProfilesLock(configDir string, fn func() error) error {
}
const (
profilesJSONFile = "profiles.json"
profilesVersion = 2
profilesJSONFile = "profiles.json"
profilesVersion = 2
profilesUnresolvedSelectorVersion = 3
profilesMaxVersion = profilesUnresolvedSelectorVersion
unresolvedProfileSelectorPrefix = "@legacy/"
)
const (
@@ -169,6 +173,7 @@ func SaveProfiles(configDir string, cfg *ProfilesConfig) error {
return err
}
normalizeProfilesConfig(cfg)
normalizeProfilesVersionForSelectors(cfg)
if err := profilesMkdirAll(configDir, config.DirPerm); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
@@ -207,13 +212,16 @@ func ensureProfilesMigrationLocked(configDir string) error {
if err != nil {
return err
}
if cfg.Version > 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() {
@@ -240,6 +248,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 +266,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 +347,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 +398,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
@@ -345,12 +423,38 @@ func ensureProfilesMigrationLocked(configDir string) error {
cfg.Version = profilesVersion
changed = true
}
if normalizeProfilesVersionForSelectors(cfg) {
changed = true
}
if changed {
return profilesSave(configDir, cfg)
}
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)
@@ -384,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
@@ -392,7 +498,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 +519,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 +542,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 +579,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 +669,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 +845,13 @@ func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
return nil, err
}
originalCfg := cloneProfilesConfig(cfg)
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID)
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
}
@@ -617,20 +859,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 +917,13 @@ 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)
if !syncOrganization {
if err := validateIdentityOnlyProfileToken(*p); err != nil {
return nil, err
}
}
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization)
if err != nil {
return nil, err
}
@@ -677,19 +931,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 +993,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 +1023,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 +1037,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,7 +1053,35 @@ 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))
}
}
}
}
// 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 {
@@ -808,6 +1110,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
}
@@ -840,28 +1145,72 @@ 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
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 +1238,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 +1326,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 +1493,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 +1590,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 +1681,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 +1698,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 +1731,39 @@ 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 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 != "" {
return exact
}
if len(profiles) == 1 {
return ProfileSelector(*profiles[0])
return storedProfileSelector(cfg, profiles[0])
}
return ""
}
@@ -1360,7 +1771,21 @@ func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string {
if err != nil || p == nil {
return ""
}
return ProfileSelector(*p)
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) {
@@ -1445,6 +1870,79 @@ 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
}
// 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
@@ -1453,6 +1951,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
+100
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
@@ -152,4 +153,103 @@ 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") ||
!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)
}
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") ||
!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)
}
}
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(), "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 {
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)
}
}
}
}
+6
View File
@@ -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)
}
+313 -75
View File
@@ -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,96 @@ 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:"-"`
// 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.
@@ -219,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
@@ -235,35 +350,56 @@ 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 == ""
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
plan := planTokenPersistenceWrites(cfg, data, RuntimeProfile())
if err := validateTokenPersistenceWritePlan(cfg, data, plan); err != nil {
return err
}
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,
"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
@@ -273,32 +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)
}
} 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 {
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 {
@@ -313,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
}
@@ -428,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
@@ -458,35 +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) == "" {
return 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",
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 {
@@ -561,12 +788,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 +827,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 +836,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 &&
+88 -28
View File
@@ -21,6 +21,7 @@ import (
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -210,10 +211,48 @@ func TestExactNonOrgCurrentRefreshIgnoresUnreadableOrgMirror(t *testing.T) {
}
}
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(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 TestFullTokenPersistenceInventoryDetectsOrphanProfileCiphertext(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
setPreflightTestCredentials(t)
configDir := t.TempDir()
data := testToken("at_orphan", "corp_orphan", "Orphan Org")
@@ -230,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)
}
}
@@ -304,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) {
@@ -312,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)
@@ -346,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()
@@ -366,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)
@@ -418,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)
File diff suppressed because it is too large Load Diff
+47 -3
View File
@@ -15,9 +15,53 @@
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"
)
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 := registryOpenReadKey(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 := registryReadValueNames(k, -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
}
+26 -9
View File
@@ -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
// ---------------------------------------------------------------------------
@@ -159,7 +161,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 +184,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)
k, err := registryOpenReadKey(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 {
@@ -0,0 +1,194 @@
// 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"
"errors"
"strings"
"testing"
"golang.org/x/sys/windows"
"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)
}
}
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)
}
})
}
+90
View File
@@ -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"
+574
View File
@@ -0,0 +1,574 @@
package smoke_test
import (
"crypto/sha256"
"encoding/base64"
"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])
}
})
}
// 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
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 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{
"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 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 {
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)))
}