fix(auth): preserve legacy login state across token backends
This commit is contained in:
1 parent
412e77f215
commit
ccb69f93b8
23 files changed
+5172
-205
No files matched your search
+337
-43
@@ -40,12 +40,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type authLoginConfig struct {
|
type authLoginConfig struct {
|
||||||
Token string
|
Token string
|
||||||
Force bool
|
Force bool
|
||||||
Device bool
|
Device bool
|
||||||
Recommend bool
|
Recommend bool
|
||||||
Yes bool
|
Yes bool
|
||||||
TargetCorpID string
|
TargetCorpID string
|
||||||
|
HistoryProfileSelector string
|
||||||
|
HistoryProfileSelectorExplicit bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type authLoginGuideAction string
|
type authLoginGuideAction string
|
||||||
@@ -148,7 +150,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
|||||||
provider.Output = cmd.ErrOrStderr()
|
provider.Output = cmd.ErrOrStderr()
|
||||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
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)
|
tokenData, err = authDeviceLogin(provider, loginCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -163,7 +168,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
|||||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||||
provider.TargetCorpID = cfg.TargetCorpID
|
provider.TargetCorpID = cfg.TargetCorpID
|
||||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
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)
|
configureOAuthProviderCompatibility(provider, configDir)
|
||||||
tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg))
|
tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg))
|
||||||
@@ -175,11 +183,23 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
|||||||
ResetRuntimeTokenCache()
|
ResetRuntimeTokenCache()
|
||||||
clearCompatCache()
|
clearCompatCache()
|
||||||
w := cmd.OutOrStdout()
|
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 {
|
runPostLoginAuthorization := func() error {
|
||||||
if !recommendAuthMode {
|
if !recommendAuthMode {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
restoreProfile := replaceRuntimeProfile(authpkg.TokenProfileSelector(tokenData))
|
restoreProfile := replaceRuntimeProfile(postLoginSelector)
|
||||||
defer restoreProfile()
|
defer restoreProfile()
|
||||||
recommendScopeMode := pat.LoginRecommendScopeRecommended
|
recommendScopeMode := pat.LoginRecommendScopeRecommended
|
||||||
var initialPlan *pat.LoginRecommendPlan
|
var initialPlan *pat.LoginRecommendPlan
|
||||||
@@ -494,7 +514,9 @@ func newAuthStatusCommand() *cobra.Command {
|
|||||||
if selected == nil {
|
if selected == nil {
|
||||||
return apperrors.NewValidation(fmt.Sprintf("profile %q not found", profileSelector))
|
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)
|
restoreProfile := pushRuntimeProfile(profileSelector)
|
||||||
defer restoreProfile()
|
defer restoreProfile()
|
||||||
@@ -522,7 +544,11 @@ func newAuthStatusCommand() *cobra.Command {
|
|||||||
_ = authDeleteTokenData(configDir)
|
_ = authDeleteTokenData(configDir)
|
||||||
} else if tokenData != nil {
|
} else if tokenData != nil {
|
||||||
refreshFailure = refreshErr
|
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) {
|
if refreshFailure == nil && authStatusAuthenticated(tokenData) {
|
||||||
@@ -652,7 +678,14 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector
|
|||||||
}
|
}
|
||||||
stableSelector := selected.CorpID
|
stableSelector := selected.CorpID
|
||||||
if exact {
|
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 {
|
if data, loadErr := authLoadTokenForProfile(configDir, stableSelector); loadErr == nil {
|
||||||
_ = authRevokeTokenForData(ctx, data)
|
_ = authRevokeTokenForData(ctx, data)
|
||||||
}
|
}
|
||||||
@@ -661,7 +694,7 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector
|
|||||||
if profile.CorpID != selected.CorpID {
|
if profile.CorpID != selected.CorpID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil {
|
if data, tokenErr := authLoadTokenForProfile(configDir, profileCLISelector(profile, cfg)); tokenErr == nil {
|
||||||
_ = authRevokeTokenForData(ctx, data)
|
_ = authRevokeTokenForData(ctx, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,7 +720,7 @@ func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string)
|
|||||||
_ = authRevokeToken(ctx)
|
_ = authRevokeToken(ctx)
|
||||||
} else {
|
} else {
|
||||||
for _, profile := range cfg.Profiles {
|
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)
|
_ = authRevokeTokenForData(ctx, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1206,7 +1239,7 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
|||||||
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
|
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
|
||||||
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
|
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
|
||||||
}
|
}
|
||||||
targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector)
|
targetCorpID, historyProfileSelector, historyProfileSelectorExplicit, err := resolveAuthLoginTarget(defaultConfigDir(), profileSelector)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return authLoginConfig{}, err
|
return authLoginConfig{}, err
|
||||||
}
|
}
|
||||||
@@ -1221,15 +1254,18 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
|||||||
"flow", flow,
|
"flow", flow,
|
||||||
"profile_selector", strings.TrimSpace(profileSelector),
|
"profile_selector", strings.TrimSpace(profileSelector),
|
||||||
"target_corp_id", targetCorpID,
|
"target_corp_id", targetCorpID,
|
||||||
|
"history_profile_selector", historyProfileSelector,
|
||||||
"recommend", recommend,
|
"recommend", recommend,
|
||||||
)
|
)
|
||||||
return authLoginConfig{
|
return authLoginConfig{
|
||||||
Token: strings.TrimSpace(token),
|
Token: strings.TrimSpace(token),
|
||||||
Force: force,
|
Force: force,
|
||||||
Device: device,
|
Device: device,
|
||||||
Recommend: recommend,
|
Recommend: recommend,
|
||||||
Yes: yes,
|
Yes: yes,
|
||||||
TargetCorpID: targetCorpID,
|
TargetCorpID: targetCorpID,
|
||||||
|
HistoryProfileSelector: historyProfileSelector,
|
||||||
|
HistoryProfileSelectorExplicit: historyProfileSelectorExplicit,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1238,17 +1274,57 @@ func authLoginForcesAuthorization(_ authLoginConfig) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
|
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)
|
selector = strings.TrimSpace(selector)
|
||||||
if 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 {
|
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") {
|
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 {
|
type contactProfileIdentity struct {
|
||||||
@@ -1258,12 +1334,23 @@ type contactProfileIdentity struct {
|
|||||||
UserName string
|
UserName string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type authLoginHistoryHint struct {
|
||||||
|
Selector string
|
||||||
|
Explicit bool
|
||||||
|
}
|
||||||
|
|
||||||
type tokenOverrideToolCaller interface {
|
type tokenOverrideToolCaller interface {
|
||||||
CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error)
|
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 {
|
func enrichAuthLoginProfileFromContact(
|
||||||
if caller == nil || data == nil {
|
ctx context.Context,
|
||||||
|
configDir string,
|
||||||
|
caller edition.ToolCaller,
|
||||||
|
data *authpkg.TokenData,
|
||||||
|
hints ...authLoginHistoryHint,
|
||||||
|
) error {
|
||||||
|
if data == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
corpID := strings.TrimSpace(data.CorpID)
|
corpID := strings.TrimSpace(data.CorpID)
|
||||||
@@ -1288,6 +1375,27 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
|||||||
)
|
)
|
||||||
return nil
|
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 (
|
var (
|
||||||
result *edition.ToolResult
|
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)
|
result, err = tokenCaller.CallToolWithToken(ctx, data.AccessToken, "contact", "get_current_user_profile", nil)
|
||||||
} else {
|
} else {
|
||||||
if strings.TrimSpace(data.UserID) == "" {
|
if strings.TrimSpace(data.UserID) == "" {
|
||||||
return fmt.Errorf("login identity lookup requires an in-memory token override")
|
tryHistory()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1311,11 +1419,15 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
|||||||
if strings.TrimSpace(data.UserID) != "" {
|
if strings.TrimSpace(data.UserID) != "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
tryHistory()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
identity, ok := contactProfileIdentityFromToolResult(result)
|
identity, ok := contactProfileIdentityFromToolResult(result, corpID)
|
||||||
if !ok {
|
if !ok {
|
||||||
logging.AuthDebug("auth.login.identity.lookup.empty", "corp_id", corpID)
|
logging.AuthDebug("auth.login.identity.lookup.empty", "corp_id", corpID)
|
||||||
|
if strings.TrimSpace(data.UserID) == "" {
|
||||||
|
tryHistory()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
logging.AuthDebug(
|
logging.AuthDebug(
|
||||||
@@ -1327,19 +1439,42 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
|||||||
"corp_name", strings.TrimSpace(identity.CorpName),
|
"corp_name", strings.TrimSpace(identity.CorpName),
|
||||||
)
|
)
|
||||||
if identity.CorpID != "" && identity.CorpID != corpID {
|
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
|
updated := *data
|
||||||
|
exchangeUserID := strings.TrimSpace(data.UserID)
|
||||||
if identity.CorpName != "" {
|
if identity.CorpName != "" {
|
||||||
updated.CorpName = identity.CorpName
|
updated.CorpName = identity.CorpName
|
||||||
}
|
}
|
||||||
if identity.UserID != "" {
|
if exchangeUserID == "" && identity.UserID != "" {
|
||||||
updated.UserID = identity.UserID
|
updated.UserID = identity.UserID
|
||||||
}
|
}
|
||||||
if identity.UserName != "" {
|
if identity.UserName != "" && (exchangeUserID == "" || identity.UserID == "" || identity.UserID == exchangeUserID) {
|
||||||
updated.UserName = identity.UserName
|
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 {
|
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
|
||||||
logging.AuthDebug(
|
logging.AuthDebug(
|
||||||
"auth.login.identity.resolved",
|
"auth.login.identity.resolved",
|
||||||
@@ -1361,7 +1496,140 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
|
// enrichAuthLoginProfileFromHistory recovers identity metadata when the
|
||||||
|
// contact service cannot describe an external-worker account. Explicit
|
||||||
|
// profile selection wins; otherwise only a sole same-corp profile is
|
||||||
|
// deterministic.
|
||||||
|
// Historical v1 profiles without userId are still useful: selecting one is a
|
||||||
|
// successful fallback that preserves org-scoped token storage semantics.
|
||||||
|
func enrichAuthLoginProfileFromHistory(configDir string, data *authpkg.TokenData, hints ...authLoginHistoryHint) (bool, error) {
|
||||||
|
if data == nil || strings.TrimSpace(data.UserID) != "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
corpID := strings.TrimSpace(data.CorpID)
|
||||||
|
if corpID == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
cfg, err := authLoadProfiles(configDir)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
sameCorp := make([]*authpkg.Profile, 0, len(cfg.Profiles))
|
||||||
|
for i := range cfg.Profiles {
|
||||||
|
profile := &cfg.Profiles[i]
|
||||||
|
if strings.TrimSpace(profile.CorpID) != corpID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sameCorp = append(sameCorp, profile)
|
||||||
|
}
|
||||||
|
if len(sameCorp) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hint := authLoginHistoryHint{}
|
||||||
|
if len(hints) > 0 {
|
||||||
|
hint = hints[0]
|
||||||
|
}
|
||||||
|
var candidate *authpkg.Profile
|
||||||
|
if hint.Explicit {
|
||||||
|
candidate = historicalProfileForSelector(corpID, hint.Selector, sameCorp)
|
||||||
|
if candidate == nil {
|
||||||
|
// An explicit account is a hard identity boundary. If that exact
|
||||||
|
// historical hint no longer matches the token's organization, do
|
||||||
|
// not silently substitute org-current, sole, or global-current.
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
} else if len(sameCorp) > 1 {
|
||||||
|
// Organization-current is a storage preference, not proof of which user
|
||||||
|
// completed a fresh authorization. With multiple accounts, only an exact
|
||||||
|
// user selection may be used when the token/contact response has no UID.
|
||||||
|
return false, nil
|
||||||
|
} else {
|
||||||
|
candidate = sameCorp[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := *data
|
||||||
|
updated.UserID = strings.TrimSpace(candidate.UserID)
|
||||||
|
if updated.UserID == "" && hint.Explicit {
|
||||||
|
updated.LegacyOrgScopedProfile = strings.TrimSpace(hint.Selector)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(updated.CorpName) == "" {
|
||||||
|
updated.CorpName = strings.TrimSpace(candidate.CorpName)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(updated.UserName) == "" {
|
||||||
|
updated.UserName = strings.TrimSpace(candidate.UserName)
|
||||||
|
}
|
||||||
|
*data = updated
|
||||||
|
logging.AuthDebug(
|
||||||
|
"auth.login.identity.resolved",
|
||||||
|
"source", "local_profile_history",
|
||||||
|
"corp_id", corpID,
|
||||||
|
"user_id", updated.UserID,
|
||||||
|
"user_name", updated.UserName,
|
||||||
|
"corp_name", updated.CorpName,
|
||||||
|
)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func historicalProfileForSelector(corpID, selector string, profiles []*authpkg.Profile) *authpkg.Profile {
|
||||||
|
selector = strings.TrimSpace(selector)
|
||||||
|
if selector == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg := &authpkg.ProfilesConfig{Profiles: make([]authpkg.Profile, 0, len(profiles))}
|
||||||
|
for _, profile := range profiles {
|
||||||
|
if profile != nil {
|
||||||
|
cfg.Profiles = append(cfg.Profiles, *profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var stableMatch *authpkg.Profile
|
||||||
|
for _, profile := range profiles {
|
||||||
|
if profile == nil || strings.TrimSpace(profile.CorpID) != strings.TrimSpace(corpID) ||
|
||||||
|
authpkg.ProfileSelectionSelector(*profile, cfg) != selector {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stableMatch != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stableMatch = profile
|
||||||
|
}
|
||||||
|
if stableMatch != nil {
|
||||||
|
return stableMatch
|
||||||
|
}
|
||||||
|
if selectedCorpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||||||
|
if strings.TrimSpace(selectedCorpID) != strings.TrimSpace(corpID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, profile := range profiles {
|
||||||
|
if profile != nil && strings.TrimSpace(profile.UserID) == strings.TrimSpace(userID) {
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var named *authpkg.Profile
|
||||||
|
for _, profile := range profiles {
|
||||||
|
if profile == nil || strings.TrimSpace(profile.Name) != selector {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if named != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
named = profile
|
||||||
|
}
|
||||||
|
if named != nil {
|
||||||
|
return named
|
||||||
|
}
|
||||||
|
if selector == strings.TrimSpace(corpID) && len(profiles) == 1 {
|
||||||
|
return profiles[0]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactProfileIdentityFromToolResult(result *edition.ToolResult, expectedCorpIDs ...string) (contactProfileIdentity, bool) {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return contactProfileIdentity{}, false
|
return contactProfileIdentity{}, false
|
||||||
}
|
}
|
||||||
@@ -1369,14 +1637,14 @@ func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactPr
|
|||||||
if strings.TrimSpace(block.Text) == "" {
|
if strings.TrimSpace(block.Text) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok {
|
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text), expectedCorpIDs...); ok {
|
||||||
return identity, true
|
return identity, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return contactProfileIdentity{}, false
|
return contactProfileIdentity{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) {
|
func contactProfileIdentityFromJSON(data []byte, expectedCorpIDs ...string) (contactProfileIdentity, bool) {
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Result []struct {
|
Result []struct {
|
||||||
OrgEmployeeModel struct {
|
OrgEmployeeModel struct {
|
||||||
@@ -1396,14 +1664,40 @@ func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool)
|
|||||||
if len(payload.Result) == 0 {
|
if len(payload.Result) == 0 {
|
||||||
return contactProfileIdentity{}, false
|
return contactProfileIdentity{}, false
|
||||||
}
|
}
|
||||||
org := payload.Result[0].OrgEmployeeModel
|
identities := make([]contactProfileIdentity, 0, len(payload.Result))
|
||||||
identity := contactProfileIdentity{
|
for i := range payload.Result {
|
||||||
CorpID: strings.TrimSpace(org.CorpID),
|
org := payload.Result[i].OrgEmployeeModel
|
||||||
CorpName: strings.TrimSpace(org.OrgName),
|
identity := contactProfileIdentity{
|
||||||
UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID),
|
CorpID: strings.TrimSpace(org.CorpID),
|
||||||
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
|
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 {
|
func firstNonEmptyString(values ...string) string {
|
||||||
|
|||||||
@@ -262,7 +262,10 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
|||||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true"}); err == nil {
|
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true"}); err == nil {
|
||||||
t.Fatal("device error should propagate")
|
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
|
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 {
|
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 {
|
if _, _, err := authCoverageRunLogin(t, nil, "table", true, nil); err == nil {
|
||||||
t.Fatal("oauth error should propagate")
|
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{
|
return &authpkg.TokenData{
|
||||||
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour), RefreshToken: "r", RefreshExpAt: time.Now().Add(48 * time.Hour),
|
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",
|
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 {
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, complete); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err == nil {
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err != nil {
|
||||||
t.Fatal("caller error should propagate")
|
t.Fatalf("contact failure must remain best effort: %v", err)
|
||||||
}
|
}
|
||||||
if err := enrichAuthLoginProfileFromContact(
|
if err := enrichAuthLoginProfileFromContact(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -374,8 +380,8 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
mismatch := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other"}}]}`}}}}
|
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 {
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); err != nil {
|
||||||
t.Fatal("corp mismatch should fail")
|
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"}}]}`}}}}
|
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 {
|
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" {
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, data); err != nil || data.CorpName != "Corp" || data.UserID != "u" {
|
||||||
t.Fatalf("enriched = %#v, %v", data, err)
|
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 {
|
if _, ok := contactProfileIdentityFromToolResult(nil); ok {
|
||||||
t.Fatal("nil result should not parse")
|
t.Fatal("nil result should not parse")
|
||||||
}
|
}
|
||||||
@@ -398,6 +423,504 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageContactFailureReusesUniqueSameCorpHistoricalIdentity(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
|
||||||
|
Version: 1,
|
||||||
|
Profiles: []authpkg.Profile{{
|
||||||
|
CorpID: "ding_ecological_worker",
|
||||||
|
CorpName: "Historical Corp",
|
||||||
|
UserID: "external-user",
|
||||||
|
UserName: "Historical Worker",
|
||||||
|
}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
caller edition.ToolCaller
|
||||||
|
wantCorp string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "contact business error",
|
||||||
|
caller: &authCoverageCaller{err: apperrors.NewAPI(
|
||||||
|
"business error: success=false",
|
||||||
|
apperrors.WithReason("business_error"),
|
||||||
|
)},
|
||||||
|
wantCorp: "Fresh Corp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "contact has no identity",
|
||||||
|
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"success":false}`}}}},
|
||||||
|
wantCorp: "Fresh Corp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "contact identity is missing user id",
|
||||||
|
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||||
|
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_ecological_worker","orgName":"Contact Corp"}}]}`,
|
||||||
|
}}}},
|
||||||
|
wantCorp: "Contact Corp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ordinary contact error",
|
||||||
|
caller: &authCoverageCaller{err: errors.New("network failure")},
|
||||||
|
wantCorp: "Fresh Corp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other contact business error",
|
||||||
|
caller: &authCoverageCaller{err: apperrors.NewAPI(
|
||||||
|
"permission denied",
|
||||||
|
apperrors.WithReason("business_error"),
|
||||||
|
)},
|
||||||
|
wantCorp: "Fresh Corp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "contact caller unavailable",
|
||||||
|
caller: nil,
|
||||||
|
wantCorp: "Fresh Corp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "contact returns another organization",
|
||||||
|
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||||
|
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_other","userid":"other-user"}}]}`,
|
||||||
|
}}}},
|
||||||
|
wantCorp: "Fresh Corp",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
data := &authpkg.TokenData{
|
||||||
|
AccessToken: "new-access",
|
||||||
|
RefreshToken: "new-refresh",
|
||||||
|
CorpID: "ding_ecological_worker",
|
||||||
|
CorpName: "Fresh Corp",
|
||||||
|
}
|
||||||
|
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, tc.caller, data); err != nil {
|
||||||
|
t.Fatalf("contact failure blocked historical identity recovery: %v", err)
|
||||||
|
}
|
||||||
|
if data.UserID != "external-user" || data.UserName != "Historical Worker" {
|
||||||
|
t.Fatalf("recovered identity = %#v", data)
|
||||||
|
}
|
||||||
|
if data.CorpName != tc.wantCorp {
|
||||||
|
t.Fatalf("corp name = %q, want %q", data.CorpName, tc.wantCorp)
|
||||||
|
}
|
||||||
|
if data.AccessToken != "new-access" || data.RefreshToken != "new-refresh" {
|
||||||
|
t.Fatalf("new token material was changed: %#v", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageContactFailureDoesNotGuessHistoricalIdentity(t *testing.T) {
|
||||||
|
businessErr := apperrors.NewAPI(
|
||||||
|
"business error: success=false",
|
||||||
|
apperrors.WithReason("business_error"),
|
||||||
|
)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
corpID string
|
||||||
|
profiles []authpkg.Profile
|
||||||
|
callErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "same corp has two identities",
|
||||||
|
corpID: "ding_ecological_worker",
|
||||||
|
profiles: []authpkg.Profile{
|
||||||
|
{CorpID: "ding_ecological_worker", UserID: "external-user"},
|
||||||
|
{CorpID: "ding_ecological_worker", UserID: "external-user-b"},
|
||||||
|
},
|
||||||
|
callErr: businessErr,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same corp has one identity and one blank profile",
|
||||||
|
corpID: "ding_ecological_worker",
|
||||||
|
profiles: []authpkg.Profile{
|
||||||
|
{CorpID: "ding_ecological_worker", UserID: "external-user"},
|
||||||
|
{CorpID: "ding_ecological_worker"},
|
||||||
|
},
|
||||||
|
callErr: businessErr,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "identity belongs to another corp",
|
||||||
|
corpID: "ding_ecological_worker",
|
||||||
|
profiles: []authpkg.Profile{
|
||||||
|
{CorpID: "ding_other", UserID: "external-user"},
|
||||||
|
},
|
||||||
|
callErr: businessErr,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no historical identity",
|
||||||
|
corpID: "ding_ecological_worker",
|
||||||
|
callErr: businessErr,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{Version: 2, Profiles: tc.profiles}); err != nil {
|
||||||
|
t.Fatalf("SaveProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
data := &authpkg.TokenData{AccessToken: "new-access", CorpID: tc.corpID}
|
||||||
|
err := enrichAuthLoginProfileFromContact(
|
||||||
|
context.Background(),
|
||||||
|
configDir,
|
||||||
|
&authCoverageCaller{err: tc.callErr},
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("contact failure must not block unresolved legacy login: %v", err)
|
||||||
|
}
|
||||||
|
if data.UserID != "" {
|
||||||
|
t.Fatalf("ambiguous/cross-corp identity was reused: %#v", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageContactHistoryFallbackEdges(t *testing.T) {
|
||||||
|
for _, data := range []*authpkg.TokenData{
|
||||||
|
nil,
|
||||||
|
{UserID: "known"},
|
||||||
|
{},
|
||||||
|
} {
|
||||||
|
reused, err := enrichAuthLoginProfileFromHistory(t.TempDir(), data)
|
||||||
|
if reused || err != nil {
|
||||||
|
t.Fatalf("ineligible history fallback = %v, %v", reused, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
|
||||||
|
Version: 2,
|
||||||
|
Profiles: []authpkg.Profile{{
|
||||||
|
CorpID: "ding_external",
|
||||||
|
CorpName: "Historical Corp",
|
||||||
|
UserID: "external-user",
|
||||||
|
UserName: "Historical Worker",
|
||||||
|
}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
data := &authpkg.TokenData{CorpID: "ding_external"}
|
||||||
|
reused, err := enrichAuthLoginProfileFromHistory(configDir, data)
|
||||||
|
if err != nil || !reused {
|
||||||
|
t.Fatalf("history fallback = %v, %v", reused, err)
|
||||||
|
}
|
||||||
|
if data.CorpName != "Historical Corp" || data.UserName != "Historical Worker" || data.UserID != "external-user" {
|
||||||
|
t.Fatalf("history metadata = %#v", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
corruptDir := t.TempDir()
|
||||||
|
if err := os.Mkdir(authpkg.ProfilesPath(corruptDir), 0o700); err != nil {
|
||||||
|
t.Fatalf("create unreadable profiles path: %v", err)
|
||||||
|
}
|
||||||
|
if reused, err := enrichAuthLoginProfileFromHistory(corruptDir, &authpkg.TokenData{CorpID: "ding_external"}); reused || err == nil {
|
||||||
|
t.Fatalf("corrupt history fallback = %v, %v; want load error", reused, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
businessErr := apperrors.NewAPI(
|
||||||
|
"business error: success=false",
|
||||||
|
apperrors.WithReason("business_error"),
|
||||||
|
)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
caller *authCoverageCaller
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "contact business error",
|
||||||
|
caller: &authCoverageCaller{err: businessErr},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "contact has no identity",
|
||||||
|
caller: &authCoverageCaller{result: &edition.ToolResult{}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "contact identity is missing user id",
|
||||||
|
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||||
|
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_external"}}]}`,
|
||||||
|
}}}},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := enrichAuthLoginProfileFromContact(
|
||||||
|
context.Background(),
|
||||||
|
corruptDir,
|
||||||
|
tc.caller,
|
||||||
|
&authpkg.TokenData{CorpID: "ding_external", AccessToken: "new-access"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("best-effort contact/history lookup blocked login: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageAuthLoginConfigPreservesHistoryIdentityHint(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
oldResolve := authResolveProfile
|
||||||
|
oldLoad := authLoadProfiles
|
||||||
|
t.Cleanup(func() {
|
||||||
|
authResolveProfile = oldResolve
|
||||||
|
authLoadProfiles = oldLoad
|
||||||
|
})
|
||||||
|
|
||||||
|
explicit := &authpkg.Profile{CorpID: "ding_same", UserID: "user_2", Name: "second"}
|
||||||
|
current := &authpkg.Profile{CorpID: "ding_current", UserID: "current_user"}
|
||||||
|
authResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) {
|
||||||
|
switch selector {
|
||||||
|
case "ding_same:user_2":
|
||||||
|
clone := *explicit
|
||||||
|
return &clone, nil
|
||||||
|
case "external-worker":
|
||||||
|
return &authpkg.Profile{Name: "external-worker", CorpID: "ding_external"}, nil
|
||||||
|
case "":
|
||||||
|
clone := *current
|
||||||
|
return &clone, nil
|
||||||
|
default:
|
||||||
|
return nil, errors.New("missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||||
|
return &authpkg.ProfilesConfig{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := newAuthLoginCommand(nil)
|
||||||
|
root, _, _ := authCoverageRoot(cmd, "table", true)
|
||||||
|
if err := root.PersistentFlags().Set("profile", "ding_same:user_2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := resolveAuthLoginConfig(cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.TargetCorpID != "ding_same" || cfg.HistoryProfileSelector != "ding_same:user_2" || !cfg.HistoryProfileSelectorExplicit {
|
||||||
|
t.Fatalf("explicit login config = %#v", cfg)
|
||||||
|
}
|
||||||
|
if target, hint, exact, err := resolveAuthLoginTarget("cfg", "external-worker"); err != nil ||
|
||||||
|
target != "ding_external" || hint != "ding_external" || !exact {
|
||||||
|
t.Fatalf("blank-userId profile target = %q/%q/%v, %v", target, hint, exact, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := root.PersistentFlags().Set("profile", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err = resolveAuthLoginConfig(cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.TargetCorpID != "" || cfg.HistoryProfileSelector != "ding_current:current_user" || cfg.HistoryProfileSelectorExplicit {
|
||||||
|
t.Fatalf("implicit login config constrained authorization target: %#v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, _, err := resolveAuthLoginTarget("cfg", "ding_same:missing"); err == nil {
|
||||||
|
t.Fatal("missing exact profile must not be reinterpreted as a corpId")
|
||||||
|
}
|
||||||
|
if target, hint, explicitHint, err := resolveAuthLoginTarget("cfg", "ding_new"); err != nil || target != "ding_new" || hint != "" || explicitHint {
|
||||||
|
t.Fatalf("new organization target = %q/%q/%v, %v", target, hint, explicitHint, err)
|
||||||
|
}
|
||||||
|
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||||
|
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{
|
||||||
|
{CorpID: "ding_ambiguous", UserID: "user_1"},
|
||||||
|
{CorpID: "ding_ambiguous", UserID: "user_2"},
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
if _, _, _, err := resolveAuthLoginTarget("cfg", "ding_ambiguous"); err == nil {
|
||||||
|
t.Fatal("ambiguous known organization must require an exact profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageOAuthAndDeviceApplyExactHistoryHint(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
oldResolve := authResolveProfile
|
||||||
|
oldLoad := authLoadProfiles
|
||||||
|
oldDevice := authDeviceLogin
|
||||||
|
oldOAuth := authOAuthLogin
|
||||||
|
oldInteractive := authLoginInteractiveTerminal
|
||||||
|
t.Cleanup(func() {
|
||||||
|
authResolveProfile = oldResolve
|
||||||
|
authLoadProfiles = oldLoad
|
||||||
|
authDeviceLogin = oldDevice
|
||||||
|
authOAuthLogin = oldOAuth
|
||||||
|
authLoginInteractiveTerminal = oldInteractive
|
||||||
|
})
|
||||||
|
|
||||||
|
selected := authpkg.Profile{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"}
|
||||||
|
authResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) {
|
||||||
|
if selector != "ding_same:user_2" {
|
||||||
|
return nil, errors.New("missing")
|
||||||
|
}
|
||||||
|
clone := selected
|
||||||
|
return &clone, nil
|
||||||
|
}
|
||||||
|
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||||
|
return &authpkg.ProfilesConfig{
|
||||||
|
CurrentProfile: "ding_same:user_1",
|
||||||
|
OrgCurrentProfiles: map[string]string{
|
||||||
|
"ding_same": "ding_same:user_1",
|
||||||
|
},
|
||||||
|
Profiles: []authpkg.Profile{
|
||||||
|
{CorpID: "ding_same", UserID: "user_1", UserName: "First"},
|
||||||
|
selected,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
authLoginInteractiveTerminal = func() bool { return false }
|
||||||
|
caller := &authCoverageCaller{err: errors.New("contact unavailable")}
|
||||||
|
newToken := func() *authpkg.TokenData {
|
||||||
|
return &authpkg.TokenData{
|
||||||
|
AccessToken: "new-access",
|
||||||
|
CorpID: "ding_same",
|
||||||
|
ExpiresAt: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deviceApplied := false
|
||||||
|
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) {
|
||||||
|
data := newToken()
|
||||||
|
if provider.IdentityEnricher == nil {
|
||||||
|
return nil, errors.New("missing device identity enricher")
|
||||||
|
}
|
||||||
|
if err := provider.IdentityEnricher(ctx, data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deviceApplied = data.UserID == "user_2"
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
if _, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{
|
||||||
|
"device": "true",
|
||||||
|
"profile": "ding_same:user_2",
|
||||||
|
}); err != nil || !deviceApplied {
|
||||||
|
t.Fatalf("device exact history hint applied=%v err=%v", deviceApplied, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthApplied := false
|
||||||
|
authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, _ bool) (*authpkg.TokenData, error) {
|
||||||
|
if provider.TargetCorpID != "ding_same" {
|
||||||
|
return nil, fmt.Errorf("OAuth target corp = %q", provider.TargetCorpID)
|
||||||
|
}
|
||||||
|
data := newToken()
|
||||||
|
if provider.IdentityEnricher == nil {
|
||||||
|
return nil, errors.New("missing OAuth identity enricher")
|
||||||
|
}
|
||||||
|
if err := provider.IdentityEnricher(ctx, data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
oauthApplied = data.UserID == "user_2"
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
if _, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{
|
||||||
|
"profile": "ding_same:user_2",
|
||||||
|
}); err != nil || !oauthApplied {
|
||||||
|
t.Fatalf("OAuth exact history hint applied=%v err=%v", oauthApplied, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageHistoricalIdentityPriorityAndBlankUserID(t *testing.T) {
|
||||||
|
oldLoad := authLoadProfiles
|
||||||
|
t.Cleanup(func() { authLoadProfiles = oldLoad })
|
||||||
|
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||||
|
if reused, err := enrichAuthLoginProfileFromHistory("cfg", &authpkg.TokenData{CorpID: "ding_same"}); reused || err != nil {
|
||||||
|
t.Fatalf("nil history registry = reused=%v err=%v", reused, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &authpkg.ProfilesConfig{
|
||||||
|
CurrentProfile: "ding_same:user_1",
|
||||||
|
OrgCurrentProfiles: map[string]string{
|
||||||
|
"ding_same": "ding_same:user_2",
|
||||||
|
},
|
||||||
|
Profiles: []authpkg.Profile{
|
||||||
|
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_1", UserName: "First"},
|
||||||
|
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil }
|
||||||
|
|
||||||
|
explicitData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||||
|
reused, err := enrichAuthLoginProfileFromHistory("cfg", explicitData, authLoginHistoryHint{Selector: "ding_same:user_1", Explicit: true})
|
||||||
|
if err != nil || !reused || explicitData.UserID != "user_1" {
|
||||||
|
t.Fatalf("explicit history selection = %#v, reused=%v err=%v", explicitData, reused, err)
|
||||||
|
}
|
||||||
|
mismatchedHintData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||||
|
reused, err = enrichAuthLoginProfileFromHistory("cfg", mismatchedHintData, authLoginHistoryHint{Selector: "ding_other:user_9", Explicit: true})
|
||||||
|
if err != nil || reused || mismatchedHintData.UserID != "" {
|
||||||
|
t.Fatalf("cross-corp explicit hint reused another identity: %#v, reused=%v err=%v", mismatchedHintData, reused, err)
|
||||||
|
}
|
||||||
|
orgCurrentData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||||
|
reused, err = enrichAuthLoginProfileFromHistory("cfg", orgCurrentData)
|
||||||
|
if err != nil || reused || orgCurrentData.UserID != "" {
|
||||||
|
t.Fatalf("implicit multi-account org-current was treated as identity proof: %#v, reused=%v err=%v", orgCurrentData, reused, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Profiles = []authpkg.Profile{cfg.Profiles[1]}
|
||||||
|
soleData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||||
|
reused, err = enrichAuthLoginProfileFromHistory("cfg", soleData)
|
||||||
|
if err != nil || !reused || soleData.UserID != "user_2" {
|
||||||
|
t.Fatalf("sole history selection = %#v, reused=%v err=%v", soleData, reused, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Profiles = []authpkg.Profile{
|
||||||
|
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_1", UserName: "First"},
|
||||||
|
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"},
|
||||||
|
}
|
||||||
|
cfg.OrgCurrentProfiles = nil
|
||||||
|
currentData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||||
|
reused, err = enrichAuthLoginProfileFromHistory("cfg", currentData)
|
||||||
|
if err != nil || reused || currentData.UserID != "" {
|
||||||
|
t.Fatalf("implicit multi-account current was treated as identity proof: %#v, reused=%v err=%v", currentData, reused, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.CurrentProfile = ""
|
||||||
|
ambiguousData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||||
|
reused, err = enrichAuthLoginProfileFromHistory("cfg", ambiguousData)
|
||||||
|
if err != nil || reused || ambiguousData.UserID != "" {
|
||||||
|
t.Fatalf("ambiguous history selection = %#v, reused=%v err=%v", ambiguousData, reused, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Profiles = []authpkg.Profile{{
|
||||||
|
Name: "external-worker", CorpID: "ding_same", CorpName: "Legacy Corp", UserName: "Legacy Worker",
|
||||||
|
}}
|
||||||
|
blankData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||||
|
reused, err = enrichAuthLoginProfileFromHistory("cfg", blankData, authLoginHistoryHint{Selector: "external-worker", Explicit: true})
|
||||||
|
if err != nil || !reused || blankData.UserID != "" || blankData.LegacyOrgScopedProfile != "external-worker" || blankData.CorpName != "Legacy Corp" || blankData.UserName != "Legacy Worker" {
|
||||||
|
t.Fatalf("blank-userId history selection = %#v, reused=%v err=%v", blankData, reused, err)
|
||||||
|
}
|
||||||
|
contactBlankData := &authpkg.TokenData{CorpID: "ding_same", AccessToken: "new-token"}
|
||||||
|
if err := enrichAuthLoginProfileFromContact(
|
||||||
|
context.Background(),
|
||||||
|
"cfg",
|
||||||
|
&authCoverageCaller{err: errors.New("contact unavailable")},
|
||||||
|
contactBlankData,
|
||||||
|
authLoginHistoryHint{Selector: "external-worker", Explicit: true},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("blank-userId history must keep contact best effort: %v", err)
|
||||||
|
}
|
||||||
|
if contactBlankData.LegacyOrgScopedProfile != "external-worker" {
|
||||||
|
t.Fatalf("blank-userId contact fallback did not authorize the historical organization slot: %#v", contactBlankData)
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles := []*authpkg.Profile{
|
||||||
|
nil,
|
||||||
|
{Name: "duplicate", CorpID: "ding_same", UserID: "user_1"},
|
||||||
|
{Name: "duplicate", CorpID: "ding_same", UserID: "user_2"},
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
selector string
|
||||||
|
profiles []*authpkg.Profile
|
||||||
|
want *authpkg.Profile
|
||||||
|
}{
|
||||||
|
{name: "empty selector", selector: "", profiles: profiles},
|
||||||
|
{name: "missing exact identity", selector: "ding_same:missing", profiles: profiles},
|
||||||
|
{name: "duplicate name", selector: "duplicate", profiles: profiles},
|
||||||
|
{name: "unmatched name", selector: "not-found", profiles: profiles},
|
||||||
|
{name: "sole organization selector", selector: "ding_same", profiles: profiles[1:2], want: profiles[1]},
|
||||||
|
} {
|
||||||
|
t.Run("selector "+tc.name, func(t *testing.T) {
|
||||||
|
if got := historicalProfileForSelector("ding_same", tc.selector, tc.profiles); got != tc.want {
|
||||||
|
t.Fatalf("historicalProfileForSelector(%q) = %#v, want %#v", tc.selector, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageAuthCoverageDefaultSeamClosures(t *testing.T) {
|
func TestCrossPlatformCoverageAuthCoverageDefaultSeamClosures(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
@@ -748,7 +1271,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
|
|||||||
if err := importCmd.RunE(badForce, nil); err == nil {
|
if err := importCmd.RunE(badForce, nil); err == nil {
|
||||||
t.Fatal("invalid force flag should fail")
|
t.Fatal("invalid force flag should fail")
|
||||||
}
|
}
|
||||||
_, out, _ = authCoverageRoot(importCmd, "table", false)
|
_, _, _ = authCoverageRoot(importCmd, "table", false)
|
||||||
if err := importCmd.RunE(importCmd, nil); err == nil {
|
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||||
t.Fatal("missing input should fail")
|
t.Fatal("missing input should fail")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -232,6 +232,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
|||||||
editionName := editionNameOrDefault()
|
editionName := editionNameOrDefault()
|
||||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||||
|
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
|
||||||
|
|
||||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -247,7 +248,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
|||||||
WorkDir: workDir,
|
WorkDir: workDir,
|
||||||
IPCEndpoint: ipcEndpoint,
|
IPCEndpoint: ipcEndpoint,
|
||||||
ClientID: identity.ClientID,
|
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,
|
Compact: opts.Common.Compact,
|
||||||
MaxEvents: opts.Common.MaxEvents,
|
MaxEvents: opts.Common.MaxEvents,
|
||||||
Duration: opts.Common.Duration,
|
Duration: opts.Common.Duration,
|
||||||
@@ -304,7 +305,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
|||||||
WorkDir: workDir,
|
WorkDir: workDir,
|
||||||
IPCEndpoint: ipcEndpoint,
|
IPCEndpoint: ipcEndpoint,
|
||||||
ClientID: identity.ClientID,
|
ClientID: identity.ClientID,
|
||||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
|
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
|
||||||
Compact: opts.Common.Compact,
|
Compact: opts.Common.Compact,
|
||||||
MaxEvents: opts.Common.MaxEvents,
|
MaxEvents: opts.Common.MaxEvents,
|
||||||
Duration: opts.Common.Duration,
|
Duration: opts.Common.Duration,
|
||||||
@@ -869,7 +870,31 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string {
|
func personalBusProfileSelector(configDir string, identity personal.Identity) string {
|
||||||
|
// The parent already resolved and loaded this selector. Preserve it before
|
||||||
|
// consulting identity metadata: personal event discovery can fill an empty
|
||||||
|
// token userId from runtime defaults, and that inferred value must not turn a
|
||||||
|
// historical unresolved account into a different exact same-corp account in
|
||||||
|
// the detached child.
|
||||||
|
if selector := strings.TrimSpace(authpkg.RuntimeProfile()); selector != "" {
|
||||||
|
return selector
|
||||||
|
}
|
||||||
|
if cfg, err := authpkg.LoadProfiles(configDir); err == nil && cfg != nil {
|
||||||
|
for i := range cfg.Profiles {
|
||||||
|
profile := cfg.Profiles[i]
|
||||||
|
if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID) &&
|
||||||
|
strings.TrimSpace(profile.UserID) == strings.TrimSpace(identity.UserID) {
|
||||||
|
return authpkg.ProfileSelectionSelector(profile, cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return authpkg.ProfileSelector(authpkg.Profile{
|
||||||
|
CorpID: identity.CorpID,
|
||||||
|
UserID: identity.UserID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string, profileSelectors ...string) []string {
|
||||||
args := []string{
|
args := []string{
|
||||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||||
"--stream-source-id", identity.SourceID,
|
"--stream-source-id", identity.SourceID,
|
||||||
@@ -878,10 +903,11 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
|
|||||||
// credentials as the parent, including when one organization has multiple
|
// credentials as the parent, including when one organization has multiple
|
||||||
// logged-in users.
|
// logged-in users.
|
||||||
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
|
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
|
||||||
args = append(args, "--profile", authpkg.ProfileSelector(authpkg.Profile{
|
profileSelector := authpkg.ProfileSelector(authpkg.Profile{CorpID: identity.CorpID, UserID: identity.UserID})
|
||||||
CorpID: identity.CorpID,
|
if len(profileSelectors) > 0 && strings.TrimSpace(profileSelectors[0]) != "" {
|
||||||
UserID: identity.UserID,
|
profileSelector = strings.TrimSpace(profileSelectors[0])
|
||||||
}))
|
}
|
||||||
|
args = append(args, "--profile", profileSelector)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(ticketMode) != "" {
|
if strings.TrimSpace(ticketMode) != "" {
|
||||||
args = append(args, "--stream-ticket-mode", ticketMode)
|
args = append(args, "--stream-ticket-mode", ticketMode)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/event/personal"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,3 +64,57 @@ func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoveragePersonalBusSpawnArgsPreservesReservedBlankProfile(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
cfg := &authpkg.ProfilesConfig{
|
||||||
|
Version: 2,
|
||||||
|
OrgCurrentProfiles: map[string]string{
|
||||||
|
"corp_event_fixture": "corp_event_fixture:identity_exact_fixture",
|
||||||
|
},
|
||||||
|
Profiles: []authpkg.Profile{
|
||||||
|
{
|
||||||
|
Name: "Fixture Organization",
|
||||||
|
CorpID: "corp_event_fixture",
|
||||||
|
CorpName: "Fixture Organization",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Exact Fixture Account",
|
||||||
|
CorpID: "corp_event_fixture",
|
||||||
|
CorpName: "Fixture Organization",
|
||||||
|
UserID: "identity_exact_fixture",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
identity := personal.Identity{CorpID: "corp_event_fixture", SourceID: "open"}
|
||||||
|
selector := personalBusProfileSelector(configDir, identity)
|
||||||
|
want := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||||
|
if selector != want || selector == identity.CorpID {
|
||||||
|
t.Fatalf("personalBusProfileSelector(blank) = %q, want reserved %q", selector, want)
|
||||||
|
}
|
||||||
|
args := personalBusSpawnArgs(identity, "", "", selector)
|
||||||
|
found := false
|
||||||
|
for i := 0; i+1 < len(args); i++ {
|
||||||
|
if args[i] == "--profile" && args[i+1] == want {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("spawn args did not preserve reserved blank selector: %v", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
authpkg.SetRuntimeProfile(want)
|
||||||
|
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||||
|
inferredExact := personal.Identity{
|
||||||
|
CorpID: "corp_event_fixture",
|
||||||
|
UserID: "identity_exact_fixture",
|
||||||
|
SourceID: "open",
|
||||||
|
}
|
||||||
|
if got := personalBusProfileSelector(configDir, inferredExact); got != want {
|
||||||
|
t.Fatalf("runtime blank selector changed after inferred userId: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
|
||||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||||
authpkg.SetRuntimeProfile("corp_a")
|
authpkg.SetRuntimeProfile("corp_a")
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e
|
|||||||
}
|
}
|
||||||
choice := strings.TrimSpace(cfg.CurrentProfile)
|
choice := strings.TrimSpace(cfg.CurrentProfile)
|
||||||
if choice == "" {
|
if choice == "" {
|
||||||
choice = authpkg.ProfileSelector(cfg.Profiles[0])
|
choice = authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||||
}
|
}
|
||||||
return profileSwitchTUIRunner(cmd, cfg, choice)
|
return profileSwitchTUIRunner(cmd, cfg, choice)
|
||||||
}
|
}
|
||||||
@@ -428,11 +428,36 @@ func (m profileSwitchTUIModel) selectedCorpID() string {
|
|||||||
if m.selected < 0 || m.selected >= len(m.profiles) {
|
if m.selected < 0 || m.selected >= len(m.profiles) {
|
||||||
return ""
|
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 {
|
func profileSwitchProfileIndex(profiles []authpkg.Profile, selector string, cfg *authpkg.ProfilesConfig) int {
|
||||||
selector = strings.TrimSpace(selector)
|
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 {
|
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||||||
for i, p := range profiles {
|
for i, p := range profiles {
|
||||||
if strings.TrimSpace(p.CorpID) == corpID && strings.TrimSpace(p.UserID) == userID {
|
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
|
fallback := -1
|
||||||
for i, p := range profiles {
|
for i, p := range profiles {
|
||||||
|
if strings.TrimSpace(p.UserID) == "" && strings.TrimSpace(p.Name) == selector {
|
||||||
|
return i
|
||||||
|
}
|
||||||
if strings.TrimSpace(p.CorpID) == selector {
|
if strings.TrimSpace(p.CorpID) == selector {
|
||||||
if fallback < 0 {
|
if fallback < 0 {
|
||||||
fallback = i
|
fallback = i
|
||||||
@@ -486,7 +514,7 @@ func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
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 "当前组织"
|
||||||
}
|
}
|
||||||
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")
|
fmt.Fprintf(w, "%-3s %-28s %-34s %-10s %s\n", "CUR", "ORG_NAME", "CORP_ID", "STATUS", "USER")
|
||||||
for _, p := range cfg.Profiles {
|
for _, p := range cfg.Profiles {
|
||||||
|
selector := profileCLISelector(p, cfg)
|
||||||
view := profileViewFromProfile(
|
view := profileViewFromProfile(
|
||||||
p,
|
p,
|
||||||
cfg,
|
cfg,
|
||||||
cfg.PrimaryProfile,
|
cfg.PrimaryProfile,
|
||||||
cfg.CurrentProfile,
|
cfg.CurrentProfile,
|
||||||
profileCountForCorp(cfg, p.CorpID) == 1,
|
profileCountForCorp(cfg, p.CorpID) == 1,
|
||||||
loadProfileTokenState(configDir, p),
|
loadProfileTokenState(configDir, p, selector),
|
||||||
)
|
)
|
||||||
current := ""
|
current := ""
|
||||||
if view.IsCurrent {
|
if view.IsCurrent {
|
||||||
@@ -698,13 +727,14 @@ func profileViews(configDir string, cfg *authpkg.ProfilesConfig) []profileView {
|
|||||||
}
|
}
|
||||||
views := make([]profileView, 0, len(cfg.Profiles))
|
views := make([]profileView, 0, len(cfg.Profiles))
|
||||||
for _, p := range cfg.Profiles {
|
for _, p := range cfg.Profiles {
|
||||||
|
selector := profileCLISelector(p, cfg)
|
||||||
views = append(views, profileViewFromProfile(
|
views = append(views, profileViewFromProfile(
|
||||||
p,
|
p,
|
||||||
cfg,
|
cfg,
|
||||||
cfg.PrimaryProfile,
|
cfg.PrimaryProfile,
|
||||||
cfg.CurrentProfile,
|
cfg.CurrentProfile,
|
||||||
profileCountForCorp(cfg, p.CorpID) == 1,
|
profileCountForCorp(cfg, p.CorpID) == 1,
|
||||||
loadProfileTokenState(configDir, p),
|
loadProfileTokenState(configDir, p, selector),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return views
|
return views
|
||||||
@@ -719,7 +749,7 @@ func profileViewFromProfile(
|
|||||||
) profileView {
|
) profileView {
|
||||||
isOrgCurrent := profileIsOrgCurrent(p, cfg)
|
isOrgCurrent := profileIsOrgCurrent(p, cfg)
|
||||||
view := profileView{
|
view := profileView{
|
||||||
Profile: authpkg.ProfileSelector(p),
|
Profile: profileCLISelector(p, cfg),
|
||||||
CorpID: p.CorpID,
|
CorpID: p.CorpID,
|
||||||
CorpName: profileOrgName(p),
|
CorpName: profileOrgName(p),
|
||||||
UserID: p.UserID,
|
UserID: p.UserID,
|
||||||
@@ -731,8 +761,8 @@ func profileViewFromProfile(
|
|||||||
RefreshExpAt: p.RefreshExpAt,
|
RefreshExpAt: p.RefreshExpAt,
|
||||||
LastLoginAt: p.LastLoginAt,
|
LastLoginAt: p.LastLoginAt,
|
||||||
LastUsedAt: p.LastUsedAt,
|
LastUsedAt: p.LastUsedAt,
|
||||||
IsPrimary: profileSelectorSelectsProfile(primaryProfile, p, isOrgCurrent, onlyAccountInOrg),
|
IsPrimary: profileSelectorSelectsProfile(primaryProfile, p, cfg, isOrgCurrent, onlyAccountInOrg),
|
||||||
IsCurrent: profileSelectorSelectsProfile(currentProfile, p, isOrgCurrent, onlyAccountInOrg),
|
IsCurrent: profileSelectorSelectsProfile(currentProfile, p, cfg, isOrgCurrent, onlyAccountInOrg),
|
||||||
IsOrgCurrent: isOrgCurrent,
|
IsOrgCurrent: isOrgCurrent,
|
||||||
}
|
}
|
||||||
if tokenState != nil {
|
if tokenState != nil {
|
||||||
@@ -743,8 +773,12 @@ func profileViewFromProfile(
|
|||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadProfileTokenState(configDir string, profile authpkg.Profile) *profileTokenState {
|
func loadProfileTokenState(configDir string, profile authpkg.Profile, selectors ...string) *profileTokenState {
|
||||||
data, err := profileLoadTokenData(configDir, authpkg.ProfileSelector(profile))
|
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) {
|
if errors.Is(err, authpkg.ErrTokenDataNotFound) || (err == nil && data == nil) {
|
||||||
return &profileTokenState{Status: authpkg.ProfileStatusRevoked}
|
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 {
|
func profileTokenTime(value time.Time) string {
|
||||||
if value.IsZero() {
|
if value.IsZero() {
|
||||||
return ""
|
return ""
|
||||||
@@ -769,8 +807,11 @@ func profileTokenTime(value time.Time) string {
|
|||||||
return value.Format(time.RFC3339)
|
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)
|
selector = strings.TrimSpace(selector)
|
||||||
|
if selector == authpkg.ProfileSelectionSelector(profile, cfg) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||||||
return corpID == strings.TrimSpace(profile.CorpID) && userID == strings.TrimSpace(profile.UserID)
|
return corpID == strings.TrimSpace(profile.CorpID) && userID == strings.TrimSpace(profile.UserID)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -210,7 +210,14 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
|||||||
if profile == nil {
|
if profile == nil {
|
||||||
return executor.Result{}, apperrors.NewValidation(fmt.Sprintf("profile %q not found", rawProfile))
|
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)
|
defer authpkg.SetRuntimeProfile(rawProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +358,9 @@ func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor
|
|||||||
|
|
||||||
for _, selection := range selections {
|
for _, selection := range selections {
|
||||||
resolvedSelector := authpkg.ProfileSelector(selection.Profile)
|
resolvedSelector := authpkg.ProfileSelector(selection.Profile)
|
||||||
|
if strings.TrimSpace(selection.Profile.UserID) == "" {
|
||||||
|
resolvedSelector = selection.Selector
|
||||||
|
}
|
||||||
authpkg.SetRuntimeProfile(resolvedSelector)
|
authpkg.SetRuntimeProfile(resolvedSelector)
|
||||||
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
|
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type blankProfileSelectorFixture struct {
|
||||||
|
configDir string
|
||||||
|
corpID string
|
||||||
|
blankName string
|
||||||
|
blankSelector string
|
||||||
|
exactUserID string
|
||||||
|
exactSelector string
|
||||||
|
blankToken *TokenData
|
||||||
|
exactToken *TokenData
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedBlankProfileSelectorFixture(
|
||||||
|
t *testing.T,
|
||||||
|
blankName string,
|
||||||
|
corpName string,
|
||||||
|
blankCurrent bool,
|
||||||
|
) blankProfileSelectorFixture {
|
||||||
|
t.Helper()
|
||||||
|
cleanupKeychain(t)
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
corpID := "corp_selector_fixture"
|
||||||
|
exactUserID := "identity_exact_fixture"
|
||||||
|
exactSelector := profileSelector(corpID, exactUserID)
|
||||||
|
currentSelector := exactSelector
|
||||||
|
previousSelector := blankName
|
||||||
|
if blankCurrent {
|
||||||
|
currentSelector = blankName
|
||||||
|
previousSelector = exactSelector
|
||||||
|
}
|
||||||
|
|
||||||
|
blankToken := testToken("at_unresolved_fixture", corpID, corpName)
|
||||||
|
blankToken.UserID = ""
|
||||||
|
blankToken.UserName = ""
|
||||||
|
exactToken := testToken("at_exact_fixture", corpID, corpName)
|
||||||
|
exactToken.UserID = exactUserID
|
||||||
|
exactToken.UserName = "Exact Fixture Account"
|
||||||
|
|
||||||
|
if err := SaveTokenDataKeychainForCorpID(corpID, blankToken); err != nil {
|
||||||
|
t.Fatalf("SaveTokenDataKeychainForCorpID(blank) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := SaveTokenDataKeychainForIdentity(corpID, exactUserID, exactToken); err != nil {
|
||||||
|
t.Fatalf("SaveTokenDataKeychainForIdentity(exact) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := SaveTokenDataKeychain(exactToken); err != nil {
|
||||||
|
t.Fatalf("SaveTokenDataKeychain(exact mirror) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := WriteTokenMarker(configDir); err != nil {
|
||||||
|
t.Fatalf("WriteTokenMarker() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &ProfilesConfig{
|
||||||
|
Version: profilesVersion,
|
||||||
|
PrimaryProfile: exactSelector,
|
||||||
|
CurrentProfile: currentSelector,
|
||||||
|
PreviousProfile: previousSelector,
|
||||||
|
OrgCurrentProfiles: map[string]string{
|
||||||
|
corpID: exactSelector,
|
||||||
|
},
|
||||||
|
Profiles: []Profile{
|
||||||
|
{
|
||||||
|
Name: blankName,
|
||||||
|
CorpID: corpID,
|
||||||
|
CorpName: corpName,
|
||||||
|
Status: ProfileStatusActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Exact Fixture Account",
|
||||||
|
CorpID: corpID,
|
||||||
|
CorpName: corpName,
|
||||||
|
UserID: exactUserID,
|
||||||
|
UserName: "Exact Fixture Account",
|
||||||
|
Status: ProfileStatusActive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.MarshalIndent(profiles) error = %v", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
if err := os.WriteFile(ProfilesPath(configDir), data, 0o600); err != nil {
|
||||||
|
t.Fatalf("os.WriteFile(profiles.json) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return blankProfileSelectorFixture{
|
||||||
|
configDir: configDir,
|
||||||
|
corpID: corpID,
|
||||||
|
blankName: blankName,
|
||||||
|
blankSelector: ProfileSelectionSelector(cfg.Profiles[0], cfg),
|
||||||
|
exactUserID: exactUserID,
|
||||||
|
exactSelector: exactSelector,
|
||||||
|
blankToken: blankToken,
|
||||||
|
exactToken: exactToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameResolvesCurrentProfileExactly(t *testing.T) {
|
||||||
|
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||||
|
|
||||||
|
selected, exact, err := ResolveProfileWithScope(fixture.configDir, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveProfileWithScope(current) error = %v", err)
|
||||||
|
}
|
||||||
|
if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" {
|
||||||
|
t.Fatalf("resolved current profile = %#v, want unresolved profile", selected)
|
||||||
|
}
|
||||||
|
if !exact {
|
||||||
|
t.Fatal("current local-name selector should resolve one exact unresolved profile")
|
||||||
|
}
|
||||||
|
cfg, err := LoadProfiles(fixture.configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.CurrentProfile != fixture.blankSelector {
|
||||||
|
t.Fatalf("current profile = %q, want stable unresolved selector %q", cfg.CurrentProfile, fixture.blankSelector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameLoadsOrganizationToken(t *testing.T) {
|
||||||
|
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false)
|
||||||
|
|
||||||
|
if fixture.blankSelector == fixture.blankName || fixture.blankSelector == fixture.corpID {
|
||||||
|
t.Fatalf("unsafe blank selector = %q, want reserved exact selector", fixture.blankSelector)
|
||||||
|
}
|
||||||
|
loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadTokenDataForProfile(blank local name) error = %v", err)
|
||||||
|
}
|
||||||
|
if loaded.UserID != "" || loaded.AccessToken != fixture.blankToken.AccessToken {
|
||||||
|
t.Fatalf("loaded token = %#v, want unresolved organization token", loaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameRoundTripsPreviousProfile(t *testing.T) {
|
||||||
|
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false)
|
||||||
|
|
||||||
|
selected, err := UsePreviousProfile(fixture.configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsePreviousProfile() error = %v", err)
|
||||||
|
}
|
||||||
|
if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" {
|
||||||
|
t.Fatalf("selected previous profile = %#v, want unresolved profile", selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadProfiles(fixture.configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.CurrentProfile != fixture.blankSelector || cfg.PreviousProfile != fixture.exactSelector {
|
||||||
|
t.Fatalf(
|
||||||
|
"profile pointers = current %q previous %q, want %q and %q",
|
||||||
|
cfg.CurrentProfile,
|
||||||
|
cfg.PreviousProfile,
|
||||||
|
fixture.blankSelector,
|
||||||
|
fixture.exactSelector,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameDeletesOnlyUnresolvedProfile(t *testing.T) {
|
||||||
|
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", false)
|
||||||
|
|
||||||
|
if err := DeleteTokenDataForProfile(fixture.configDir, fixture.blankSelector); err != nil {
|
||||||
|
t.Fatalf("DeleteTokenDataForProfile(blank local name) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadProfiles(fixture.configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != fixture.corpID || cfg.Profiles[0].UserID != fixture.exactUserID {
|
||||||
|
t.Fatalf("profiles after blank deletion = %#v, want only exact account", cfg.Profiles)
|
||||||
|
}
|
||||||
|
loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.exactSelector)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadTokenDataForProfile(exact after blank deletion) error = %v", err)
|
||||||
|
}
|
||||||
|
if loaded.UserID != fixture.exactUserID || loaded.AccessToken != fixture.exactToken.AccessToken {
|
||||||
|
t.Fatalf("exact token after blank deletion = %#v, want exact account preserved", loaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageBlankProfileNameContainingColonWinsOverIdentitySyntax(t *testing.T) {
|
||||||
|
fixture := seedBlankProfileSelectorFixture(t, "legacy:outsourced", "Fixture Organization", true)
|
||||||
|
|
||||||
|
if fixture.blankSelector == fixture.blankName {
|
||||||
|
t.Fatalf("colon-containing name leaked as selector %q", fixture.blankSelector)
|
||||||
|
}
|
||||||
|
if _, _, parsedAsIdentity := ParseIdentitySelector(fixture.blankSelector); parsedAsIdentity {
|
||||||
|
t.Fatalf("stable blank selector %q was parsed as an identity", fixture.blankSelector)
|
||||||
|
}
|
||||||
|
selected, exact, err := ResolveProfileWithScope(fixture.configDir, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveProfileWithScope(colon local name) error = %v", err)
|
||||||
|
}
|
||||||
|
if selected == nil || selected.CorpID != fixture.corpID || selected.UserID != "" {
|
||||||
|
t.Fatalf("resolved colon-name profile = %#v, want unresolved profile", selected)
|
||||||
|
}
|
||||||
|
if !exact {
|
||||||
|
t.Fatal("colon-containing local name should resolve one exact unresolved profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadProfiles(fixture.configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.CurrentProfile != fixture.blankSelector {
|
||||||
|
t.Fatalf("current profile = %q, want migrated colon-name selector %q", cfg.CurrentProfile, fixture.blankSelector)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadTokenDataForProfile(colon local name) error = %v", err)
|
||||||
|
}
|
||||||
|
if loaded.UserID != "" || loaded.AccessToken != fixture.blankToken.AccessToken {
|
||||||
|
t.Fatalf("loaded colon-name token = %#v, want unresolved organization token", loaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageRealExactSelectorWinsOverMatchingBlankLegacyName(t *testing.T) {
|
||||||
|
const exactSelector = "corp_selector_fixture:identity_exact_fixture"
|
||||||
|
fixture := seedBlankProfileSelectorFixture(t, exactSelector, "Fixture Organization", true)
|
||||||
|
|
||||||
|
selected, exact, err := ResolveProfileWithScope(fixture.configDir, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveProfileWithScope(current exact collision) error = %v", err)
|
||||||
|
}
|
||||||
|
if selected == nil || selected.UserID != fixture.exactUserID || !exact {
|
||||||
|
t.Fatalf("resolved current collision = %#v exact=%v, want real exact identity", selected, exact)
|
||||||
|
}
|
||||||
|
cfg, err := LoadProfiles(fixture.configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.CurrentProfile != exactSelector {
|
||||||
|
t.Fatalf("current collision selector = %q, want real exact %q", cfg.CurrentProfile, exactSelector)
|
||||||
|
}
|
||||||
|
|
||||||
|
blank, err := LoadTokenDataForProfile(fixture.configDir, fixture.blankSelector)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadTokenDataForProfile(reserved blank collision) error = %v", err)
|
||||||
|
}
|
||||||
|
if blank.UserID != "" || blank.AccessToken != fixture.blankToken.AccessToken {
|
||||||
|
t.Fatalf("reserved blank collision token = %#v", blank)
|
||||||
|
}
|
||||||
|
if err := DeleteTokenDataForProfile(fixture.configDir, fixture.blankSelector); err != nil {
|
||||||
|
t.Fatalf("DeleteTokenDataForProfile(reserved blank collision) error = %v", err)
|
||||||
|
}
|
||||||
|
exactToken, err := LoadTokenDataForProfile(fixture.configDir, exactSelector)
|
||||||
|
if err != nil || exactToken.UserID != fixture.exactUserID {
|
||||||
|
t.Fatalf("exact identity after blank collision delete = %#v, %v", exactToken, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageUnrelatedProfileDeletionPreservesBlankOrganizationCurrentMapping(t *testing.T) {
|
||||||
|
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||||
|
cfg, err := LoadProfiles(fixture.configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Profiles = append(cfg.Profiles, Profile{
|
||||||
|
Name: "Unrelated Account",
|
||||||
|
CorpID: "corp_unrelated_fixture",
|
||||||
|
UserID: "identity_unrelated_fixture",
|
||||||
|
Status: ProfileStatusActive,
|
||||||
|
})
|
||||||
|
if err := SaveProfiles(fixture.configDir, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveProfiles(unrelated) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := RemoveProfile(fixture.configDir, "corp_unrelated_fixture:identity_unrelated_fixture"); err != nil {
|
||||||
|
t.Fatalf("RemoveProfile(unrelated) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err = LoadProfiles(fixture.configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadProfiles(after unrelated delete) error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.CurrentProfile != fixture.blankSelector {
|
||||||
|
t.Fatalf("blank current after unrelated delete = %q, want %q", cfg.CurrentProfile, fixture.blankSelector)
|
||||||
|
}
|
||||||
|
if got := cfg.OrgCurrentProfiles[fixture.corpID]; got != fixture.exactSelector {
|
||||||
|
t.Fatalf("organization current after unrelated delete = %q, want %q", got, fixture.exactSelector)
|
||||||
|
}
|
||||||
|
selected, err := ResolveProfile(fixture.configDir, fixture.corpID)
|
||||||
|
if err != nil || selected.UserID != fixture.exactUserID {
|
||||||
|
t.Fatalf("organization selector after unrelated delete = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageSameCorpNonCurrentDeletionPreservesExactOrganizationCurrent(t *testing.T) {
|
||||||
|
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||||
|
cfg, err := LoadProfiles(fixture.configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Profiles = append(cfg.Profiles, Profile{
|
||||||
|
Name: "Another Exact Account",
|
||||||
|
CorpID: fixture.corpID,
|
||||||
|
CorpName: "Fixture Organization",
|
||||||
|
UserID: "identity_noncurrent_fixture",
|
||||||
|
Status: ProfileStatusActive,
|
||||||
|
})
|
||||||
|
if err := SaveProfiles(fixture.configDir, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveProfiles(non-current exact) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := RemoveProfile(fixture.configDir, fixture.corpID+":identity_noncurrent_fixture"); err != nil {
|
||||||
|
t.Fatalf("RemoveProfile(non-current exact) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err = LoadProfiles(fixture.configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadProfiles(after same-corp delete) error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.CurrentProfile != fixture.blankSelector {
|
||||||
|
t.Fatalf("blank current after same-corp delete = %q, want %q", cfg.CurrentProfile, fixture.blankSelector)
|
||||||
|
}
|
||||||
|
if got := cfg.OrgCurrentProfiles[fixture.corpID]; got != fixture.exactSelector {
|
||||||
|
t.Fatalf("organization current after same-corp delete = %q, want %q", got, fixture.exactSelector)
|
||||||
|
}
|
||||||
|
selected, err := ResolveProfile(fixture.configDir, fixture.corpID)
|
||||||
|
if err != nil || selected.UserID != fixture.exactUserID {
|
||||||
|
t.Fatalf("organization selector after same-corp delete = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3159,7 +3159,13 @@ func TestCrossPlatformCoverageMultiAccountSelectorAndIdentityLoadEdges(t *testin
|
|||||||
if got, err := loadTokenForProfileIdentity(profile); err != nil || got.AccessToken != "mirror" {
|
if got, err := loadTokenForProfileIdentity(profile); err != nil || got.AccessToken != "mirror" {
|
||||||
t.Fatalf("identity repair = %#v %v", got, err)
|
t.Fatalf("identity repair = %#v %v", got, err)
|
||||||
}
|
}
|
||||||
if _, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err != nil {
|
if _, err := loadTokenForProfileIdentity(Profile{CorpID: "corp-a"}); err == nil {
|
||||||
t.Fatalf("organization-only token load = %v", err)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,12 +219,20 @@ func preflightTokenRefreshPersistence(configDir string, data *TokenData) error {
|
|||||||
return fmt.Errorf("identity token slot %q is unreadable: %w", TokenAccountForIdentity(corpID, userID), err)
|
return fmt.Errorf("identity token slot %q is unreadable: %w", TokenAccountForIdentity(corpID, userID), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkOrganizationMirror := true
|
runtimeSelector := strings.TrimSpace(RuntimeProfile())
|
||||||
if _, _, exact := ParseIdentitySelector(RuntimeProfile()); exact {
|
persistenceSelector := runtimeSelector
|
||||||
checkOrganizationMirror =
|
if persistenceSelector == "" && userID == "" {
|
||||||
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) ==
|
persistenceSelector = strings.TrimSpace(data.LegacyOrgScopedProfile)
|
||||||
profileSelector(corpID, userID)
|
|
||||||
}
|
}
|
||||||
|
makeCurrent := persistenceSelector == ""
|
||||||
|
existingIdentity := profileIndexByIdentity(cfg, corpID, userID) >= 0
|
||||||
|
upgradesLegacyProfile := !existingIdentity && userID != "" &&
|
||||||
|
len(profilesForCorpID(cfg, corpID)) == 1 && legacyProfileIndexByCorpID(cfg, corpID) >= 0
|
||||||
|
preserveUnresolvedOrg := userID != "" &&
|
||||||
|
unresolvedProfileForCorp(cfg, corpID) != nil && !upgradesLegacyProfile
|
||||||
|
checkOrganizationMirror := userID == "" || (!preserveUnresolvedOrg && (makeCurrent ||
|
||||||
|
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) ==
|
||||||
|
profileSelector(corpID, userID)))
|
||||||
if checkOrganizationMirror {
|
if checkOrganizationMirror {
|
||||||
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||||
return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err)
|
return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -290,6 +290,24 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
|
|||||||
return data, nil
|
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.
|
// parseMCPTokenResponse parses token response from MCP proxy.
|
||||||
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."}
|
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."}
|
||||||
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||||
@@ -313,7 +331,7 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
|||||||
}
|
}
|
||||||
// Check for error response
|
// Check for error response
|
||||||
if resp.ErrorCode != "" || resp.ErrorMsg != "" {
|
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 == "" {
|
if resp.AccessToken == "" {
|
||||||
return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body))
|
return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body))
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -662,6 +663,7 @@ func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("load access token: %w", err)
|
return nil, fmt.Errorf("load access token: %w", err)
|
||||||
}
|
}
|
||||||
|
profileSelector := StableTokenProfileSelector(p.configDir, data)
|
||||||
|
|
||||||
// Fast path: access_token still valid — no lock needed.
|
// Fast path: access_token still valid — no lock needed.
|
||||||
if data.IsAccessTokenValid() {
|
if data.IsAccessTokenValid() {
|
||||||
@@ -678,14 +680,26 @@ func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error
|
|||||||
// refresh credential. Keep the profile active so a long-running source
|
// refresh credential. Keep the profile active so a long-running source
|
||||||
// can retry after backoff. Terminal and unknown failures remain fatal.
|
// can retry after backoff. Terminal and unknown failures remain fatal.
|
||||||
if ClassifyRefreshFailure(rErr) != RefreshFailureTransient {
|
if ClassifyRefreshFailure(rErr) != RefreshFailureTransient {
|
||||||
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
|
_ = oauthMarkProfile(p.configDir, profileSelector, ProfileStatusExpired)
|
||||||
}
|
}
|
||||||
if p.logger != nil {
|
if p.logger != nil {
|
||||||
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
|
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
|
||||||
}
|
}
|
||||||
|
var exchangeErr *MCPTokenExchangeError
|
||||||
|
if errors.As(rErr, &exchangeErr) && exchangeErr.requiresReauthorization() {
|
||||||
|
command := "dws auth login"
|
||||||
|
if profileSelector != "" {
|
||||||
|
command += " --profile " + strconv.Quote(profileSelector)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"旧版登录态已无法由当前认证服务刷新;本地 profile 已保留,请运行 %s 完成一次重新授权: %w",
|
||||||
|
command,
|
||||||
|
rErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr)
|
return nil, fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr)
|
||||||
} else {
|
} else {
|
||||||
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
|
_ = oauthMarkProfile(p.configDir, profileSelector, ProfileStatusExpired)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound)
|
return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound)
|
||||||
@@ -817,9 +831,12 @@ func (p *OAuthProvider) prepareLoginToken(ctx context.Context, tokenData *TokenD
|
|||||||
return fmt.Errorf("resolve login identity: %w", err)
|
return fmt.Errorf("resolve login identity: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" {
|
// v1.0.52 and earlier deliberately persisted the freshly exchanged token
|
||||||
return fmt.Errorf("resolve login identity: userId is required for corpId %q", tokenData.CorpID)
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -685,7 +685,10 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
|
|||||||
resetAppConfigCache()
|
resetAppConfigCache()
|
||||||
oauthHTTPClient = mcpSrv.Client()
|
oauthHTTPClient = mcpSrv.Client()
|
||||||
mcpProvider := &OAuthProvider{configDir: configDir, httpClient: 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" {
|
if updated, err := mcpProvider.refreshViaMCP(context.Background(), mcpOriginal); err != nil || updated.Source != "mcp" {
|
||||||
t.Fatalf("MCP refresh = %#v, %v", updated, err)
|
t.Fatalf("MCP refresh = %#v, %v", updated, err)
|
||||||
}
|
}
|
||||||
|
|||||||
+448
-64
@@ -15,6 +15,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -75,8 +76,9 @@ func withProfilesLock(configDir string, fn func() error) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
profilesJSONFile = "profiles.json"
|
profilesJSONFile = "profiles.json"
|
||||||
profilesVersion = 2
|
profilesVersion = 2
|
||||||
|
unresolvedProfileSelectorPrefix = "@legacy/"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -240,6 +242,9 @@ func ensureProfilesMigrationLocked(configDir string) error {
|
|||||||
cfg.OrgCurrentProfiles = make(map[string]string)
|
cfg.OrgCurrentProfiles = make(map[string]string)
|
||||||
}
|
}
|
||||||
orgTokens := make(map[string]*TokenData)
|
orgTokens := make(map[string]*TokenData)
|
||||||
|
var legacyToken *TokenData
|
||||||
|
var legacyTokenErr error
|
||||||
|
legacyTokenLoaded := false
|
||||||
for i := range cfg.Profiles {
|
for i := range cfg.Profiles {
|
||||||
p := &cfg.Profiles[i]
|
p := &cfg.Profiles[i]
|
||||||
corpID := strings.TrimSpace(p.CorpID)
|
corpID := strings.TrimSpace(p.CorpID)
|
||||||
@@ -255,12 +260,75 @@ func ensureProfilesMigrationLocked(configDir string) error {
|
|||||||
if loadErr != nil {
|
if loadErr != nil {
|
||||||
token = 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
|
orgToken = token
|
||||||
orgTokens[corpID] = orgToken
|
orgTokens[corpID] = orgToken
|
||||||
}
|
}
|
||||||
if orgToken == nil {
|
if orgToken == nil {
|
||||||
continue
|
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 strings.TrimSpace(p.UserID) == "" && strings.TrimSpace(orgToken.UserID) != "" {
|
||||||
if existing := findExactProfile(cfg, corpID, orgToken.UserID); existing != nil && existing != p {
|
if existing := findExactProfile(cfg, corpID, orgToken.UserID); existing != nil && existing != p {
|
||||||
p.CorpID = ""
|
p.CorpID = ""
|
||||||
@@ -273,12 +341,12 @@ func ensureProfilesMigrationLocked(configDir string) error {
|
|||||||
}
|
}
|
||||||
changed = true
|
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
|
continue
|
||||||
}
|
}
|
||||||
_, identityErr := profilesLoadIdentity(corpID, p.UserID)
|
_, identityErr := profilesLoadIdentity(corpID, p.UserID)
|
||||||
if errors.Is(identityErr, ErrTokenDataNotFound) {
|
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
|
return err
|
||||||
}
|
}
|
||||||
} else if identityErr != nil {
|
} else if identityErr != nil {
|
||||||
@@ -324,6 +392,10 @@ func ensureProfilesMigrationLocked(configDir string) error {
|
|||||||
cfg.CurrentProfile = exact
|
cfg.CurrentProfile = exact
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" && exact != cfg.PrimaryProfile {
|
||||||
|
cfg.PrimaryProfile = exact
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if legacySelectionState && cfg.CurrentProfile == "" {
|
if legacySelectionState && cfg.CurrentProfile == "" {
|
||||||
if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" {
|
if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" {
|
||||||
cfg.CurrentProfile = exact
|
cfg.CurrentProfile = exact
|
||||||
@@ -351,6 +423,29 @@ func ensureProfilesMigrationLocked(configDir string) error {
|
|||||||
return nil
|
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.
|
// UpsertProfileFromToken updates profiles.json after a successful login or refresh.
|
||||||
func UpsertProfileFromToken(configDir string, data *TokenData) error {
|
func UpsertProfileFromToken(configDir string, data *TokenData) error {
|
||||||
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
|
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
|
||||||
@@ -392,7 +487,11 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
|
|||||||
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
||||||
}
|
}
|
||||||
idx := profileIndexByIdentity(cfg, corpID, userID)
|
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)
|
idx = legacyProfileIndexByCorpID(cfg, corpID)
|
||||||
}
|
}
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
@@ -409,6 +508,7 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
|
|||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
cfg.Profiles = append(cfg.Profiles, profile)
|
cfg.Profiles = append(cfg.Profiles, profile)
|
||||||
|
idx = len(cfg.Profiles) - 1
|
||||||
} else {
|
} else {
|
||||||
p := &cfg.Profiles[idx]
|
p := &cfg.Profiles[idx]
|
||||||
if userID != "" {
|
if userID != "" {
|
||||||
@@ -431,17 +531,33 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa
|
|||||||
p.LastUsedAt = now
|
p.LastUsedAt = now
|
||||||
p.UpdatedAt = 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 {
|
if makeCurrent {
|
||||||
newSelector := profileSelector(corpID, userID)
|
newSelector := storedProfileSelector(cfg, storedProfile)
|
||||||
if previousCurrent != nil && ProfileSelector(*previousCurrent) != newSelector {
|
if previousCurrent != nil && storedProfileSelector(cfg, previousCurrent) != newSelector {
|
||||||
cfg.PreviousProfile = ProfileSelector(*previousCurrent)
|
cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent)
|
||||||
}
|
}
|
||||||
cfg.CurrentProfile = newSelector
|
cfg.CurrentProfile = newSelector
|
||||||
setOrgCurrentProfile(cfg, corpID, newSelector)
|
if userID == "" {
|
||||||
|
delete(cfg.OrgCurrentProfiles, corpID)
|
||||||
|
} else {
|
||||||
|
setOrgCurrentProfile(cfg, corpID, newSelector)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if cfg.CurrentProfile == "" {
|
if cfg.CurrentProfile == "" {
|
||||||
cfg.CurrentProfile = profileSelector(corpID, userID)
|
cfg.CurrentProfile = storedProfileSelector(cfg, storedProfile)
|
||||||
setOrgCurrentProfile(cfg, corpID, cfg.CurrentProfile)
|
if userID == "" {
|
||||||
|
delete(cfg.OrgCurrentProfiles, corpID)
|
||||||
|
} else {
|
||||||
|
setOrgCurrentProfile(cfg, corpID, cfg.CurrentProfile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return profilesSave(configDir, cfg)
|
return profilesSave(configDir, cfg)
|
||||||
}
|
}
|
||||||
@@ -452,6 +568,87 @@ func ProfileSelector(profile Profile) string {
|
|||||||
return profileSelector(profile.CorpID, profile.UserID)
|
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
|
// TokenProfileSelector returns the exact identity selector for token data when
|
||||||
// its userId is known, otherwise it returns the historical corpId selector.
|
// its userId is known, otherwise it returns the historical corpId selector.
|
||||||
func TokenProfileSelector(data *TokenData) string {
|
func TokenProfileSelector(data *TokenData) string {
|
||||||
@@ -461,6 +658,34 @@ func TokenProfileSelector(data *TokenData) string {
|
|||||||
return profileSelector(data.CorpID, data.UserID)
|
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 {
|
func profileSelector(corpID, userID string) string {
|
||||||
corpID = strings.TrimSpace(corpID)
|
corpID = strings.TrimSpace(corpID)
|
||||||
userID = strings.TrimSpace(userID)
|
userID = strings.TrimSpace(userID)
|
||||||
@@ -609,7 +834,8 @@ func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
originalCfg := cloneProfilesConfig(cfg)
|
originalCfg := cloneProfilesConfig(cfg)
|
||||||
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID)
|
syncOrganization := shouldSyncOrganizationMirror(cfg, *p)
|
||||||
|
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -617,20 +843,26 @@ func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
|
|||||||
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
||||||
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
||||||
}
|
}
|
||||||
storedSelector := ProfileSelector(*p)
|
storedSelector := storedProfileSelector(cfg, p)
|
||||||
if cfg.CurrentProfile != storedSelector {
|
if cfg.CurrentProfile != storedSelector {
|
||||||
if previousCurrent != nil {
|
if previousCurrent != nil {
|
||||||
cfg.PreviousProfile = ProfileSelector(*previousCurrent)
|
cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent)
|
||||||
}
|
}
|
||||||
cfg.CurrentProfile = storedSelector
|
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)
|
touchProfileUsage(p)
|
||||||
if err := profilesSave(configDir, cfg); err != nil {
|
if err := profilesSave(configDir, cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
|
if syncOrganization {
|
||||||
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
|
||||||
|
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := profilesSyncLegacyMirror(configDir); err != nil {
|
if err := profilesSyncLegacyMirror(configDir); err != nil {
|
||||||
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
||||||
@@ -669,7 +901,8 @@ func usePreviousProfileLocked(configDir string) (*Profile, error) {
|
|||||||
return nil, fmt.Errorf("resolve previous profile %q: %w", prev, err)
|
return nil, fmt.Errorf("resolve previous profile %q: %w", prev, err)
|
||||||
}
|
}
|
||||||
originalCfg := cloneProfilesConfig(cfg)
|
originalCfg := cloneProfilesConfig(cfg)
|
||||||
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID)
|
syncOrganization := shouldSyncOrganizationMirror(cfg, *p)
|
||||||
|
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -677,19 +910,25 @@ func usePreviousProfileLocked(configDir string) (*Profile, error) {
|
|||||||
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
||||||
current, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
current, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
|
||||||
}
|
}
|
||||||
cfg.CurrentProfile = ProfileSelector(*p)
|
cfg.CurrentProfile = storedProfileSelector(cfg, p)
|
||||||
if current != nil {
|
if current != nil {
|
||||||
cfg.PreviousProfile = ProfileSelector(*current)
|
cfg.PreviousProfile = storedProfileSelector(cfg, current)
|
||||||
} else {
|
} else {
|
||||||
cfg.PreviousProfile = ""
|
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)
|
touchProfileUsage(p)
|
||||||
if err := profilesSave(configDir, cfg); err != nil {
|
if err := profilesSave(configDir, cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
|
if syncOrganization {
|
||||||
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
|
||||||
|
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := profilesSyncLegacyMirror(configDir); err != nil {
|
if err := profilesSyncLegacyMirror(configDir); err != nil {
|
||||||
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
|
||||||
@@ -733,6 +972,21 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
removed := *p
|
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]
|
kept := cfg.Profiles[:0]
|
||||||
for _, profile := range cfg.Profiles {
|
for _, profile := range cfg.Profiles {
|
||||||
remove := profile.CorpID == removed.CorpID
|
remove := profile.CorpID == removed.CorpID
|
||||||
@@ -748,7 +1002,7 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
|||||||
if exact && len(profilesForCorpID(cfg, removed.CorpID)) > 0 {
|
if exact && len(profilesForCorpID(cfg, removed.CorpID)) > 0 {
|
||||||
remaining := profilesForCorpID(cfg, removed.CorpID)
|
remaining := profilesForCorpID(cfg, removed.CorpID)
|
||||||
if len(remaining) == 1 {
|
if len(remaining) == 1 {
|
||||||
replacementSelector = ProfileSelector(*remaining[0])
|
replacementSelector = storedProfileSelector(cfg, remaining[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if exact {
|
if exact {
|
||||||
@@ -762,15 +1016,14 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
|||||||
} else {
|
} else {
|
||||||
delete(cfg.OrgCurrentProfiles, removed.CorpID)
|
delete(cfg.OrgCurrentProfiles, removed.CorpID)
|
||||||
}
|
}
|
||||||
pointers := []*string{&cfg.PrimaryProfile, &cfg.CurrentProfile, &cfg.PreviousProfile}
|
for i, pointer := range pointers {
|
||||||
for _, pointer := range pointers {
|
|
||||||
if exact {
|
if exact {
|
||||||
if selectorMatchesIdentity(*pointer, removed) {
|
if pointerMatches[i] {
|
||||||
*pointer = replacementSelector
|
*pointer = replacementSelector
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if selectorTargetsCorp(*pointer, removed.CorpID) {
|
if pointerMatches[i] || selectorTargetsCorp(*pointer, removed.CorpID) {
|
||||||
*pointer = ""
|
*pointer = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -779,12 +1032,27 @@ func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
|||||||
cfg.CurrentProfile = previous
|
cfg.CurrentProfile = previous
|
||||||
cfg.PreviousProfile = ""
|
cfg.PreviousProfile = ""
|
||||||
} else if len(cfg.Profiles) == 1 {
|
} 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 {
|
if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile {
|
||||||
cfg.PreviousProfile = ""
|
cfg.PreviousProfile = ""
|
||||||
}
|
}
|
||||||
|
currentSelectionChanged := strings.TrimSpace(cfg.CurrentProfile) != originalCurrentSelector
|
||||||
|
organizationCurrentChanged := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(removed.CorpID)]) != originalOrganizationCurrent
|
||||||
|
if strings.TrimSpace(cfg.CurrentProfile) != "" {
|
||||||
|
if current, _, resolveErr := resolveProfileSelection(configDir, cfg, cfg.CurrentProfile); resolveErr == nil && current != nil {
|
||||||
|
if (currentSelectionChanged || organizationCurrentChanged) &&
|
||||||
|
strings.TrimSpace(current.CorpID) == strings.TrimSpace(removed.CorpID) &&
|
||||||
|
unresolvedProfileForCorp(cfg, removed.CorpID) != nil {
|
||||||
|
if strings.TrimSpace(current.UserID) == "" {
|
||||||
|
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(current.CorpID))
|
||||||
|
} else {
|
||||||
|
setOrgCurrentProfile(cfg, current.CorpID, storedProfileSelector(cfg, current))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(cfg.Profiles) == 0 {
|
if len(cfg.Profiles) == 0 {
|
||||||
cfg.PrimaryProfile = ""
|
cfg.PrimaryProfile = ""
|
||||||
cfg.CurrentProfile = ""
|
cfg.CurrentProfile = ""
|
||||||
@@ -808,6 +1076,9 @@ func selectorTargetsCorp(selector, corpID string) bool {
|
|||||||
if selector == corpID {
|
if selector == corpID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if selectedCorpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
|
||||||
|
return selectedCorpID == corpID
|
||||||
|
}
|
||||||
selectedCorpID, _, exact := ParseIdentitySelector(selector)
|
selectedCorpID, _, exact := ParseIdentitySelector(selector)
|
||||||
return exact && selectedCorpID == corpID
|
return exact && selectedCorpID == corpID
|
||||||
}
|
}
|
||||||
@@ -856,12 +1127,16 @@ type profileSelectionMirrorSnapshot struct {
|
|||||||
marker tokenMarkerSnapshot
|
marker tokenMarkerSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshotProfileSelectionMirrors(configDir, corpID string) (profileSelectionMirrorSnapshot, error) {
|
func snapshotProfileSelectionMirrors(configDir, corpID string, includeOrganization bool) (profileSelectionMirrorSnapshot, error) {
|
||||||
organization, err := snapshotTokenSlot(func() (*TokenData, error) {
|
var organization tokenSlotSnapshot
|
||||||
return profilesLoadCorp(corpID)
|
if includeOrganization {
|
||||||
})
|
var err error
|
||||||
if err != nil {
|
organization, err = snapshotTokenSlot(func() (*TokenData, error) {
|
||||||
return profileSelectionMirrorSnapshot{}, err
|
return profilesLoadCorp(corpID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return profileSelectionMirrorSnapshot{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
legacy, err := snapshotTokenSlot(profilesLoadLegacy)
|
legacy, err := snapshotTokenSlot(profilesLoadLegacy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -889,12 +1164,14 @@ func rollbackProfileSelection(
|
|||||||
if err := profilesSave(configDir, cloneProfilesConfig(cfg)); err != nil {
|
if err := profilesSave(configDir, cloneProfilesConfig(cfg)); err != nil {
|
||||||
rollbackErr = errors.Join(rollbackErr, err)
|
rollbackErr = errors.Join(rollbackErr, err)
|
||||||
}
|
}
|
||||||
if mirrors.organization.exists {
|
if mirrors.organization.known {
|
||||||
if err := profilesSaveCorp(corpID, mirrors.organization.token); err != nil {
|
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)
|
rollbackErr = errors.Join(rollbackErr, err)
|
||||||
}
|
}
|
||||||
} else if err := profilesDeleteCorp(corpID); err != nil {
|
|
||||||
rollbackErr = errors.Join(rollbackErr, err)
|
|
||||||
}
|
}
|
||||||
if mirrors.legacy.exists {
|
if mirrors.legacy.exists {
|
||||||
if err := profilesSaveLegacy(mirrors.legacy.token); err != nil {
|
if err := profilesSaveLegacy(mirrors.legacy.token); err != nil {
|
||||||
@@ -975,33 +1252,48 @@ func syncOrganizationTokenMirrorForProfile(profile Profile) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadTokenForProfileIdentity(profile Profile) (*TokenData, error) {
|
func loadTokenForProfileIdentity(profile Profile) (*TokenData, error) {
|
||||||
if strings.TrimSpace(profile.UserID) != "" {
|
if strings.TrimSpace(profile.UserID) == "" {
|
||||||
data, err := profilesLoadIdentity(profile.CorpID, profile.UserID)
|
data, err := profilesLoadCorp(profile.CorpID)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrTokenDataNotFound) {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
orgData, orgErr := profilesLoadCorp(profile.CorpID)
|
if data == nil {
|
||||||
if orgErr != nil {
|
return nil, ErrTokenDataNotFound
|
||||||
if errors.Is(orgErr, ErrTokenDataNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, orgErr
|
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(orgData.UserID) == "" {
|
if strings.TrimSpace(data.UserID) != "" {
|
||||||
return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", profile.CorpID, ProfileSelector(profile))
|
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 data, nil
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if saveErr := profilesSaveIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil {
|
|
||||||
return nil, saveErr
|
|
||||||
}
|
|
||||||
return orgData, 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) {
|
func normalizeProfilesConfig(cfg *ProfilesConfig) {
|
||||||
@@ -1127,6 +1419,12 @@ func resolveProfileSelection(_ string, cfg *ProfilesConfig, selector string) (*P
|
|||||||
if selector == "" {
|
if selector == "" {
|
||||||
return nil, false, fmt.Errorf("profile selector is empty")
|
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 {
|
if organization, account, compound := ParseIdentitySelector(selector); compound {
|
||||||
corpID, err := resolveOrganizationCorpID(cfg, organization)
|
corpID, err := resolveOrganizationCorpID(cfg, organization)
|
||||||
@@ -1218,6 +1516,12 @@ func resolveProfileDeletionSelection(cfg *ProfilesConfig, selector string) (*Pro
|
|||||||
if selector == "" {
|
if selector == "" {
|
||||||
return nil, false, fmt.Errorf("profile selector is empty")
|
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 {
|
if _, _, compound := ParseIdentitySelector(selector); compound {
|
||||||
return resolveProfileSelection("", cfg, selector)
|
return resolveProfileSelection("", cfg, selector)
|
||||||
}
|
}
|
||||||
@@ -1303,6 +1607,12 @@ func resolveOrganizationDefault(cfg *ProfilesConfig, corpID, displaySelector str
|
|||||||
return p, false, nil
|
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 {
|
if len(profiles) == 1 {
|
||||||
return profiles[0], false, nil
|
return profiles[0], false, nil
|
||||||
}
|
}
|
||||||
@@ -1314,12 +1624,18 @@ func resolveOrganizationDefault(cfg *ProfilesConfig, corpID, displaySelector str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func profileSelectorCandidates(profiles []*Profile) []string {
|
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))
|
candidates := make([]string, 0, len(profiles))
|
||||||
for _, p := range profiles {
|
for _, p := range profiles {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
candidates = append(candidates, ProfileSelector(*p))
|
candidates = append(candidates, storedProfileSelector(cfg, p))
|
||||||
}
|
}
|
||||||
sort.Strings(candidates)
|
sort.Strings(candidates)
|
||||||
return candidates
|
return candidates
|
||||||
@@ -1341,18 +1657,35 @@ func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string {
|
|||||||
if selector == "" {
|
if selector == "" {
|
||||||
return ""
|
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 corpID, userID, exact := ParseIdentitySelector(selector); exact {
|
||||||
if p := findExactProfile(cfg, corpID, userID); p != nil {
|
if p := findExactProfile(cfg, corpID, userID); p != nil {
|
||||||
return ProfileSelector(*p)
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
// Older multi-account writers stored the unresolved profile's local name.
|
||||||
|
// Recover that intent before the organization grammar can capture a
|
||||||
|
// CorpName-like name, then persist the safe selector.
|
||||||
|
if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil {
|
||||||
|
return storedProfileSelector(cfg, profile)
|
||||||
|
}
|
||||||
if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 {
|
if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 {
|
||||||
if exact := exactProfileSelectorForCorp(cfg, selector, cfg.OrgCurrentProfiles[selector]); exact != "" {
|
if exact := exactProfileSelectorForCorp(cfg, selector, cfg.OrgCurrentProfiles[selector]); exact != "" {
|
||||||
return exact
|
return exact
|
||||||
}
|
}
|
||||||
if len(profiles) == 1 {
|
if len(profiles) == 1 {
|
||||||
return ProfileSelector(*profiles[0])
|
return storedProfileSelector(cfg, profiles[0])
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -1360,7 +1693,7 @@ func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string {
|
|||||||
if err != nil || p == nil {
|
if err != nil || p == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return ProfileSelector(*p)
|
return storedProfileSelector(cfg, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setOrgCurrentProfile(cfg *ProfilesConfig, corpID, selector string) {
|
func setOrgCurrentProfile(cfg *ProfilesConfig, corpID, selector string) {
|
||||||
@@ -1445,6 +1778,51 @@ func profilesForCorpID(cfg *ProfilesConfig, corpID string) []*Profile {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func unresolvedProfileForCorp(cfg *ProfilesConfig, corpID string) *Profile {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
corpID = strings.TrimSpace(corpID)
|
||||||
|
for i := range cfg.Profiles {
|
||||||
|
profile := &cfg.Profiles[i]
|
||||||
|
if strings.TrimSpace(profile.CorpID) == corpID && strings.TrimSpace(profile.UserID) == "" {
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unresolvedProfileForLocalName(cfg *ProfilesConfig, name string) *Profile {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var match *Profile
|
||||||
|
for i := range cfg.Profiles {
|
||||||
|
profile := &cfg.Profiles[i]
|
||||||
|
if strings.TrimSpace(profile.UserID) != "" || strings.TrimSpace(profile.Name) != name ||
|
||||||
|
len(profilesForCorpID(cfg, profile.CorpID)) <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if match != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
match = profile
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a blank profile coexists with exact accounts, the organization slot is
|
||||||
|
// that unresolved profile's only canonical credential. Exact identities must
|
||||||
|
// remain in their identity slots and may still become global current without
|
||||||
|
// overwriting the organization slot.
|
||||||
|
func shouldSyncOrganizationMirror(cfg *ProfilesConfig, profile Profile) bool {
|
||||||
|
return strings.TrimSpace(profile.UserID) == "" || unresolvedProfileForCorp(cfg, profile.CorpID) == nil
|
||||||
|
}
|
||||||
|
|
||||||
func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool {
|
func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return false
|
return false
|
||||||
@@ -1453,6 +1831,12 @@ func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool {
|
|||||||
if selector == "" {
|
if selector == "" {
|
||||||
return false
|
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 corpID, userID, exact := ParseIdentitySelector(selector); exact {
|
||||||
if findExactProfile(cfg, corpID, userID) != nil {
|
if findExactProfile(cfg, corpID, userID) != nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -152,4 +153,95 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
|
|||||||
if markCalls != 1 {
|
if markCalls != 1 {
|
||||||
t.Fatalf("terminal refresh marked profile expired %d times, want 1", markCalls)
|
t.Fatalf("terminal refresh marked profile expired %d times, want 1", markCalls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||||
|
return nil, &MCPTokenExchangeError{
|
||||||
|
Code: legacyMCPRefreshRejectedCode,
|
||||||
|
Message: "不合法的临时授权码",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := provider.GetTokenSnapshot(context.Background())
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `dws auth login --profile "corp:user"`) ||
|
||||||
|
!strings.Contains(err.Error(), legacyMCPRefreshRejectedCode) {
|
||||||
|
t.Fatalf("legacy MCP refresh guidance = %v", err)
|
||||||
|
}
|
||||||
|
var exchangeErr *MCPTokenExchangeError
|
||||||
|
if !errors.As(err, &exchangeErr) || !exchangeErr.requiresReauthorization() {
|
||||||
|
t.Fatalf("legacy MCP refresh cause was not preserved: %v", err)
|
||||||
|
}
|
||||||
|
if markCalls != 2 {
|
||||||
|
t.Fatalf("legacy MCP rejection marked profile expired %d times, want 2", markCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
SetRuntimeProfile("External Worker")
|
||||||
|
_, err = provider.GetTokenSnapshot(context.Background())
|
||||||
|
SetRuntimeProfile("")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `dws auth login --profile "External Worker"`) {
|
||||||
|
t.Fatalf("legacy MCP refresh guidance did not quote spaced selector: %v", err)
|
||||||
|
}
|
||||||
|
if markCalls != 3 {
|
||||||
|
t.Fatalf("spaced legacy MCP rejection marked profile expired %d times, want 3", markCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
|
||||||
|
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||||
|
expired := *fixture.blankToken
|
||||||
|
expired.ExpiresAt = time.Now().Add(-time.Hour)
|
||||||
|
expired.RefreshExpAt = time.Now().Add(time.Hour)
|
||||||
|
|
||||||
|
oldLoad := oauthLoadToken
|
||||||
|
oldLoadLocked := oauthLoadTokenLocked
|
||||||
|
oldAcquire := oauthAcquireLock
|
||||||
|
oldRefresh := oauthRefreshToken
|
||||||
|
oldMark := oauthMarkProfile
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
oauthLoadToken = oldLoad
|
||||||
|
oauthLoadTokenLocked = oldLoadLocked
|
||||||
|
oauthAcquireLock = oldAcquire
|
||||||
|
oauthRefreshToken = oldRefresh
|
||||||
|
oauthMarkProfile = oldMark
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
})
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
oauthLoadToken = func(string) (*TokenData, error) { return &expired, nil }
|
||||||
|
oauthLoadTokenLocked = func(string, string) (*TokenData, error) { return &expired, nil }
|
||||||
|
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
|
||||||
|
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||||
|
return nil, &MCPTokenExchangeError{
|
||||||
|
Code: legacyMCPRefreshRejectedCode,
|
||||||
|
Message: "legacy refresh rejected",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var markedSelector string
|
||||||
|
oauthMarkProfile = func(configDir, selector, status string) error {
|
||||||
|
markedSelector = selector
|
||||||
|
return MarkProfileStatus(configDir, selector, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewOAuthProvider(fixture.configDir, nil)
|
||||||
|
_, err := provider.GetTokenSnapshot(context.Background())
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "--profile "+strconv.Quote(fixture.blankSelector)) {
|
||||||
|
t.Fatalf("legacy blank refresh guidance = %v, want selector %q", err, fixture.blankSelector)
|
||||||
|
}
|
||||||
|
if markedSelector != fixture.blankSelector {
|
||||||
|
t.Fatalf("marked selector = %q, want blank %q", markedSelector, fixture.blankSelector)
|
||||||
|
}
|
||||||
|
cfg, loadErr := LoadProfiles(fixture.configDir)
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("LoadProfiles() error = %v", loadErr)
|
||||||
|
}
|
||||||
|
for _, profile := range cfg.Profiles {
|
||||||
|
switch profile.UserID {
|
||||||
|
case "":
|
||||||
|
if profile.Status != ProfileStatusExpired {
|
||||||
|
t.Fatalf("blank profile status = %q, want expired", profile.Status)
|
||||||
|
}
|
||||||
|
case fixture.exactUserID:
|
||||||
|
if profile.Status != ProfileStatusActive {
|
||||||
|
t.Fatalf("exact profile status = %q, want active", profile.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+95
-41
@@ -59,27 +59,28 @@ var (
|
|||||||
profile, _, err := resolveProfileForLoadLocked(configDir, selector)
|
profile, _, err := resolveProfileForLoadLocked(configDir, selector)
|
||||||
return profile, err
|
return profile, err
|
||||||
}
|
}
|
||||||
tokenResolveDeletion = resolveProfileDeletionSelection
|
tokenResolveDeletion = resolveProfileDeletionSelection
|
||||||
tokenResolveSelection = resolveProfileSelection
|
tokenResolveSelection = resolveProfileSelection
|
||||||
tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked
|
tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked
|
||||||
tokenRemoveProfile = removeProfileLocked
|
tokenRemoveProfile = removeProfileLocked
|
||||||
tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked
|
tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked
|
||||||
tokenSyncOrganizationMirror = syncOrganizationTokenMirrorForProfile
|
tokenSyncOrganizationMirror = syncOrganizationTokenMirrorForProfile
|
||||||
tokenLoadProfiles = LoadProfiles
|
tokenLoadProfiles = LoadProfiles
|
||||||
tokenSaveProfiles = SaveProfiles
|
tokenEnsureProfilesMigration = ensureProfilesMigrationLocked
|
||||||
tokenWriteMarker = WriteTokenMarker
|
tokenSaveProfiles = SaveProfiles
|
||||||
tokenWriteManualMarker = WriteManualTokenMarker
|
tokenWriteMarker = WriteTokenMarker
|
||||||
tokenDeleteMarker = DeleteTokenMarker
|
tokenWriteManualMarker = WriteManualTokenMarker
|
||||||
tokenParseURL = url.Parse
|
tokenDeleteMarker = DeleteTokenMarker
|
||||||
tokenNewRequest = http.NewRequestWithContext
|
tokenParseURL = url.Parse
|
||||||
tokenDefaultConfigDir = getDefaultConfigDir
|
tokenNewRequest = http.NewRequestWithContext
|
||||||
tokenLoadData = LoadTokenData
|
tokenDefaultConfigDir = getDefaultConfigDir
|
||||||
tokenRevokeURL = GetRevokeTokenURL
|
tokenLoadData = LoadTokenData
|
||||||
tokenMCPBaseURL = GetMCPBaseURL
|
tokenRevokeURL = GetRevokeTokenURL
|
||||||
tokenLogoutURL = LogoutURL
|
tokenMCPBaseURL = GetMCPBaseURL
|
||||||
tokenLogoutContinueURL = LogoutContinueURL
|
tokenLogoutURL = LogoutURL
|
||||||
tokenLogoutHTTPClient = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
tokenLogoutContinueURL = LogoutContinueURL
|
||||||
tokenRevokeHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
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.
|
// TokenData holds the OAuth token set persisted to disk.
|
||||||
@@ -96,6 +97,10 @@ type TokenData struct {
|
|||||||
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
|
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
|
||||||
UpdatedAt string `json:"updated_at,omitempty"`
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
|
// LegacyOrgScopedProfile is an in-memory destination for an explicitly
|
||||||
|
// matched historical profile whose userId was never resolved. It is never
|
||||||
|
// persisted as token material.
|
||||||
|
LegacyOrgScopedProfile string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsAccessTokenValid returns true if the access token has not expired.
|
// IsAccessTokenValid returns true if the access token has not expired.
|
||||||
@@ -235,16 +240,44 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err := ensureProfilesWritable(cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
runtimeSelector := strings.TrimSpace(RuntimeProfile())
|
runtimeSelector := strings.TrimSpace(RuntimeProfile())
|
||||||
makeCurrent := runtimeSelector == ""
|
persistenceSelector := runtimeSelector
|
||||||
|
if persistenceSelector == "" && userID == "" {
|
||||||
|
persistenceSelector = strings.TrimSpace(data.LegacyOrgScopedProfile)
|
||||||
|
}
|
||||||
|
makeCurrent := persistenceSelector == ""
|
||||||
exactSelector := profileSelector(corpID, userID)
|
exactSelector := profileSelector(corpID, userID)
|
||||||
mirrorOrg := makeCurrent ||
|
|
||||||
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) == exactSelector
|
|
||||||
existingIdentity := profileIndexByIdentity(cfg, corpID, userID) >= 0
|
existingIdentity := profileIndexByIdentity(cfg, corpID, userID) >= 0
|
||||||
upgradesLegacyProfile := !existingIdentity && userID != "" && legacyProfileIndexByCorpID(cfg, corpID) >= 0
|
upgradesLegacyProfile := !existingIdentity && userID != "" &&
|
||||||
|
len(profilesForCorpID(cfg, corpID)) == 1 && legacyProfileIndexByCorpID(cfg, corpID) >= 0
|
||||||
|
preserveUnresolvedOrg := userID != "" && unresolvedProfileForCorp(cfg, corpID) != nil && !upgradesLegacyProfile
|
||||||
|
// An unresolved legacy identity has no exact slot by definition. Its
|
||||||
|
// organization slot is therefore the canonical credential even when an
|
||||||
|
// explicit runtime profile means this login should not become global
|
||||||
|
// current. When it coexists with exact accounts, those accounts remain
|
||||||
|
// identity-slot-only so profile switching or refresh cannot overwrite the
|
||||||
|
// unresolved account's sole credential.
|
||||||
|
mirrorOrg := userID == "" || (!preserveUnresolvedOrg && (makeCurrent ||
|
||||||
|
exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) == exactSelector))
|
||||||
logging.AuthDebug(
|
logging.AuthDebug(
|
||||||
"auth.token.persist.plan",
|
"auth.token.persist.plan",
|
||||||
"corp_id", corpID,
|
"corp_id", corpID,
|
||||||
@@ -255,6 +288,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
|||||||
"upgrades_legacy_profile", upgradesLegacyProfile,
|
"upgrades_legacy_profile", upgradesLegacyProfile,
|
||||||
"profiles_before", len(cfg.Profiles),
|
"profiles_before", len(cfg.Profiles),
|
||||||
"runtime_profile", runtimeSelector,
|
"runtime_profile", runtimeSelector,
|
||||||
|
"persistence_profile", persistenceSelector,
|
||||||
"write_identity_slot", userID != "",
|
"write_identity_slot", userID != "",
|
||||||
"write_org_mirror", mirrorOrg,
|
"write_org_mirror", mirrorOrg,
|
||||||
"write_global_mirror", makeCurrent,
|
"write_global_mirror", makeCurrent,
|
||||||
@@ -277,12 +311,6 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
|||||||
if err := tokenSaveKeychainForIdentity(corpID, userID, data); err != nil {
|
if err := tokenSaveKeychainForIdentity(corpID, userID, data); err != nil {
|
||||||
return rollback(err)
|
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, makeCurrent); err != nil {
|
||||||
return rollback(err)
|
return rollback(err)
|
||||||
@@ -459,7 +487,22 @@ func loadTokenDataForProfileLocked(configDir, profile string) (*TokenData, error
|
|||||||
|
|
||||||
func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) {
|
func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) {
|
||||||
if strings.TrimSpace(profile.UserID) == "" {
|
if strings.TrimSpace(profile.UserID) == "" {
|
||||||
return tokenLoadKeychainForCorpID(profile.CorpID)
|
data, err := tokenLoadKeychainForCorpID(profile.CorpID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
return nil, ErrTokenDataNotFound
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(data.UserID) != "" {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"organization token mirror for corpId %q belongs to userId %q; cannot use it for unresolved profile %q",
|
||||||
|
profile.CorpID,
|
||||||
|
data.UserID,
|
||||||
|
profile.Name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
}
|
}
|
||||||
data, err := tokenLoadKeychainIdentity(profile.CorpID, profile.UserID)
|
data, err := tokenLoadKeychainIdentity(profile.CorpID, profile.UserID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -561,12 +604,20 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
|||||||
removeSelector := removed.CorpID
|
removeSelector := removed.CorpID
|
||||||
orgCurrent := false
|
orgCurrent := false
|
||||||
if exact {
|
if exact {
|
||||||
removeSelector = ProfileSelector(removed)
|
if strings.TrimSpace(removed.UserID) == "" {
|
||||||
orgCurrent = exactProfileSelectorForCorp(
|
// A blank profile is exact only when it was selected by its unique
|
||||||
cfg,
|
// local name. Converting it back to corpId here would turn a
|
||||||
removed.CorpID,
|
// one-profile logout into whole-organization deletion.
|
||||||
cfg.OrgCurrentProfiles[removed.CorpID],
|
removeSelector = effectiveSelector
|
||||||
) == ProfileSelector(removed)
|
orgCurrent = true
|
||||||
|
} else {
|
||||||
|
removeSelector = ProfileSelector(removed)
|
||||||
|
orgCurrent = exactProfileSelectorForCorp(
|
||||||
|
cfg,
|
||||||
|
removed.CorpID,
|
||||||
|
cfg.OrgCurrentProfiles[removed.CorpID],
|
||||||
|
) == ProfileSelector(removed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := tokenRemoveProfile(configDir, removeSelector); err != nil {
|
if _, err := tokenRemoveProfile(configDir, removeSelector); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -592,7 +643,8 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
|||||||
return rollback(loadErr)
|
return rollback(loadErr)
|
||||||
}
|
}
|
||||||
replacementSelector := updated.OrgCurrentProfiles[removed.CorpID]
|
replacementSelector := updated.OrgCurrentProfiles[removed.CorpID]
|
||||||
if exact && replacementSelector != "" {
|
preserveUnresolvedOrg := unresolvedProfileForCorp(updated, removed.CorpID) != nil
|
||||||
|
if exact && replacementSelector != "" && !preserveUnresolvedOrg {
|
||||||
replacement, _, resolveErr := tokenResolveSelection(configDir, updated, replacementSelector)
|
replacement, _, resolveErr := tokenResolveSelection(configDir, updated, replacementSelector)
|
||||||
if resolveErr != nil {
|
if resolveErr != nil {
|
||||||
return rollback(resolveErr)
|
return rollback(resolveErr)
|
||||||
@@ -600,8 +652,10 @@ func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
|||||||
if err := tokenSyncOrganizationMirror(*replacement); err != nil {
|
if err := tokenSyncOrganizationMirror(*replacement); err != nil {
|
||||||
return rollback(err)
|
return rollback(err)
|
||||||
}
|
}
|
||||||
} else if err := tokenDeleteKeychainForCorpID(removed.CorpID); err != nil {
|
} else if !preserveUnresolvedOrg {
|
||||||
return rollback(err)
|
if err := tokenDeleteKeychainForCorpID(removed.CorpID); err != nil {
|
||||||
|
return rollback(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
preserveManualDefault := markerSnapshot.known &&
|
preserveManualDefault := markerSnapshot.known &&
|
||||||
|
|||||||
@@ -210,6 +210,45 @@ func TestExactNonOrgCurrentRefreshIgnoresUnreadableOrgMirror(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageExactRefreshAndSwitchIgnoreUnreadableReservedBlankOrgSlot(t *testing.T) {
|
||||||
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||||
|
fixture := seedLegacyBlankAndExactIdentitySlots(t)
|
||||||
|
if err := os.WriteFile(profileCiphertextPathForTest(fixture.corpID), []byte("corrupt reserved blank slot"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile(reserved blank ciphertext) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
SetRuntimeProfile("")
|
||||||
|
if err := preflightTokenRefreshPersistence(fixture.configDir, fixture.beta); err != nil {
|
||||||
|
t.Fatalf("preflightTokenRefreshPersistence(current exact with reserved blank) error = %v", err)
|
||||||
|
}
|
||||||
|
refreshed := *fixture.beta
|
||||||
|
refreshed.AccessToken = "at_identity_beta_refreshed"
|
||||||
|
if err := SaveTokenData(fixture.configDir, &refreshed); err != nil {
|
||||||
|
t.Fatalf("SaveTokenData(current exact with reserved blank) error = %v", err)
|
||||||
|
}
|
||||||
|
if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" {
|
||||||
|
t.Fatalf("reserved blank slot changed during exact refresh: %q, %v", raw, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected, err := SetCurrentProfile(fixture.configDir, profileSelector(fixture.alpha.CorpID, fixture.alpha.UserID)); err != nil || selected.UserID != fixture.alpha.UserID {
|
||||||
|
t.Fatalf("SetCurrentProfile(exact with reserved blank) = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
if selected, err := UsePreviousProfile(fixture.configDir); err != nil || selected.UserID != fixture.beta.UserID {
|
||||||
|
t.Fatalf("UsePreviousProfile(exact with reserved blank) = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" {
|
||||||
|
t.Fatalf("reserved blank slot changed during exact switches: %q, %v", raw, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blankRefresh := *fixture.blank
|
||||||
|
blankRefresh.LegacyOrgScopedProfile = fixture.blankName
|
||||||
|
SetRuntimeProfile(fixture.blankName)
|
||||||
|
if err := preflightTokenRefreshPersistence(fixture.configDir, &blankRefresh); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "profile token slot") {
|
||||||
|
t.Fatalf("blank refresh preflight error = %v, want unreadable reserved slot", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
|
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
|
||||||
cleanupKeychain(t)
|
cleanupKeychain(t)
|
||||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||||
|
|||||||
+960
-17
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,451 @@
|
|||||||
|
package smoke_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCLISmoke_LegacyAuthSlotsMigrateThroughStatus exercises the upgrade
|
||||||
|
// boundary through a real CLI subprocess. Fixtures are deliberately written
|
||||||
|
// as version-1 JSON and raw historical keychain accounts; using the current
|
||||||
|
// auth SaveTokenData API here would only prove that the new format can read
|
||||||
|
// itself.
|
||||||
|
func TestCLISmoke_LegacyAuthSlotsMigrateThroughStatus(t *testing.T) {
|
||||||
|
t.Run("v1.0.51 global-only slot", func(t *testing.T) {
|
||||||
|
env, configDir := isolatedLegacyAuthCLIEnv(t)
|
||||||
|
corpID, userID := legacyFixtureIdentity(t, "global")
|
||||||
|
accessToken := "fixture-access-global"
|
||||||
|
|
||||||
|
writeLegacyProfilesV1(t, configDir, []legacySmokeProfile{{
|
||||||
|
Name: "Legacy Global Org", CorpID: corpID, CorpName: "Legacy Global Org", UserID: userID,
|
||||||
|
}}, corpID)
|
||||||
|
seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken(
|
||||||
|
t, accessToken, corpID, "Legacy Global Org", "", "",
|
||||||
|
))
|
||||||
|
|
||||||
|
status := runLegacyAuthStatus(t, env)
|
||||||
|
assertAuthenticatedStatus(t, status, corpID, userID)
|
||||||
|
assertRawIdentitySlot(t, corpID, userID, accessToken)
|
||||||
|
assertMigratedProfiles(t, configDir, map[string]string{corpID: userID})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("v1.0.52 migrates every organization", func(t *testing.T) {
|
||||||
|
env, configDir := isolatedLegacyAuthCLIEnv(t)
|
||||||
|
profiles := make([]legacySmokeProfile, 0, 3)
|
||||||
|
wantIdentities := make(map[string]string, 3)
|
||||||
|
wantAccess := make(map[string]string, 3)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
corpID, userID := legacyFixtureIdentity(t, fmt.Sprintf("org-%d", i))
|
||||||
|
corpName := fmt.Sprintf("Legacy Multi Org %d", i+1)
|
||||||
|
accessToken := fmt.Sprintf("fixture-access-multi-%d", i+1)
|
||||||
|
profiles = append(profiles, legacySmokeProfile{
|
||||||
|
Name: corpName, CorpID: corpID, CorpName: corpName, UserID: userID,
|
||||||
|
})
|
||||||
|
wantIdentities[corpID] = userID
|
||||||
|
wantAccess[corpID] = accessToken
|
||||||
|
// v1.0.52 kept one token per organization. The token response could
|
||||||
|
// omit userId even when the v1 profile registry still knew it.
|
||||||
|
seedRawLegacyKeychainSlot(t, legacyOrganizationAccount(corpID), legacyRawToken(
|
||||||
|
t, accessToken, corpID, corpName, "", "",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
current := profiles[1]
|
||||||
|
writeLegacyProfilesV1(t, configDir, profiles, current.CorpID)
|
||||||
|
// v1.0.52 also mirrored the selected organization in the global slot.
|
||||||
|
seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken(
|
||||||
|
t, wantAccess[current.CorpID], current.CorpID, current.CorpName, "", "",
|
||||||
|
))
|
||||||
|
|
||||||
|
status := runLegacyAuthStatus(t, env)
|
||||||
|
assertAuthenticatedStatus(t, status, current.CorpID, current.UserID)
|
||||||
|
for corpID, userID := range wantIdentities {
|
||||||
|
assertRawIdentitySlot(t, corpID, userID, wantAccess[corpID])
|
||||||
|
}
|
||||||
|
assertMigratedProfiles(t, configDir, wantIdentities)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("v1.0.52 unresolved external account remains usable", func(t *testing.T) {
|
||||||
|
env, configDir := isolatedLegacyAuthCLIEnv(t)
|
||||||
|
corpID, _ := legacyFixtureIdentity(t, "external")
|
||||||
|
accessToken := "fixture-access-external"
|
||||||
|
writeLegacyProfilesV1(t, configDir, []legacySmokeProfile{{
|
||||||
|
Name: "Legacy External Org", CorpID: corpID, CorpName: "Legacy External Org",
|
||||||
|
}}, corpID)
|
||||||
|
seedRawLegacyKeychainSlot(t, legacyOrganizationAccount(corpID), legacyRawToken(
|
||||||
|
t, accessToken, corpID, "Legacy External Org", "", "",
|
||||||
|
))
|
||||||
|
seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken(
|
||||||
|
t, accessToken, corpID, "Legacy External Org", "", "",
|
||||||
|
))
|
||||||
|
|
||||||
|
status := runLegacyAuthStatus(t, env)
|
||||||
|
assertAuthenticatedStatus(t, status, corpID, "")
|
||||||
|
assertRawOrganizationSlot(t, corpID, accessToken, "")
|
||||||
|
|
||||||
|
var migrated legacyProfilesDocument
|
||||||
|
readJSONFile(t, filepath.Join(configDir, "profiles.json"), &migrated)
|
||||||
|
if migrated.Version != 2 || len(migrated.Profiles) != 1 {
|
||||||
|
t.Fatalf("migrated unresolved profiles = %#v", migrated)
|
||||||
|
}
|
||||||
|
if migrated.Profiles[0].CorpID != corpID || migrated.Profiles[0].UserID != "" {
|
||||||
|
t.Fatalf("unresolved external identity was changed or discarded: %#v", migrated.Profiles[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("v1.0.53 v2 exact profile repairs global-only slot", func(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
globalTokenUserID bool
|
||||||
|
}{
|
||||||
|
{name: "global token omits user id"},
|
||||||
|
{name: "global token user id matches", globalTokenUserID: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
env, configDir := isolatedLegacyAuthCLIEnv(t)
|
||||||
|
corpID, userID := legacyFixtureIdentity(t, "v2-exact-"+tc.name)
|
||||||
|
profile := legacySmokeProfile{
|
||||||
|
Name: "Half Migrated Exact Account", CorpID: corpID,
|
||||||
|
CorpName: "Half Migrated Exact Org", UserID: userID,
|
||||||
|
}
|
||||||
|
accessToken := "fixture-access-v2-exact-" + strings.ReplaceAll(tc.name, " ", "-")
|
||||||
|
writeHalfMigratedProfilesV2(t, configDir, profile)
|
||||||
|
|
||||||
|
tokenUserID := ""
|
||||||
|
if tc.globalTokenUserID {
|
||||||
|
tokenUserID = userID
|
||||||
|
}
|
||||||
|
// Reproduce the interrupted 1.0.53 migration exactly: the registry is
|
||||||
|
// already v2, while token material still exists only in the global
|
||||||
|
// compatibility account. Do not seed either the org or identity slot.
|
||||||
|
seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken(
|
||||||
|
t, accessToken, corpID, profile.CorpName, tokenUserID, "",
|
||||||
|
))
|
||||||
|
|
||||||
|
exactSelector := corpID + ":" + userID
|
||||||
|
status := runLegacyAuthStatus(t, env, exactSelector)
|
||||||
|
assertAuthenticatedStatus(t, status, corpID, userID)
|
||||||
|
assertRawIdentitySlot(t, corpID, userID, accessToken)
|
||||||
|
assertRawOrganizationSlot(t, corpID, accessToken, tokenUserID)
|
||||||
|
assertMigratedProfiles(t, configDir, map[string]string{corpID: userID})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("v1.0.53 v2 unresolved profile repairs global-only organization slot", func(t *testing.T) {
|
||||||
|
env, configDir := isolatedLegacyAuthCLIEnv(t)
|
||||||
|
corpID, _ := legacyFixtureIdentity(t, "v2-unresolved")
|
||||||
|
profile := legacySmokeProfile{
|
||||||
|
Name: "Half Migrated External Account", CorpID: corpID,
|
||||||
|
CorpName: "Half Migrated External Org",
|
||||||
|
}
|
||||||
|
accessToken := "fixture-access-v2-unresolved"
|
||||||
|
writeHalfMigratedProfilesV2(t, configDir, profile)
|
||||||
|
seedRawLegacyKeychainSlot(t, keychain.AccountToken, legacyRawToken(
|
||||||
|
t, accessToken, corpID, profile.CorpName, "", "",
|
||||||
|
))
|
||||||
|
|
||||||
|
status := runLegacyAuthStatus(t, env, profile.Name)
|
||||||
|
assertAuthenticatedStatus(t, status, corpID, "")
|
||||||
|
assertRawOrganizationSlot(t, corpID, accessToken, "")
|
||||||
|
|
||||||
|
var migrated legacyProfilesDocument
|
||||||
|
readJSONFile(t, filepath.Join(configDir, "profiles.json"), &migrated)
|
||||||
|
if migrated.Version != 2 || len(migrated.Profiles) != 1 {
|
||||||
|
t.Fatalf("repaired unresolved v2 profiles = %#v", migrated)
|
||||||
|
}
|
||||||
|
if migrated.Profiles[0].CorpID != corpID || migrated.Profiles[0].UserID != "" {
|
||||||
|
t.Fatalf("unresolved v2 identity was changed or discarded: %#v", migrated.Profiles[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacySmokeProfile struct {
|
||||||
|
Name string
|
||||||
|
CorpID string
|
||||||
|
CorpName string
|
||||||
|
UserID string
|
||||||
|
UserName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyProfilesDocument struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
CurrentProfile string `json:"currentProfile"`
|
||||||
|
OrgCurrentProfiles map[string]string `json:"orgCurrentProfiles"`
|
||||||
|
Profiles []struct {
|
||||||
|
CorpID string `json:"corpId"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
} `json:"profiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyAuthStatus struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Authenticated bool `json:"authenticated"`
|
||||||
|
Refreshed bool `json:"refreshed"`
|
||||||
|
TokenValid bool `json:"token_valid"`
|
||||||
|
CorpID string `json:"corp_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func isolatedLegacyAuthCLIEnv(t *testing.T) ([]string, string) {
|
||||||
|
t.Helper()
|
||||||
|
env := isolatedCLIEnv(t)
|
||||||
|
configDir := smokeEnvValue(t, env, "DWS_CONFIG_DIR")
|
||||||
|
// File-backed platforms are already isolated by DWS_KEYCHAIN_DIR. Windows
|
||||||
|
// uses HKCU, so give the parent seeder and CLI child the same throwaway
|
||||||
|
// registry namespace as well.
|
||||||
|
namespace := filepath.Join(configDir, "legacy-auth-smoke")
|
||||||
|
t.Setenv(keychain.TestNamespaceEnv, namespace)
|
||||||
|
env = append(env, keychain.TestNamespaceEnv+"="+namespace)
|
||||||
|
sort.Strings(env)
|
||||||
|
|
||||||
|
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
|
||||||
|
t.Fatalf("clear isolated historical keychain: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = keychain.RemoveAuthTokenEntries(keychain.Service)
|
||||||
|
})
|
||||||
|
return env, configDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func smokeEnvValue(t *testing.T, env []string, key string) string {
|
||||||
|
t.Helper()
|
||||||
|
prefix := key + "="
|
||||||
|
for _, entry := range env {
|
||||||
|
if strings.HasPrefix(entry, prefix) {
|
||||||
|
return strings.TrimPrefix(entry, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("CLI environment does not contain %s", key)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyFixtureIdentity(t *testing.T, suffix string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
sum := sha256.Sum256([]byte(t.Name() + "\x00" + suffix))
|
||||||
|
return fmt.Sprintf("ding_fixture_%x", sum[:6]), fmt.Sprintf("fixture-user-%x", sum[6:12])
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLegacyProfilesV1(t *testing.T, configDir string, profiles []legacySmokeProfile, currentCorpID string) {
|
||||||
|
t.Helper()
|
||||||
|
rawProfiles := make([]map[string]any, 0, len(profiles))
|
||||||
|
for _, profile := range profiles {
|
||||||
|
raw := map[string]any{
|
||||||
|
"name": profile.Name,
|
||||||
|
"corpId": profile.CorpID,
|
||||||
|
"corpName": profile.CorpName,
|
||||||
|
"clientId": "fixture-legacy-client",
|
||||||
|
"status": "active",
|
||||||
|
"expiresAt": "2099-01-02T03:04:05Z",
|
||||||
|
}
|
||||||
|
if profile.UserID != "" {
|
||||||
|
raw["userId"] = profile.UserID
|
||||||
|
}
|
||||||
|
if profile.UserName != "" {
|
||||||
|
raw["userName"] = profile.UserName
|
||||||
|
}
|
||||||
|
rawProfiles = append(rawProfiles, raw)
|
||||||
|
}
|
||||||
|
document := map[string]any{
|
||||||
|
"version": 1,
|
||||||
|
"primaryProfile": profiles[0].CorpID,
|
||||||
|
"currentProfile": currentCorpID,
|
||||||
|
"profiles": rawProfiles,
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(document, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal raw v1 profiles fixture: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("create raw v1 config directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "profiles.json"), append(data, '\n'), 0o600); err != nil {
|
||||||
|
t.Fatalf("write raw v1 profiles fixture: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHalfMigratedProfilesV2(t *testing.T, configDir string, profile legacySmokeProfile) {
|
||||||
|
t.Helper()
|
||||||
|
selector := profile.CorpID
|
||||||
|
orgCurrentProfiles := map[string]string{}
|
||||||
|
if profile.UserID != "" {
|
||||||
|
selector = profile.CorpID + ":" + profile.UserID
|
||||||
|
orgCurrentProfiles[profile.CorpID] = selector
|
||||||
|
}
|
||||||
|
rawProfile := map[string]any{
|
||||||
|
"name": profile.Name,
|
||||||
|
"corpId": profile.CorpID,
|
||||||
|
"corpName": profile.CorpName,
|
||||||
|
"clientId": "fixture-legacy-client",
|
||||||
|
"status": "active",
|
||||||
|
"expiresAt": "2099-01-02T03:04:05Z",
|
||||||
|
}
|
||||||
|
if profile.UserID != "" {
|
||||||
|
rawProfile["userId"] = profile.UserID
|
||||||
|
}
|
||||||
|
document := map[string]any{
|
||||||
|
"version": 2,
|
||||||
|
"primaryProfile": selector,
|
||||||
|
"currentProfile": selector,
|
||||||
|
"orgCurrentProfiles": orgCurrentProfiles,
|
||||||
|
"profiles": []map[string]any{rawProfile},
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(document, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal raw half-migrated v2 profiles fixture: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("create raw v2 config directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "profiles.json"), append(data, '\n'), 0o600); err != nil {
|
||||||
|
t.Fatalf("write raw half-migrated v2 profiles fixture: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyRawToken(t *testing.T, accessToken, corpID, corpName, userID, userName string) string {
|
||||||
|
t.Helper()
|
||||||
|
document := map[string]any{
|
||||||
|
"access_token": accessToken,
|
||||||
|
"refresh_token": "fixture-refresh-" + accessToken,
|
||||||
|
"persistent_code": "fixture-persistent-" + accessToken,
|
||||||
|
"expires_at": "2099-01-02T03:04:05Z",
|
||||||
|
"refresh_expires_at": "2099-02-02T03:04:05Z",
|
||||||
|
"corp_id": corpID,
|
||||||
|
"corp_name": corpName,
|
||||||
|
"client_id": "fixture-legacy-client",
|
||||||
|
"source": "mcp",
|
||||||
|
}
|
||||||
|
if userID != "" {
|
||||||
|
document["user_id"] = userID
|
||||||
|
}
|
||||||
|
if userName != "" {
|
||||||
|
document["user_name"] = userName
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(document, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal raw historical token: %v", err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedRawLegacyKeychainSlot(t *testing.T, account, rawToken string) {
|
||||||
|
t.Helper()
|
||||||
|
// Write the old account directly. Do not route setup through auth.SaveTokenData,
|
||||||
|
// which would eagerly create the very identity slot this E2E must observe.
|
||||||
|
if err := keychain.Set(keychain.Service, account, rawToken); err != nil {
|
||||||
|
t.Fatalf("write raw historical keychain account %q: %v", account, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLegacyAuthStatus(t *testing.T, env []string, profile ...string) legacyAuthStatus {
|
||||||
|
t.Helper()
|
||||||
|
if len(profile) > 1 {
|
||||||
|
t.Fatalf("auth status accepts at most one profile selector, got %d", len(profile))
|
||||||
|
}
|
||||||
|
args := []string{"--format", "json", "auth", "status"}
|
||||||
|
if len(profile) == 1 && strings.TrimSpace(profile[0]) != "" {
|
||||||
|
args = append(args, "--profile", profile[0])
|
||||||
|
}
|
||||||
|
stdout, stderr, err := runCLI(t, env, args...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dws auth status failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
|
||||||
|
}
|
||||||
|
var status legacyAuthStatus
|
||||||
|
if err := json.Unmarshal([]byte(stdout), &status); err != nil {
|
||||||
|
t.Fatalf("dws auth status returned invalid JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAuthenticatedStatus(t *testing.T, status legacyAuthStatus, corpID, userID string) {
|
||||||
|
t.Helper()
|
||||||
|
if !status.Success || !status.Authenticated || !status.TokenValid || status.Refreshed {
|
||||||
|
t.Fatalf("auth status did not reuse valid historical login: %#v", status)
|
||||||
|
}
|
||||||
|
if status.CorpID != corpID || status.UserID != userID {
|
||||||
|
t.Fatalf("auth status identity = %q:%q, want %q:%q", status.CorpID, status.UserID, corpID, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRawIdentitySlot(t *testing.T, corpID, userID, accessToken string) {
|
||||||
|
t.Helper()
|
||||||
|
account := legacyIdentityAccount(corpID, userID)
|
||||||
|
raw, err := keychain.Get(keychain.Service, account)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migrated identity account %q: %v", account, err)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
t.Fatalf("migrated identity account %q is missing", account)
|
||||||
|
}
|
||||||
|
var token map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &token); err != nil {
|
||||||
|
t.Fatalf("parse migrated identity account %q: %v", account, err)
|
||||||
|
}
|
||||||
|
if token["access_token"] != accessToken || token["corp_id"] != corpID || token["user_id"] != userID {
|
||||||
|
t.Fatalf("migrated identity account %q = %#v", account, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRawOrganizationSlot(t *testing.T, corpID, accessToken, userID string) {
|
||||||
|
t.Helper()
|
||||||
|
account := legacyOrganizationAccount(corpID)
|
||||||
|
raw, err := keychain.Get(keychain.Service, account)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read organization account %q: %v", account, err)
|
||||||
|
}
|
||||||
|
var token map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &token); err != nil {
|
||||||
|
t.Fatalf("parse organization account %q: %v", account, err)
|
||||||
|
}
|
||||||
|
if token["access_token"] != accessToken || token["corp_id"] != corpID {
|
||||||
|
t.Fatalf("organization account %q = %#v", account, token)
|
||||||
|
}
|
||||||
|
if got, _ := token["user_id"].(string); got != userID {
|
||||||
|
t.Fatalf("organization account %q user_id = %q, want %q", account, got, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertMigratedProfiles(t *testing.T, configDir string, identities map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
var migrated legacyProfilesDocument
|
||||||
|
readJSONFile(t, filepath.Join(configDir, "profiles.json"), &migrated)
|
||||||
|
if migrated.Version != 2 {
|
||||||
|
t.Fatalf("profiles version = %d, want 2", migrated.Version)
|
||||||
|
}
|
||||||
|
if len(migrated.Profiles) != len(identities) {
|
||||||
|
t.Fatalf("migrated profile count = %d, want %d", len(migrated.Profiles), len(identities))
|
||||||
|
}
|
||||||
|
for corpID, userID := range identities {
|
||||||
|
selector := corpID + ":" + userID
|
||||||
|
if migrated.OrgCurrentProfiles[corpID] != selector {
|
||||||
|
t.Errorf("orgCurrentProfiles[%q] = %q, want %q", corpID, migrated.OrgCurrentProfiles[corpID], selector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSONFile(t *testing.T, path string, target any) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, target); err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyOrganizationAccount(corpID string) string {
|
||||||
|
return keychain.AccountToken + ":" + strings.TrimSpace(corpID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyIdentityAccount(corpID, userID string) string {
|
||||||
|
identity := strings.TrimSpace(corpID) + "\x00" + strings.TrimSpace(userID)
|
||||||
|
return fmt.Sprintf("%s:id:%x", keychain.AccountToken, sha256.Sum256([]byte(identity)))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user