From d81b4366081e2ac2266ef5af468c1b1ee3e2537a Mon Sep 17 00:00:00 2001 From: liurunzhou Date: Thu, 4 Jun 2026 11:06:46 +0800 Subject: [PATCH] feat(multi-user): port park-cli multi-user surface (Users[]/CurrentUser, holder-gate, R2-aware config, sidecar sweep) into lark-cli --- cmd/auth/auth.go | 37 +- cmd/auth/list.go | 44 +- cmd/auth/list_r2_test.go | 86 +++ cmd/auth/login.go | 321 ++++++---- cmd/auth/login_config_test.go | 176 +++++- cmd/auth/login_holder.go | 251 ++++++++ cmd/auth/login_holder_gate_test.go | 181 ++++++ cmd/auth/login_holder_sanitize_test.go | 193 ++++++ cmd/auth/login_holder_test.go | 285 +++++++++ cmd/auth/login_holder_username_test.go | 121 ++++ cmd/auth/login_legacy_workflow_test.go | 568 ++++++++++++++++++ cmd/auth/login_new_user_test.go | 130 ++++ cmd/auth/login_postflock_r2_test.go | 78 +++ cmd/auth/login_result.go | 76 ++- cmd/auth/login_result_test.go | 257 ++++++++ cmd/auth/login_step8_test.go | 276 +++++++++ cmd/auth/login_test.go | 36 +- cmd/auth/logout.go | 105 +++- cmd/auth/logout_test.go | 256 ++++++++ cmd/auth/users.go | 37 ++ cmd/auth/users_list.go | 120 ++++ cmd/auth/users_logout.go | 165 +++++ cmd/auth/users_logout_active_user_test.go | 106 ++++ .../users_logout_profile_fallback_test.go | 147 +++++ cmd/auth/users_test.go | 253 ++++++++ cmd/auth/users_use.go | 140 +++++ cmd/auth/users_use_profile_fallback_test.go | 131 ++++ cmd/bootstrap.go | 59 +- cmd/bootstrap_user_test.go | 199 ++++++ cmd/config/bind.go | 118 +++- cmd/config/bind_lock_path_test.go | 129 ++++ cmd/config/bind_r2_test.go | 137 +++++ cmd/config/bind_uat_cleanup_test.go | 185 ++++++ cmd/config/config_test.go | 7 + cmd/config/default_as.go | 42 +- cmd/config/init.go | 146 ++++- cmd/config/init_currentuser_test.go | 113 ++++ cmd/config/init_flock_test.go | 201 +++++++ cmd/config/remove.go | 23 +- cmd/config/remove_sweep_test.go | 245 ++++++++ cmd/config/show.go | 32 +- cmd/config/show_path_lock_skip_test.go | 178 ++++++ cmd/config/show_r2_test.go | 137 +++++ cmd/config/strict_mode.go | 34 +- cmd/global_flags.go | 23 +- cmd/global_flags_test.go | 37 ++ cmd/profile/add.go | 19 +- cmd/profile/list.go | 18 +- cmd/profile/profile_list_currentuser_test.go | 93 +++ .../profile_remove_self_toggle_test.go | 93 +++ cmd/profile/profile_test.go | 7 +- cmd/profile/remove.go | 28 +- cmd/profile/remove_sweep_test.go | 131 ++++ cmd/profile/rename.go | 12 + cmd/profile/use.go | 13 + internal/auth/context.go | 113 ++++ internal/auth/context_test.go | 215 +++++++ internal/auth/purge.go | 68 +++ internal/auth/purge_test.go | 193 ++++++ internal/auth/storage.go | 452 ++++++++++++++ internal/auth/storage_test.go | 312 ++++++++++ internal/auth/user_index.go | 357 +++++++++++ internal/auth/user_index_test.go | 543 +++++++++++++++++ internal/auth/user_profile.go | 125 ++++ internal/auth/user_profile_test.go | 306 ++++++++++ internal/cmdutil/factory.go | 59 +- internal/cmdutil/factory_default.go | 44 +- .../cmdutil/factory_default_step7_test.go | 112 ++++ internal/core/config.go | 356 +++++++++-- internal/core/config_multiuser_test.go | 375 ++++++++++++ internal/core/config_step6_test.go | 463 ++++++++++++++ internal/core/config_test.go | 18 +- internal/core/errors.go | 25 + internal/core/notconfigured.go | 41 +- .../core/passthrough_or_notconfigured_test.go | 104 ++++ internal/core/require_config_r2_test.go | 73 +++ internal/core/resolve_login_test.go | 122 ++++ .../decorate_user_resolution_test.go | 105 ++++ internal/credential/default_provider.go | 84 ++- .../credential/default_provider_r2_test.go | 46 ++ .../credential/default_provider_step7_test.go | 180 ++++++ internal/credential/integration_test.go | 19 +- internal/envvars/envvars.go | 5 + internal/errcompat/promote.go | 12 +- internal/errcompat/promote_r2_test.go | 44 ++ internal/migrate/migrate.go | 126 ++++ internal/migrate/migrate_test.go | 331 ++++++++++ internal/validate/sanitize.go | 32 +- skills/lark-shared/SKILL.md | 56 ++ 89 files changed, 12139 insertions(+), 412 deletions(-) create mode 100644 cmd/auth/list_r2_test.go create mode 100644 cmd/auth/login_holder.go create mode 100644 cmd/auth/login_holder_gate_test.go create mode 100644 cmd/auth/login_holder_sanitize_test.go create mode 100644 cmd/auth/login_holder_test.go create mode 100644 cmd/auth/login_holder_username_test.go create mode 100644 cmd/auth/login_legacy_workflow_test.go create mode 100644 cmd/auth/login_new_user_test.go create mode 100644 cmd/auth/login_postflock_r2_test.go create mode 100644 cmd/auth/login_step8_test.go create mode 100644 cmd/auth/logout_test.go create mode 100644 cmd/auth/users.go create mode 100644 cmd/auth/users_list.go create mode 100644 cmd/auth/users_logout.go create mode 100644 cmd/auth/users_logout_active_user_test.go create mode 100644 cmd/auth/users_logout_profile_fallback_test.go create mode 100644 cmd/auth/users_test.go create mode 100644 cmd/auth/users_use.go create mode 100644 cmd/auth/users_use_profile_fallback_test.go create mode 100644 cmd/bootstrap_user_test.go create mode 100644 cmd/config/bind_lock_path_test.go create mode 100644 cmd/config/bind_r2_test.go create mode 100644 cmd/config/bind_uat_cleanup_test.go create mode 100644 cmd/config/init_currentuser_test.go create mode 100644 cmd/config/init_flock_test.go create mode 100644 cmd/config/remove_sweep_test.go create mode 100644 cmd/config/show_path_lock_skip_test.go create mode 100644 cmd/config/show_r2_test.go create mode 100644 cmd/profile/profile_list_currentuser_test.go create mode 100644 cmd/profile/profile_remove_self_toggle_test.go create mode 100644 cmd/profile/remove_sweep_test.go create mode 100644 internal/auth/context.go create mode 100644 internal/auth/context_test.go create mode 100644 internal/auth/purge.go create mode 100644 internal/auth/purge_test.go create mode 100644 internal/auth/storage.go create mode 100644 internal/auth/storage_test.go create mode 100644 internal/auth/user_index.go create mode 100644 internal/auth/user_index_test.go create mode 100644 internal/auth/user_profile.go create mode 100644 internal/auth/user_profile_test.go create mode 100644 internal/cmdutil/factory_default_step7_test.go create mode 100644 internal/core/config_multiuser_test.go create mode 100644 internal/core/config_step6_test.go create mode 100644 internal/core/passthrough_or_notconfigured_test.go create mode 100644 internal/core/require_config_r2_test.go create mode 100644 internal/core/resolve_login_test.go create mode 100644 internal/credential/decorate_user_resolution_test.go create mode 100644 internal/credential/default_provider_r2_test.go create mode 100644 internal/credential/default_provider_step7_test.go create mode 100644 internal/errcompat/promote_r2_test.go create mode 100644 internal/migrate/migrate.go create mode 100644 internal/migrate/migrate_test.go diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 288f16de5..2e90baab2 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -26,13 +26,9 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { Use: "auth", Short: "OAuth credentials and authorization management", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first - // PersistentPreRun[E] found walking up the chain, so the root-level - // SilenceUsage=true would be skipped without this line. + // cobra stops at the first PersistentPreRun[E] walking up; root's SilenceUsage=true is skipped without this. cmd.SilenceUsage = true - // cmd.Name() returns the subcommand name (e.g. "login"), not "auth". - // Pass "auth" as a literal so the error message reads - // `"auth" is not supported: ...` + // Pass "auth" literally; cmd.Name() is the subcommand. return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth") }, } @@ -45,6 +41,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdAuthList(f, nil)) cmd.AddCommand(NewCmdAuthCheck(f, nil)) cmd.AddCommand(NewCmdAuthQRCode(f, nil)) + cmd.AddCommand(NewCmdAuthUsers(f)) return cmd } @@ -53,38 +50,44 @@ type userInfoResponse struct { Code int `json:"code"` Msg string `json:"msg"` Data struct { - OpenID string `json:"open_id"` - Name string `json:"name"` + OpenID string `json:"open_id"` + UnionID string `json:"union_id"` + Name string `json:"name"` } `json:"data"` } -// getUserInfo fetches the current user's OpenID and name using the given access token. -func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, name string, err error) { +// getUserInfoFn is the test seam for getUserInfo (mirrors getAppInfoFn). +var getUserInfoFn = getUserInfo + +// getUserInfo fetches the current user's OpenID, UnionID, and name. +// UnionID is captured for cross-app reconciliation in per-user state files; +// absence is non-fatal. +func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, unionId, name string, err error) { apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: larkauth.PathUserInfoV1, SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}, }, larkcore.WithUserAccessToken(accessToken)) if err != nil { - return "", "", err + return "", "", "", err } var resp userInfoResponse if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { - return "", "", fmt.Errorf("failed to parse user info: %w", err) + return "", "", "", fmt.Errorf("failed to parse user info: %w", err) } if resp.Code != 0 { - return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg) + return "", "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg) } if resp.Data.OpenID == "" { - return "", "", fmt.Errorf("failed to get user info: missing open_id in response") + return "", "", "", fmt.Errorf("failed to get user info: missing open_id in response") } name = resp.Data.Name if name == "" { name = "(unknown)" } - return resp.Data.OpenID, name, nil + return resp.Data.OpenID, resp.Data.UnionID, name, nil } // appInfo contains application information (owner, scopes). @@ -111,9 +114,7 @@ type appInfoResponse struct { } `json:"data"` } -// getAppInfoFn is the package-level seam used by callers (scopes.go) so tests -// can substitute a fake without standing up a full SDK + httpmock pipeline. -// Mirrors the pollDeviceToken pattern in login.go. +// getAppInfoFn is the test seam for getAppInfo. var getAppInfoFn = getAppInfo // getAppInfo queries app info from the Lark API. diff --git a/cmd/auth/list.go b/cmd/auth/list.go index ff682f824..3291413c7 100644 --- a/cmd/auth/list.go +++ b/cmd/auth/list.go @@ -6,6 +6,8 @@ package auth import ( "errors" "fmt" + "io" + "os" "github.com/spf13/cobra" @@ -42,20 +44,22 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co func authListRun(opts *ListOptions) error { f := opts.Factory - multi, _ := core.LoadMultiAppConfig() - if multi == nil || len(multi.Apps) == 0 { - // auth list is a read-only probe; the "configured but no users" - // branch below already returns exit 0 with a stderr hint, so we - // keep the same contract here. We still want the hint to be - // workspace-aware, so we pull the message+hint out of - // NotConfiguredError() instead of hard-coding it. - var cfgErr *core.ConfigError - if errors.As(core.NotConfiguredError(), &cfgErr) { - fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message) - if cfgErr.Hint != "" { - fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint) - } + multi, err := core.LoadMultiAppConfig() + if err != nil { + // auth list is a read-only probe — when the file is simply + // missing we keep exit 0 with a workspace-aware stderr hint. + // Anything else (R2 forward-incompat, parse error) must still + // surface with its envelope intact: a *core.ConfigError must + // reach the dispatcher with its upgrade Hint preserved. + if errors.Is(err, os.ErrNotExist) { + printNotConfiguredHint(f.IOStreams.ErrOut) + return nil } + return core.PassThroughOrNotConfigured(err) + } + if multi == nil || len(multi.Apps) == 0 { + // Configured but empty Apps[] — read-only probe, exit 0 with hint. + printNotConfiguredHint(f.IOStreams.ErrOut) return nil } @@ -82,3 +86,17 @@ func authListRun(opts *ListOptions) error { output.PrintJson(f.IOStreams.Out, items) return nil } + +// printNotConfiguredHint emits the workspace-aware "not configured / +// no users" hint to stderr without failing the read-only probe. +// Pulls Message+Hint from core.NotConfiguredError() so a single source +// of truth governs both the error path and the soft-success path. +func printNotConfiguredHint(errOut io.Writer) { + var cfgErr *core.ConfigError + if errors.As(core.NotConfiguredError(), &cfgErr) { + fmt.Fprintln(errOut, cfgErr.Message) + if cfgErr.Hint != "" { + fmt.Fprintln(errOut, " hint: "+cfgErr.Hint) + } + } +} diff --git a/cmd/auth/list_r2_test.go b/cmd/auth/list_r2_test.go new file mode 100644 index 000000000..5ced32b12 --- /dev/null +++ b/cmd/auth/list_r2_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zalando/go-keyring" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Regression: a config.json written by a newer lark-cli (schemaVersion > +// CurrentSchemaVersion) must surface its R2 *core.ConfigError + upgrade +// Hint through `auth list` and `auth users list`. Pre-fix, both paths +// did `multi, _ := core.LoadMultiAppConfig()` and silently dropped the +// error — `auth list` continued on with multi==nil and printed the +// generic "not configured" hint, steering operators (and AI agents) +// toward `config init --new`, which would overwrite the fields the +// newer binary populated. +// +// Mirrors the contract pinned at +// internal/credential/default_provider_r2_test.go (ResolveAccount) and +// internal/errcompat/promote_r2_test.go (dispatcher promotion). +func TestAuthListRun_R2ForwardIncompat_PassesUpgradeHint(t *testing.T) { + keyring.MockInit() + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + t.Setenv("HOME", t.TempDir()) + + future := []byte(`{"schemaVersion":99,"apps":[{"appId":"cli_x","appSecret":"s","brand":"feishu","users":[]}]}` + "\n") + if err := os.WriteFile(filepath.Join(dir, "config.json"), future, 0600); err != nil { + t.Fatalf("seed config.json: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := authListRun(&ListOptions{Factory: f}) + if err == nil { + t.Fatal("auth list must surface R2 error from a future schema, got nil — would steer operator to config init --new") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *core.ConfigError preserved by PassThroughOrNotConfigured, got %T: %v", err, err) + } + if !strings.Contains(cfgErr.Message, "newer lark-cli") { + t.Errorf("R2 message lost; got %q", cfgErr.Message) + } + if !strings.Contains(cfgErr.Hint, "upgrade lark-cli") { + t.Errorf("R2 upgrade hint lost; got %q", cfgErr.Hint) + } +} + +// Same contract for `auth users list`. Same root cause pre-fix. +func TestAuthUsersListRun_R2ForwardIncompat_PassesUpgradeHint(t *testing.T) { + keyring.MockInit() + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + t.Setenv("HOME", t.TempDir()) + + future := []byte(`{"schemaVersion":99,"apps":[{"appId":"cli_x","appSecret":"s","brand":"feishu","users":[]}]}` + "\n") + if err := os.WriteFile(filepath.Join(dir, "config.json"), future, 0600); err != nil { + t.Fatalf("seed config.json: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := authUsersListRun(&UsersListOptions{Factory: f}) + if err == nil { + t.Fatal("auth users list must surface R2 error from a future schema, got nil") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *core.ConfigError, got %T: %v", err, err) + } + if !strings.Contains(cfgErr.Message, "newer lark-cli") { + t.Errorf("R2 message lost; got %q", cfgErr.Message) + } + if !strings.Contains(cfgErr.Hint, "upgrade lark-cli") { + t.Errorf("R2 upgrade hint lost; got %q", cfgErr.Hint) + } +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 5f3a959e7..77f2f71d9 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "io" "sort" "strings" "time" @@ -118,12 +119,27 @@ func completeDomain(toComplete string) []string { func authLoginRun(opts *LoginOptions) error { f := opts.Factory - config, err := f.Config() + // Profile-rung-only resolve. The default Factory.Config() path enforces + // the user-rung strict selector — fine for "use this user to make a + // call", but the very point of `auth login --user ou_new` is to ADD a + // new user to the profile. Going through f.Config() would error + // "user 'ou_new' not found in profile 'prod'" before the device flow + // even starts, making the new-user login path unreachable. + // + // verifyHolder (post-authorization, see login_holder.go) is the right + // place to reconcile --user against the freshly-issued open_id, and it + // already accepts brand-new open_ids by design. So login uses a + // profile-only resolver, leaving UserOpenId empty until the upstream + // echo arrives. + multi, err := core.LoadMultiAppConfig() + if err != nil { + return core.PassThroughOrNotConfigured(err) + } + config, err := core.ResolveProfileConfigForLogin(multi, f.Keychain, f.Invocation.Profile) if err != nil { return err } - // Determine UI language from saved config var lang i18n.Lang if multi, _ := core.LoadMultiAppConfig(); multi != nil { if app := multi.FindApp(config.ProfileName); app != nil { @@ -138,7 +154,6 @@ func authLoginRun(opts *LoginOptions) error { } } - // --device-code: resume polling from a previous --no-wait call if opts.DeviceCode != "" { return authLoginPollDeviceCode(opts, config, msg, log) } @@ -146,7 +161,7 @@ func authLoginRun(opts *LoginOptions) error { selectedDomains := opts.Domains scopeLevel := "" // "common" or "all" (from interactive mode) - // Expand --domain all to all available domains (from_meta projects + shortcut services) + // Expand --domain all to all available domains for _, d := range selectedDomains { if strings.EqualFold(d, "all") { selectedDomains = sortedKnownDomains(config.Brand) @@ -154,7 +169,6 @@ func authLoginRun(opts *LoginOptions) error { } } - // Validate domain names and suggest corrections for unknown ones if len(selectedDomains) > 0 { knownDomains := allKnownDomains(config.Brand) for _, d := range selectedDomains { @@ -205,16 +219,11 @@ func authLoginRun(opts *LoginOptions) error { } } - // Normalize --scope so users can pass either OAuth-standard space-separated - // values or the more natural comma-separated list. RFC 6749 §3.3 mandates - // space-delimited scopes in the wire request, so the device authorization - // endpoint rejects raw "a,b" strings as a single malformed scope. + // Normalize --scope: accept comma- or space-separated input and emit the + // space-delimited form RFC 6749 §3.3 mandates on the wire. finalScope := normalizeScopeInput(opts.Scope) - // Resolve scopes from domain/permission filters and merge with --scope. - // --scope, --domain, and --recommend combine additively so callers can, - // for example, request all `docs` scopes plus a few specific `drive` - // scopes in a single command. + // --scope, --domain, and --recommend combine additively. if len(selectedDomains) > 0 || opts.Recommend { var candidateScopes []string if len(selectedDomains) > 0 { @@ -233,7 +242,6 @@ func authLoginRun(opts *LoginOptions) error { return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options") } - // Merge --scope additively with the resolved domain scopes. merged := make(map[string]bool, len(candidateScopes)+len(strings.Fields(finalScope))) for _, s := range candidateScopes { merged[s] = true @@ -244,9 +252,7 @@ func authLoginRun(opts *LoginOptions) error { finalScope = joinSortedScopeSet(merged) } - // Apply --exclude on top of the resolved scope set. We honour exclude - // regardless of whether scopes came from --scope, --domain, --recommend, - // or any combination thereof. + // Apply --exclude on top of the resolved scope set. if len(opts.Exclude) > 0 { excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude) if len(unknown) > 0 { @@ -260,7 +266,7 @@ func authLoginRun(opts *LoginOptions) error { } } - // Step 1: Request device authorization + // Request device authorization. httpClient, err := f.HttpClient() if err != nil { return err @@ -295,11 +301,8 @@ func authLoginRun(opts *LoginOptions) error { return nil } - // Step 2: Show user code and verification URL. - // Both branches surface AgentTimeoutHint, but on different channels: - // JSON mode embeds it as a structured field (so an agent that captures - // stdout into a JSON parser sees it without stream-mixing surprises), - // text mode prints to stderr (alongside the URL prompt). + // Show user code and verification URL. JSON mode embeds AgentTimeoutHint + // as a structured field; text mode prints to stderr alongside the URL. if opts.JSON { data := map[string]interface{}{ "event": "device_authorization", @@ -320,7 +323,7 @@ func authLoginRun(opts *LoginOptions) error { fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint) } - // Step 3: Poll for token + // Poll for token. log(msg.WaitingAuth) result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) @@ -343,47 +346,61 @@ func authLoginRun(opts *LoginOptions) error { return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned") } - // Step 6: Get user info log(msg.AuthSuccess) sdk, err := f.LarkClient() if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err) } - openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) + openId, unionId, userName, err := getUserInfoFn(opts.Ctx, sdk, result.Token.AccessToken) if err != nil { return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err) } + // Holder verification — see login_holder.go for the full contract. + // Hard abort on --user / env mismatch (operator declared an explicit + // target). Soft advisory + proceed on AppConfig.CurrentUser mismatch + // so the legacy "logout && login as someone else" workflow keeps + // working — Bob is appended, Alice stays active, the WARN tells the + // operator how to switch. holderWarning (when non-nil) is threaded + // through writeLoginSuccess / handleLoginScopeIssue so JSON consumers + // see the structured `holder_mismatch_warning` field too. + holderWarning, err := enforceLoginHolderGate(f, config.ProfileName, openId, userName, f.IOStreams.ErrOut) + if err != nil { + return err + } + scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope) - // Step 7: Store token - now := time.Now().UnixMilli() + // Snapshot any prior token for the same slot so we can restore-on-rollback + // if subsequent steps fail. The slot may have held a different user's + // still-valid token, so we restore rather than delete. + priorToken := larkauth.GetStoredToken(config.AppID, openId) + now := time.Now() storedToken := &larkauth.StoredUAToken{ UserOpenId: openId, AppId: config.AppID, AccessToken: result.Token.AccessToken, RefreshToken: result.Token.RefreshToken, - ExpiresAt: now + int64(result.Token.ExpiresIn)*1000, - RefreshExpiresAt: now + int64(result.Token.RefreshExpiresIn)*1000, + ExpiresAt: now.UnixMilli() + int64(result.Token.ExpiresIn)*1000, + RefreshExpiresAt: now.UnixMilli() + int64(result.Token.RefreshExpiresIn)*1000, Scope: result.Token.Scope, - GrantedAt: now, + GrantedAt: now.UnixMilli(), } if err := larkauth.SetStoredToken(storedToken); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save token: %v", err).WithCause(err) } - // Step 8: Update config — overwrite Users to single user, clean old tokens - if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil { - _ = larkauth.RemoveStoredToken(config.AppID, openId) + // Upsert user; never silently switch CurrentUser (see syncLoginUserToProfile). + if err := syncLoginUserToProfile(loginRoot(), config.ProfileName, config.AppID, openId, unionId, userName, result.Token.Scope, now, f.IOStreams.ErrOut); err != nil { + restoreStoredToken(config.AppID, openId, priorToken) return err } if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil { - return handleLoginScopeIssue(opts, msg, f, issue, openId, userName) + return handleLoginScopeIssue(opts, msg, f, issue, openId, userName, holderWarning) } - writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary) - return nil + return writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary, holderWarning) } // authLoginPollDeviceCode resumes the device flow by polling with a device code @@ -404,9 +421,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err) } } - // Skip the stderr hint in JSON mode — the --no-wait call that issued the - // device_code already returned the hint as a JSON field, and writing - // text to stderr would pollute consumers that combine streams via 2>&1. + // JSON mode already returned the hint as a JSON field on the issuing + // --no-wait call; suppress the stderr text to keep 2>&1 output clean. if !opts.JSON { fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint) } @@ -425,98 +441,208 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned") } - // Get user info log(msg.AuthSuccess) sdk, err := f.LarkClient() if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err) } - openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) + openId, unionId, userName, err := getUserInfoFn(opts.Ctx, sdk, result.Token.AccessToken) if err != nil { return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err) } + // Holder verification (mirrors authLoginRun's pre-write gate). + holderWarning, err := enforceLoginHolderGate(f, config.ProfileName, openId, userName, f.IOStreams.ErrOut) + if err != nil { + return err + } + scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope) - // Store token - now := time.Now().UnixMilli() + // Store token (snapshot prior for restore-on-rollback). + priorToken := larkauth.GetStoredToken(config.AppID, openId) + now := time.Now() storedToken := &larkauth.StoredUAToken{ UserOpenId: openId, AppId: config.AppID, AccessToken: result.Token.AccessToken, RefreshToken: result.Token.RefreshToken, - ExpiresAt: now + int64(result.Token.ExpiresIn)*1000, - RefreshExpiresAt: now + int64(result.Token.RefreshExpiresIn)*1000, + ExpiresAt: now.UnixMilli() + int64(result.Token.ExpiresIn)*1000, + RefreshExpiresAt: now.UnixMilli() + int64(result.Token.RefreshExpiresIn)*1000, Scope: result.Token.Scope, - GrantedAt: now, + GrantedAt: now.UnixMilli(), } if err := larkauth.SetStoredToken(storedToken); err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "failed to save token: %v", err).WithCause(err) } - // Update config — overwrite Users to single user, clean old tokens - if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil { - _ = larkauth.RemoveStoredToken(config.AppID, openId) - return errs.NewInternalError(errs.SubtypeSDKError, "failed to update login profile: %v", err).WithCause(err) + // Update config — upsert user, never silently switch CurrentUser. + if err := syncLoginUserToProfile(loginRoot(), config.ProfileName, config.AppID, openId, unionId, userName, result.Token.Scope, now, f.IOStreams.ErrOut); err != nil { + restoreStoredToken(config.AppID, openId, priorToken) + return err } if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil { - return handleLoginScopeIssue(opts, msg, f, issue, openId, userName) + return handleLoginScopeIssue(opts, msg, f, issue, openId, userName, holderWarning) } - writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary) - return nil + return writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary, holderWarning) } -// syncLoginUserToProfile persists the logged-in user info into the named profile. -func syncLoginUserToProfile(profileName, appID, openID, userName string) error { +// syncLoginUserToProfile persists the logged-in user info into the named +// profile, using upsert semantics so a multi-user install can hold multiple +// AppUser records side by side. +// +// CurrentUser is only set when the profile has no users yet, the prior +// CurrentUser was empty, or the prior CurrentUser was already this same +// openID — re-logging in as Alice when CurrentUser is Bob does NOT silently +// switch the active user. +// +// Sidecar writes (UserProfile JSON, user activity index) are best-effort with +// a stderr warning on failure; neither is load-bearing for `auth status` and +// both rebuild from a re-login. +// +// Concurrency: a 30s flock via root.Locks(SingleUser()).Acquire serialises the +// whole config R-M-W against any other process running login concurrently, +// preventing the lost-update hazard between two `auth login` invocations. +func syncLoginUserToProfile( + root larkauth.Root, + profileName, appID, openID, unionID, userName, grantedScope string, + now time.Time, + errOut io.Writer, +) error { + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "login: acquire flock: %v", err).WithCause(err) + } + defer lk.Release() + multi, err := core.LoadMultiAppConfig() if err != nil { - return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err) + // R2 transparency: a forward-incompat *core.ConfigError must reach + // the dispatcher with its upgrade Hint intact even when the file + // became newer-than-us between the pre-login resolve and this + // post-flock reload. Collapsing to a generic storage error would + // route the operator to retry/diagnostics instead of the upgrade. + return core.PassThroughOrNotConfigured(err) + } + idx := multi.FindAppIndex(profileName) + if idx < 0 { + // Profile vanished between resolve and post-flock reload — a + // concurrent `profile remove` raced us. SubtypeNotConfigured would + // invite re-init; this is a transient invalid-config state, not + // "no config". + return errs.NewConfigError(errs.SubtypeInvalidConfig, "profile %q not found in config", profileName) + } + app := &multi.Apps[idx] + + priorCurrent := app.CurrentUser + priorEmpty := len(app.Users) == 0 + + // Upsert the user row. Position-stable: existing rows keep their slot so + // `auth users list` output stays reproducible across re-logins. + nowPtr := now + userIdx := app.FindUserIndex(openID) + if userIdx < 0 { + newUser := core.AppUser{ + UserOpenId: openID, + UserName: userName, + UnionId: unionID, + CachedAt: &nowPtr, + FirstAuthAt: &nowPtr, + LastUsed: &nowPtr, + LastScopes: larkauth.NormaliseScopes(strings.Fields(grantedScope)), + } + app.Users = append(app.Users, newUser) + } else { + u := &app.Users[userIdx] + u.UserOpenId = openID + u.UserName = userName + if unionID != "" { + u.UnionId = unionID + } + u.CachedAt = &nowPtr + // FirstAuthAt is sticky — only stamp it the first time. + if u.FirstAuthAt == nil { + u.FirstAuthAt = &nowPtr + } + u.LastUsed = &nowPtr + u.LastScopes = larkauth.NormaliseScopes(strings.Fields(grantedScope)) } - app := findProfileByName(multi, profileName) - if app == nil { - return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName) + // Never silently switch the active user: only set CurrentUser on first + // user, when prior was empty, or when this is the same user re-logging in. + if priorEmpty || priorCurrent == "" || priorCurrent == openID { + app.CurrentUser = openID } - oldUsers := append([]core.AppUser(nil), app.Users...) - app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}} if err := core.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err) } - for _, oldUser := range oldUsers { - if oldUser.UserOpenId != openID { - _ = larkauth.RemoveStoredToken(appID, oldUser.UserOpenId) - } + // Sidecar writes — best-effort, warn-only. Transient I/O hiccups self-heal + // on the next login. + ctx := larkauth.ForUser(appID, openID) + if err := larkauth.SaveUserProfileFor(root, ctx, larkauth.UserProfile{ + UserOpenId: openID, + UnionId: unionID, + UserName: userName, + CachedAt: now, + FirstAuthAt: now, + }); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] auth login: write user profile: %v\n", err) + } + if err := larkauth.RecordUserActivity(root, ctx, strings.Fields(grantedScope)); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] auth login: record user activity: %v\n", err) } return nil } -// findProfileByName returns the AppConfig matching profileName, or nil. -func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig { - for i := range multi.Apps { - if multi.Apps[i].ProfileName() == profileName { - return &multi.Apps[i] - } +// loginRoot is the package-level seam for the auth.Root used by login. +// Tests override this to a t.TempDir() to keep sidecar / flock state out of +// the developer's home directory. +var loginRoot = func() larkauth.Root { + return larkauth.NewLocalRoot(core.GetConfigDir()) +} + +// lookupAppConfig reads the prior CurrentUser BEFORE writing anything so the +// holder check has something to compare against. Returns nil on miss; callers +// treat nil as "no holder from config" and fall through. +func lookupAppConfig(profileName string) *core.AppConfig { + multi, err := core.LoadMultiAppConfig() + if err != nil || multi == nil { + return nil } - return nil + return multi.FindApp(profileName) +} + +// restoreStoredToken puts back a previously-snapshotted token slot after a +// syncLoginUserToProfile failure. The slot may have held a different user's +// still-valid token, so we restore rather than delete; if prior was nil the +// slot was empty, so we Remove to undo the SetStoredToken from the failed +// attempt. +func restoreStoredToken(appID, openID string, prior *larkauth.StoredUAToken) { + if prior == nil { + _ = larkauth.RemoveStoredToken(appID, openID) + return + } + _ = larkauth.SetStoredToken(prior) } // collectScopesForDomains collects API scopes (from from_meta projects) and -// shortcut scopes for the given domain names. -// Domains with auth_domain children are automatically expanded to include -// their children's scopes. +// shortcut scopes for the given domain names. Domains with auth_domain +// children are expanded to include their children's scopes. func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string { scopeSet := make(map[string]bool) - // 1. API scopes from from_meta projects + // API scopes from from_meta projects for _, s := range registry.CollectScopesForProjects(domains, identity) { scopeSet[s] = true } - // 2. Expand domains: include auth_domain children + // Expand auth_domain children domainSet := make(map[string]bool, len(domains)) for _, d := range domains { domainSet[d] = true @@ -525,7 +651,7 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB } } - // 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity) + // Shortcut scopes matching by Service (only those supporting the identity) for _, sc := range shortcuts.AllShortcuts() { if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { continue @@ -537,7 +663,6 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB } } - // 4. Deduplicate and sort result := make([]string, 0, len(scopeSet)) for s := range scopeSet { result = append(result, s) @@ -546,9 +671,9 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB return result } -// allKnownDomains returns all valid auth domain names (from_meta projects + -// shortcut services), excluding domains that have auth_domain set (they are -// folded into their parent domain). +// allKnownDomains returns valid auth domain names (from_meta projects + +// shortcut services), excluding domains with auth_domain set (folded into +// their parent). func allKnownDomains(brand core.LarkBrand) map[string]bool { domains := make(map[string]bool) for _, p := range registry.ListFromMetaProjects() { @@ -593,22 +718,14 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool { return false } -// normalizeScopeInput accepts a user-supplied --scope value that may use -// commas, spaces, tabs, or newlines (or any mix) as separators and returns the -// canonical OAuth 2.0 wire form: a single space-joined string with empties -// trimmed and duplicates removed (first occurrence wins; order preserved). -// -// Examples: -// -// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read" -// "a, b , c" -> "a b c" -// "a b a" -> "a b" -// "" -> "" +// normalizeScopeInput accepts a --scope value using commas, spaces, tabs, +// or newlines (or any mix) and returns the canonical OAuth 2.0 wire form: a +// single space-joined string with empties trimmed and duplicates removed +// (first occurrence wins; order preserved). func normalizeScopeInput(raw string) string { if raw == "" { return "" } - // Treat both commas and any whitespace as separators. fields := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' }) @@ -629,7 +746,6 @@ func normalizeScopeInput(raw string) string { // suggestDomain finds the best "did you mean" match for an unknown domain. func suggestDomain(input string, known map[string]bool) string { - // Check common cases: prefix match or input is a substring for k := range known { if strings.HasPrefix(k, input) || strings.HasPrefix(input, k) { return k @@ -653,11 +769,10 @@ func joinSortedScopeSet(set map[string]bool) string { } // applyExcludeScopes removes the provided exclude entries from the requested -// scope string. Each --exclude flag value may itself contain comma- or -// whitespace-separated scopes. Returns the filtered scope string and any -// exclude entries that were not present in the requested set (callers can -// surface those as a validation error to catch typos like -// `--exclude drive:file:downlod`). +// scope string. Each --exclude value may contain comma- or whitespace- +// separated scopes. Returns the filtered scope string and any exclude +// entries that were not present in the requested set, so callers can surface +// typos like `--exclude drive:file:downlod` as a validation error. func applyExcludeScopes(requested string, excludes []string) (string, []string) { requestedSet := make(map[string]bool) for _, s := range strings.Fields(requested) { @@ -666,8 +781,8 @@ func applyExcludeScopes(requested string, excludes []string) (string, []string) excludeSet := make(map[string]bool) for _, raw := range excludes { - // --exclude already splits on commas (StringSliceVar), but also - // tolerate whitespace-separated entries inside a single value. + // --exclude already splits on commas (StringSliceVar); also tolerate + // whitespace-separated entries inside a single value. for _, s := range strings.Fields(strings.ReplaceAll(raw, ",", " ")) { excludeSet[s] = true } diff --git a/cmd/auth/login_config_test.go b/cmd/auth/login_config_test.go index 63f0095da..0afa16f66 100644 --- a/cmd/auth/login_config_test.go +++ b/cmd/auth/login_config_test.go @@ -4,10 +4,14 @@ package auth import ( + "bytes" "strings" "testing" + "time" + larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" + "github.com/zalando/go-keyring" ) func setupLoginConfigDir(t *testing.T) { @@ -15,7 +19,166 @@ func setupLoginConfigDir(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) } -func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) { +// testLoginRoot returns an auth.Root rooted at the test's config dir so +// flock + sidecar state stays inside the temp dir. +func testLoginRoot(t *testing.T) larkauth.Root { + t.Helper() + return larkauth.NewLocalRoot(core.GetConfigDir()) +} + +// Upsert must append, not replace: legacy single-user semantics would have +// silently wiped Alice when Bob logged in. +func TestSyncLoginUserToProfile_UpsertNewUserAppendsRow(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + multi := &core.MultiAppConfig{ + CurrentApp: "target", + Apps: []core.AppConfig{ + { + Name: "target", + AppId: "app-target", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + var stderr bytes.Buffer + if err := syncLoginUserToProfile(testLoginRoot(t), "target", "app-target", "ou_bob", "uid_bob", "Bob", "im:message:send", time.Now(), &stderr); err != nil { + t.Fatalf("syncLoginUserToProfile: %v", err) + } + + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + users := saved.Apps[0].Users + if len(users) != 2 { + t.Fatalf("Users len = %d, want 2 (Alice preserved, Bob appended); got %#v", len(users), users) + } + if users[0].UserOpenId != "ou_alice" { + t.Errorf("Users[0] = %q, want ou_alice (insertion order preserved)", users[0].UserOpenId) + } + if users[1].UserOpenId != "ou_bob" || users[1].UserName != "Bob" { + t.Errorf("Users[1] = %#v, want Bob", users[1]) + } + if users[1].UnionId != "uid_bob" { + t.Errorf("Users[1].UnionId = %q, want uid_bob", users[1].UnionId) + } + // Re-login of a different user must not switch the active user. + if saved.Apps[0].CurrentUser != "ou_alice" { + t.Errorf("CurrentUser = %q, want ou_alice (re-login of a different user must NOT switch active user)", saved.Apps[0].CurrentUser) + } +} + +// Re-login refreshes UserName/LastUsed/LastScopes but FirstAuthAt is sticky. +func TestSyncLoginUserToProfile_UpsertExistingUserUpdatesInPlace(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + firstAuth := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + multi := &core.MultiAppConfig{ + CurrentApp: "target", + Apps: []core.AppConfig{ + { + Name: "target", + AppId: "app-target", + CurrentUser: "ou_alice", + Users: []core.AppUser{{ + UserOpenId: "ou_alice", + UserName: "old-name", + FirstAuthAt: &firstAuth, + }}, + }, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + now := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + var stderr bytes.Buffer + if err := syncLoginUserToProfile(testLoginRoot(t), "target", "app-target", "ou_alice", "uid_alice", "Alice", "docs:read im:message:send", now, &stderr); err != nil { + t.Fatalf("syncLoginUserToProfile: %v", err) + } + + saved, _ := core.LoadMultiAppConfig() + users := saved.Apps[0].Users + if len(users) != 1 { + t.Fatalf("Users len = %d, want 1 (re-login should not append duplicate)", len(users)) + } + u := users[0] + if u.UserName != "Alice" { + t.Errorf("UserName = %q, want Alice (refreshed)", u.UserName) + } + if u.UnionId != "uid_alice" { + t.Errorf("UnionId = %q, want uid_alice", u.UnionId) + } + if u.FirstAuthAt == nil || !u.FirstAuthAt.Equal(firstAuth) { + t.Errorf("FirstAuthAt = %v, want %v (must be sticky)", u.FirstAuthAt, firstAuth) + } + if u.LastUsed == nil || !u.LastUsed.Equal(now) { + t.Errorf("LastUsed = %v, want %v", u.LastUsed, now) + } + if u.LastScopes != "docs:read,im:message:send" { + t.Errorf("LastScopes = %q, want sorted+joined form", u.LastScopes) + } +} + +// First login on an empty profile must stamp CurrentUser; `auth users use` +// flows expect it populated. +func TestSyncLoginUserToProfile_FirstUserStampsCurrentUser(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + multi := &core.MultiAppConfig{ + Apps: []core.AppConfig{{Name: "target", AppId: "app-target"}}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + var stderr bytes.Buffer + if err := syncLoginUserToProfile(testLoginRoot(t), "target", "app-target", "ou_alice", "", "Alice", "", time.Now(), &stderr); err != nil { + t.Fatalf("syncLoginUserToProfile: %v", err) + } + saved, _ := core.LoadMultiAppConfig() + if saved.Apps[0].CurrentUser != "ou_alice" { + t.Errorf("CurrentUser = %q, want ou_alice (first user stamp)", saved.Apps[0].CurrentUser) + } +} + +// Regression guard: re-login of the active user must not clear-and-rewrite +// CurrentUser. +func TestSyncLoginUserToProfile_RefreshDoesNotChangeCurrentUser(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + multi := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "target", + AppId: "app-target", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + var stderr bytes.Buffer + if err := syncLoginUserToProfile(testLoginRoot(t), "target", "app-target", "ou_alice", "", "Alice", "", time.Now(), &stderr); err != nil { + t.Fatalf("syncLoginUserToProfile: %v", err) + } + saved, _ := core.LoadMultiAppConfig() + if saved.Apps[0].CurrentUser != "ou_alice" { + t.Errorf("CurrentUser = %q, want ou_alice (refresh of active user)", saved.Apps[0].CurrentUser) + } +} + +// Touching one profile must not stomp another profile's users. +func TestSyncLoginUserToProfile_PreservesOtherProfiles(t *testing.T) { + keyring.MockInit() setupLoginConfigDir(t) multi := &core.MultiAppConfig{ CurrentApp: "target", @@ -36,7 +199,8 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) { t.Fatalf("SaveMultiAppConfig() error = %v", err) } - if err := syncLoginUserToProfile("target", "app-target", "ou_new", "new-user"); err != nil { + var stderr bytes.Buffer + if err := syncLoginUserToProfile(testLoginRoot(t), "target", "app-target", "ou_new", "", "new-user", "", time.Now(), &stderr); err != nil { t.Fatalf("syncLoginUserToProfile() error = %v", err) } @@ -44,8 +208,8 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) { if err != nil { t.Fatalf("LoadMultiAppConfig() error = %v", err) } - if got := saved.Apps[0].Users; len(got) != 1 || got[0].UserOpenId != "ou_new" || got[0].UserName != "new-user" { - t.Fatalf("target users = %#v, want replaced login user", got) + if got := saved.Apps[0].Users; len(got) != 2 { + t.Fatalf("target users = %#v, want 2 entries (upsert preserves prior)", got) } if got := saved.Apps[1].Users; len(got) != 1 || got[0].UserOpenId != "ou_other" { t.Fatalf("other users = %#v, want unchanged", got) @@ -53,6 +217,7 @@ func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) { } func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) { + keyring.MockInit() setupLoginConfigDir(t) multi := &core.MultiAppConfig{ Apps: []core.AppConfig{{ @@ -64,7 +229,8 @@ func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) { t.Fatalf("SaveMultiAppConfig() error = %v", err) } - err := syncLoginUserToProfile("missing", "app-default", "ou_new", "new-user") + var stderr bytes.Buffer + err := syncLoginUserToProfile(testLoginRoot(t), "missing", "app-default", "ou_new", "", "new-user", "", time.Now(), &stderr) if err == nil { t.Fatal("expected error for missing profile") } diff --git a/cmd/auth/login_holder.go b/cmd/auth/login_holder.go new file mode 100644 index 000000000..5389a933c --- /dev/null +++ b/cmd/auth/login_holder.go @@ -0,0 +1,251 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "io" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" +) + +// holderMismatchWarning carries the structured shape of a soft holder +// mismatch — the operator did not declare an explicit target user (no +// --user, no env), but the AppConfig.CurrentUser left over from a prior +// login disagrees with the freshly-authorized identity. The Message field +// is the human-readable stderr WARN (still emitted for operators tailing +// 2>&1), while the typed fields let JSON-mode consumers key on the +// mismatch programmatically without parsing the message string. +// +// nil means "no soft mismatch" — either the holder matched (clean login) +// or the mismatch was hard-aborted upstream (flag/env source). +type holderMismatchWarning struct { + HolderOpenId string + HolderUserName string + FreshOpenId string + FreshUserName string + Message string +} + +// enforceLoginHolderGate is the single entry point both authLoginRun and +// authLoginPollDeviceCode use to gate a fresh authorization against the +// operator-named (or operator-implied) holder. Centralizing the wrapper +// keeps the soft-advisory wiring from drifting between the immediate-login +// path and the --no-wait/--device-code resume path: both paths must hard- +// abort on flag/env mismatches, both must emit the soft advisory to stderr +// on AppConfig.CurrentUser mismatches, and both must let login proceed in +// that soft case. +// +// Returns (warning, abortErr): +// - abortErr non-nil → caller MUST return it (no warning emitted) +// - abortErr nil, warning non-nil → soft mismatch; the human-readable +// Message has already been written to errOut and the typed fields +// are threaded through writeLoginSuccess / handleLoginScopeIssue so +// the JSON success payload carries a structured +// `holder_mismatch_warning` field for non-human consumers. +// - both nil → clean login, no holder concern. +// +// freshUserName is the display name returned by the user-info call (may be +// empty); it is threaded through so the soft-advisory branch can render +// "Alice (ou_alice)" instead of two opaque open_ids. +func enforceLoginHolderGate(f *cmdutil.Factory, profileName, freshOpenId, freshUserName string, errOut io.Writer) (*holderMismatchWarning, error) { + holderOpenId, holderSource, holderUserName := resolveLoginHolder( + f.Invocation.UserOpenId, f.Invocation.UserSource, lookupAppConfig(profileName)) + warning, abortErr := verifyHolder(holderOpenId, holderUserName, holderSource, freshOpenId, freshUserName) + if abortErr != nil { + return nil, abortErr + } + if warning != nil { + fmt.Fprintln(errOut, warning.Message) + } + return warning, nil +} + +// verifyHolder gates a fresh authorization against the user the operator +// named (or implied) before the device flow ran. Without this gate a stale +// LARKSUITE_CLI_OPEN_ID, a --user typo, or even a phishing redirect could +// silently overwrite a different user's record. +// +// The function distinguishes the holder source and treats them differently: +// +// - holderSource == "flag" or "env" — the operator EXPLICITLY declared +// the target user. A mismatch here is either a typo or a phishing/ +// redirect guard: we ABORT before any keychain / config write and +// return a structured *errs.ConfigError so the dispatcher renders a +// clean message. +// +// - holderSource == "" — the holder was IMPLIED by AppConfig.CurrentUser +// (the active user from the last `auth users use` or login). A mismatch +// is the legacy "logout-and-login-as-someone-else" workflow: pre- +// multi-user lark-cli let `auth login` silently replace the single +// stored user there. Aborting here would break that workflow with no +// security benefit (the operator did not declare an intent to lock +// to the implied user). We allow the login to proceed and return a +// stderr advisory naming the new fallback semantics — Bob is appended +// to Users[] and the active user stays Alice; switch via `auth users +// use` if that is what was intended. +// +// - any other holderSource — fail-closed internal error. The producer +// (resolveLoginHolder) only emits {"", "flag", "env"} today, so a new +// value means a refactor introduced a label without thinking about +// this gate. We refuse to guess whether to abort or advise. +// +// Empty holder is a no-op (legacy single-user install with no CurrentUser +// stamped, or a fresh init). +// +// Returns (warning, abortErr) — error last per Go convention, matching +// enforceLoginHolderGate's signature so the two helpers compose cleanly: +// - abortErr non-nil → caller MUST return it (no warning emitted) +// - abortErr nil, warning non-nil → caller writes warning.Message to +// stderr and proceeds with the login. The typed fields are +// threaded through writeLoginSuccess / authorizationCompletePayload +// so JSON consumers see a structured `holder_mismatch_warning`. +// - both nil → no holder concern, proceed silently +// +// holderUserName / freshUserName are display names (possibly empty). The +// hard-abort branches stay open_id-only — the operator just typed an +// open_id (flag) or set an env var, and the failure attribution is to a +// machine-readable identifier they can edit. The soft-advisory branch +// renders "Alice (ou_alice)" when both names are present so a human +// reading stderr does not have to mentally map two opaque ou_* strings. +// +// The switch is fail-closed on unknown sources: anything not in the +// {"", "flag", "env"} allow-list aborts with an internal error rather +// than silently falling through to the soft path. The producer +// (resolveLoginHolder) only ever emits those three values today, so any +// other source is a programmer bug — adding a new "profile" or +// "keychain" label without thinking about the gate's contract should +// fail loudly at the gate, not quietly leak a fresh token. +func verifyHolder(holderOpenId, holderUserName, holderSource, freshOpenId, freshUserName string) (*holderMismatchWarning, error) { + if holderOpenId == "" || holderOpenId == freshOpenId { + return nil, nil + } + + switch holderSource { + case "flag": + hint := "you passed --user " + holderOpenId + " but the device you authorized was " + freshOpenId + + ". Re-run with `--user " + freshOpenId + "` to register the user you actually authorized," + + " or re-authorize on a device signed in as " + holderOpenId + "." + return nil, errs.NewConfigError(errs.SubtypeInvalidArgument, + "login holder mismatch: requested user %q but authorized user is %q", + holderOpenId, freshOpenId). + WithHint(hint) + case "env": + hint := "LARKSUITE_CLI_OPEN_ID is set to " + holderOpenId + " but the device you authorized was " + freshOpenId + + " — unset the env var or re-run with `--user " + freshOpenId + "`." + return nil, errs.NewConfigError(errs.SubtypeInvalidArgument, + "login holder mismatch: requested user %q but authorized user is %q", + holderOpenId, freshOpenId). + WithHint(hint) + case "": + // Implied holder (AppConfig.CurrentUser). Soft mismatch — emit a + // warning and let login proceed. The Message must: + // 1. NOT recommend "re-run without --user" (the operator already + // did not pass --user; that hint is self-contradictory). + // 2. Tell the operator the new active-user semantics: the fresh + // user is appended, the active user is unchanged, and the + // explicit switch command. + // 3. Render "Alice (ou_alice)" when display names are available + // so the human reader can map open_ids to people; fall back + // to bare open_id when the name is unknown. + holderLabel := formatHolderLabel(holderUserName, holderOpenId) + freshLabel := formatHolderLabel(freshUserName, freshOpenId) + message := "[lark-cli] [WARN] auth login: the active profile's currentUser is " + holderLabel + + " but the device you authorized was " + freshLabel + + ". Login will proceed and add " + freshLabel + " to the profile;" + + " the active user stays " + holderLabel + + ". Run `lark-cli auth users use " + freshOpenId + "` to switch the active user." + return &holderMismatchWarning{ + HolderOpenId: holderOpenId, + HolderUserName: holderUserName, + FreshOpenId: freshOpenId, + FreshUserName: freshUserName, + Message: message, + }, nil + default: + // Fail-closed: an unknown source is a programmer bug at the + // resolver, not the operator's fault. Surface it as an internal + // error so it gets fixed instead of silently downgrading the + // gate's safety property. + return nil, errs.NewInternalError(errs.SubtypeUnknown, + "verifyHolder: unknown holderSource %q (expected \"\", \"flag\", or \"env\") — cannot decide whether to abort or advise; refusing to proceed", + holderSource) + } +} + +// formatHolderLabel renders a holder for human-readable advisory text. +// When a display name is available it returns "Name (open_id)"; otherwise +// just the open_id. Avoids "(ou_xxx)" empty-name artifacts and keeps the +// advisory grep-friendly (the open_id always appears verbatim so existing +// `[lark-cli]` log filters can still extract the identity). +// +// userName is sanitized through validate.SanitizeForTerminal before it is +// woven into the stderr advisory: a stored UserName originates from the +// IdP user-info call but is then persisted in MultiAppConfig and could +// reach this label long after the original login. A maliciously-crafted +// (or accidentally-corrupted) name carrying ANSI escapes, C0 control +// bytes, or zero-width Unicode would otherwise poison the WARN line — +// rewriting prompts, masking output, or injecting fake brand prefixes. +// open_id stays verbatim by contract: it is grep-bait for `[lark-cli]` +// log filters and is regex-validated upstream at the IdP boundary. +// +// The typed JSON fields on holderMismatchWarning are NOT sanitized here: +// per validate.SanitizeForTerminal's contract, JSON / NDJSON consumers +// need raw bytes so they can render in their own escape-aware UI. Only +// the human-readable Message goes through this label, so the sanitizer +// fires exactly where it is safe to mutate. +func formatHolderLabel(userName, openId string) string { + if userName == "" { + return openId + } + return validate.SanitizeForTerminal(userName) + " (" + openId + ")" +} + +// resolveLoginHolder picks the holder identity to verify the login +// against, in priority order: invocation --user/env, then active +// AppConfig.CurrentUser, then none. Falling back to CurrentUser when +// the operator did not pass --user lets the soft-mismatch advisory +// path nudge them — without locking the legacy `auth login` re-login +// workflow behind a hard abort. +// +// Critically, when the invocation override matches an existing user by +// UserName (the --user flag is documented at cmd/global_flags.go to +// accept "open_id or username"), we translate it to the stored +// UserOpenId here so verifyHolder's equality check sees apples-to-apples +// open_ids. Without this translation, `--user Alice` against a profile +// where Alice is stored as ou_alice would land in verifyHolder as +// ("Alice", "ou_alice") — a guaranteed mismatch and an aborted login. +// +// Also returns the matched holder's stored UserName when available — the +// caller threads it into verifyHolder's soft-advisory branch so a human +// reading stderr sees "Alice (ou_alice)" instead of bare open_ids. Empty +// when no matching row is found in the profile (e.g. operator passed a +// brand-new open_id, or the holder is implied from CurrentUser but the +// row was scrubbed). +func resolveLoginHolder(invUserOpenId, invUserSource string, app *core.AppConfig) (openId, source, userName string) { + if invUserOpenId != "" { + // Two-pass: exact UserOpenId, then UserName. If neither hits, + // return verbatim so a brand-new open_id can still match the + // fresh-authorization echo in verifyHolder. + if app != nil { + if hit := app.FindUser(invUserOpenId); hit != nil { + return hit.UserOpenId, invUserSource, hit.UserName + } + } + return invUserOpenId, invUserSource, "" + } + if app != nil && app.CurrentUser != "" { + // Implied holder — try to recover its display name for the soft + // advisory; fine to leave empty if the row was scrubbed. + var name string + if hit := app.FindUser(app.CurrentUser); hit != nil { + name = hit.UserName + } + return app.CurrentUser, "", name + } + return "", "", "" +} diff --git a/cmd/auth/login_holder_gate_test.go b/cmd/auth/login_holder_gate_test.go new file mode 100644 index 000000000..9022a462e --- /dev/null +++ b/cmd/auth/login_holder_gate_test.go @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// enforceLoginHolderGate is the seam both the immediate-login path +// (login.go:367) and the --no-wait/--device-code poll path (login.go:455) +// share for the holder-mismatch policy. The verifyHolder unit tests pin the +// decision matrix in pure form (input tuple → output tuple), and the +// integration tests under login_run_*_test.go drive the whole authLoginRun +// happy path. Neither covers the wrapper itself: that resolveLoginHolder is +// fed the right factory fields, that abortErr short-circuits before the +// stderr write, and that the soft-warning branch writes the Message exactly +// once to the io.Writer the caller passes (not to f.IOStreams.ErrOut, which +// the production callers do thread in but tests should be able to swap). +// +// These three cases pin that wrapper contract directly, so a refactor that +// e.g. moves the stderr write inside verifyHolder, or starts writing to +// f.IOStreams.ErrOut even when an explicit errOut was passed, fails here +// instead of slipping through to a JSON-mode regression. +func TestEnforceLoginHolderGate_CleanMatch_NilNilNoStderr(t *testing.T) { + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + { + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }, + }, + }) + + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ProfileName: "default", AppID: "cli_test"}) + // No --user / env override; the wrapper falls through to AppConfig.CurrentUser + // and the fresh authorization echoes the same open_id, so this is a clean match. + f.Invocation = cmdutil.InvocationContext{Profile: "default"} + + var errOut bytes.Buffer + warning, err := enforceLoginHolderGate(f, "default", "ou_alice", "Alice", &errOut) + if err != nil { + t.Fatalf("clean match must not return abortErr; got %v", err) + } + if warning != nil { + t.Errorf("clean match must not return a warning; got %#v", warning) + } + if errOut.Len() != 0 { + t.Errorf("clean match must not write to errOut; got %q", errOut.String()) + } +} + +// Flag-source mismatch — the operator typed `--user ou_alice` but the +// device authorized as ou_bob. The wrapper must surface verifyHolder's +// hard abort (a *errs.ConfigError with SubtypeInvalidArgument) and MUST +// NOT write the soft-advisory message to errOut: there is no advisory in +// this branch — verifyHolder returns (nil, abortErr) — and a stray +// stderr write would corrupt the dispatcher's clean error-render path. +func TestEnforceLoginHolderGate_FlagMismatch_AbortNoStderr(t *testing.T) { + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + { + Name: "default", + AppId: "cli_test", + // CurrentUser intentionally empty so the flag-source path is + // the ONLY holder selector; otherwise resolveLoginHolder's + // invocation-priority rule masks the test. + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }, + }, + }) + + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ProfileName: "default", AppID: "cli_test"}) + f.Invocation = cmdutil.InvocationContext{ + Profile: "default", + UserOpenId: "ou_alice", + UserSource: "flag", + } + + var errOut bytes.Buffer + warning, err := enforceLoginHolderGate(f, "default", "ou_bob", "Bob", &errOut) + if err == nil { + t.Fatal("flag-source mismatch must return abortErr") + } + if warning != nil { + t.Errorf("flag-source mismatch must NOT return a warning; got %#v", warning) + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) + } + if cfgErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want SubtypeInvalidArgument", cfgErr.Subtype) + } + // The whole point of this case: an abortErr branch must NOT also write + // the soft Message to stderr — verifyHolder's contract is that warning + // is nil whenever abortErr is non-nil, and the wrapper's contract is + // that it only writes when warning != nil. + if errOut.Len() != 0 { + t.Errorf("abort branch must not write to errOut (warning is nil); got %q", errOut.String()) + } +} + +// Implied-holder mismatch — the operator did not pass --user / env, but +// AppConfig.CurrentUser names ou_alice while the device authorized as +// ou_bob. The wrapper must let the login proceed (abortErr nil) and emit +// warning.Message to errOut exactly once. This is the seam the JSON-mode +// path piggybacks on: by the time writeLoginSuccess runs, the stderr line +// has already been written, and the JSON `holder_mismatch_warning` field +// surfaces the same warning structurally — so the wrapper must not write +// the message twice (which would double the stderr line under JSON mode +// when stderr is redirected to a tee), and must not skip the write +// (which would deny operators tailing 2>&1 the legacy advisory). +func TestEnforceLoginHolderGate_ImpliedMismatch_WarningWritesOnce(t *testing.T) { + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + { + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", // implied holder — soft branch + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }, + }, + }) + + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ProfileName: "default", AppID: "cli_test"}) + // No --user / env: invocation context has empty UserOpenId and empty + // UserSource, so resolveLoginHolder falls through to AppConfig.CurrentUser + // and verifyHolder takes the implied-holder soft branch. + f.Invocation = cmdutil.InvocationContext{Profile: "default"} + + var errOut bytes.Buffer + warning, err := enforceLoginHolderGate(f, "default", "ou_bob", "Bob", &errOut) + if err != nil { + t.Fatalf("implied-holder mismatch must NOT abort; got %v", err) + } + if warning == nil { + t.Fatal("implied-holder mismatch must return a non-nil warning") + } + + // Typed-field round-trip: the wrapper hands these straight through from + // resolveLoginHolder + the fresh-authorization fields, so a refactor + // that swapped holder/fresh slots (e.g. inverted equality direction in + // verifyHolder) would corrupt the JSON consumer payload. Pin both + // halves so a swap is caught at the wrapper, not at the JSON envelope. + if warning.HolderOpenId != "ou_alice" || warning.HolderUserName != "Alice" { + t.Errorf("holder slots wrong: openId=%q name=%q", warning.HolderOpenId, warning.HolderUserName) + } + if warning.FreshOpenId != "ou_bob" || warning.FreshUserName != "Bob" { + t.Errorf("fresh slots wrong: openId=%q name=%q", warning.FreshOpenId, warning.FreshUserName) + } + + // errOut received exactly the warning Message followed by a single + // trailing newline (Fprintln). The "exactly once" is the regression + // guard — the wrapper used to be open-coded at both call sites, and + // re-introducing that pattern (or routing the soft branch through both + // the wrapper AND a downstream errOut write inside writeLoginSuccess) + // would double the line. + got := errOut.String() + want := warning.Message + "\n" + if got != want { + t.Errorf("errOut content drift\n got: %q\nwant: %q", got, want) + } + // Defensive — strings.Count for the brand prefix catches the case where + // some future Fprintln'd context line shares the prefix and lands here. + if n := strings.Count(got, "[lark-cli]"); n != 1 { + t.Errorf("errOut should contain `[lark-cli]` exactly once; got %d:\n%s", n, got) + } +} diff --git a/cmd/auth/login_holder_sanitize_test.go b/cmd/auth/login_holder_sanitize_test.go new file mode 100644 index 000000000..d17eadca0 --- /dev/null +++ b/cmd/auth/login_holder_sanitize_test.go @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "strings" + "testing" +) + +// formatHolderLabel feeds the human-readable advisory written to stderr +// when the soft holder mismatch fires. The userName it embeds comes from +// app.Users[].UserName, which is persisted from a prior login's IdP +// user-info response. A maliciously- (or accidentally-) crafted name +// carrying ANSI escapes, C0 control bytes, or zero-width Unicode would +// otherwise reach a tailing terminal and could rewrite preceding output, +// inject a fake `[lark-cli]` prefix, or hide a phishing message under a +// cursor-back sequence. validate.SanitizeForTerminal strips all of those. +// +// Threat model: a previously-authorized account whose display name was +// poisoned (compromised IdP, MITM during an earlier login on a different +// device, hand-edit of the on-disk MultiAppConfig) re-surfaces during a +// soft re-login by a different user. Without sanitization the WARN line +// can be hijacked. The fresh user's UserName comes from THIS login's +// user-info call, so the same poisoning vector applies if that response +// is attacker-controlled. +func TestFormatHolderLabel_StripsTerminalEscapes(t *testing.T) { + cases := []struct { + name string + userName string + openId string + // wantNotContain pins what MUST NOT survive into the label — + // asserting on absence catches new escape categories the + // sanitizer learns to strip later. + wantNotContain []string + // wantContain pins what survives — the brand prefix still + // embedded by the caller, the open_id verbatim (grep-bait), + // and the printable name characters. + wantContain []string + }{ + { + name: "ansi-color escape gets stripped", + userName: "\x1b[31mAlice\x1b[0m", + openId: "ou_alice", + // \x1b is the lead byte of every CSI escape; if any bit + // of it survives, a downstream terminal can still parse a + // truncated escape and mis-render. + wantNotContain: []string{"\x1b", "[31m", "[0m"}, + wantContain: []string{"Alice", "ou_alice"}, + }, + { + name: "BEL and other C0 control bytes get stripped", + userName: "Al\x07ice\x08", + openId: "ou_alice", + wantNotContain: []string{"\x07", "\x08"}, + wantContain: []string{"Alice", "ou_alice"}, + }, + { + name: "carriage-return cannot rewind the line", + userName: "Alice\rEVIL", + openId: "ou_alice", + // \r alone is enough to repaint the prefix; the sanitizer + // drops it because it is < 0x20 and not in the {\n,\t} + // allow-list. + wantNotContain: []string{"\r"}, + wantContain: []string{"Alice", "EVIL", "ou_alice"}, + }, + { + name: "DEL byte gets stripped", + userName: "Alice\x7f", + openId: "ou_alice", + wantNotContain: []string{"\x7f"}, + wantContain: []string{"Alice", "ou_alice"}, + }, + { + name: "OSC sequence gets stripped", + userName: "\x1b]0;evil-title\x07Alice", + openId: "ou_alice", + // OSC starts with \x1b] and is terminated by BEL or + // ST. SanitizeForTerminal's regex covers CSI; any + // stray \x1b lead byte the regex misses is then caught + // by the C0 sweep. + wantNotContain: []string{"\x1b", "\x07"}, + wantContain: []string{"Alice", "ou_alice"}, + }, + { + // Defense-in-depth for the threat-model docstring's + // "zero-width Unicode" line. SanitizeForTerminal routes + // these runes through charcheck.IsDangerousUnicode (a + // branch separate from CSI / C0 / DEL); we want a test + // at the formatHolderLabel boundary that fails if a + // future refactor swaps in a CSI-only stripper. + // + // U+200B = zero-width space (invisible joiner — could hide + // a name boundary so "Al​ice" reads as "Alice" + // but fails an exact-match comparison) + // U+202E = right-to-left override (visually flips the + // tail of the name; classic "report.txt" disguise + // for "report.exe") + name: "zero-width and bidi override Unicode get stripped", + userName: "Al​ice‮EVIL", + openId: "ou_alice", + wantNotContain: []string{"​", "‮"}, + wantContain: []string{"Alice", "EVIL", "ou_alice"}, + }, + { + name: "empty userName falls through to bare open_id (no sanitize call)", + userName: "", + openId: "ou_alice", + wantContain: []string{"ou_alice"}, + }, + { + name: "clean userName survives unmodified", + userName: "Alice", + openId: "ou_alice", + wantContain: []string{"Alice", "ou_alice", "Alice (ou_alice)"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := formatHolderLabel(tc.userName, tc.openId) + for _, s := range tc.wantNotContain { + if strings.Contains(got, s) { + t.Errorf("label contained banned substring %q (bytes: %x)\nlabel: %q", s, []byte(s), got) + } + } + for _, s := range tc.wantContain { + if !strings.Contains(got, s) { + t.Errorf("label missing required substring %q\nlabel: %q", s, got) + } + } + }) + } +} + +// End-to-end through verifyHolder's soft-mismatch branch: the Message +// field on the returned holderMismatchWarning is what gets written to +// stderr. It MUST have the poisoned name stripped. The typed +// HolderUserName / FreshUserName fields, by contrast, carry raw bytes +// for JSON consumers (per validate.SanitizeForTerminal's documented +// contract: not for json/ndjson output, programmatic consumers need +// the raw data and apply their own escape rules). +func TestVerifyHolder_SoftMismatch_SanitizesMessageButPreservesTypedFields(t *testing.T) { + const poisonedHolder = "Al\x1b[31mice\x1b[0m" + const poisonedFresh = "Bo\x07b" + + warning, abortErr := verifyHolder( + "ou_alice", poisonedHolder, "", // implied holder source -> soft branch + "ou_bob", poisonedFresh, + ) + if abortErr != nil { + t.Fatalf("expected soft warning, got abortErr: %v", abortErr) + } + if warning == nil { + t.Fatal("expected non-nil holderMismatchWarning") + } + + // Stderr-bound Message must NOT contain the escapes — that is the + // whole point of M1. + for _, banned := range []string{"\x1b", "[31m", "[0m", "\x07"} { + if strings.Contains(warning.Message, banned) { + t.Errorf("warning.Message leaked %q (bytes: %x): %q", banned, []byte(banned), warning.Message) + } + } + // And it must still contain the readable parts so a human tailing + // stderr can map open_ids to people. + for _, want := range []string{"Alice", "Bob", "ou_alice", "ou_bob", "[lark-cli]", "[WARN]"} { + if !strings.Contains(warning.Message, want) { + t.Errorf("warning.Message lost expected fragment %q: %q", want, warning.Message) + } + } + + // Typed fields preserve raw bytes — JSON consumers need that, and + // the sanitize-on-write convention is documented at + // validate.SanitizeForTerminal. Asserting equality (not just + // "contains") here proves no copy of the sanitizer leaked into the + // typed-field path. + if warning.HolderUserName != poisonedHolder { + t.Errorf("HolderUserName was sanitized but should stay raw for JSON consumers; got %q want %q", + warning.HolderUserName, poisonedHolder) + } + if warning.FreshUserName != poisonedFresh { + t.Errorf("FreshUserName was sanitized but should stay raw for JSON consumers; got %q want %q", + warning.FreshUserName, poisonedFresh) + } + // Open_ids are regex-validated upstream at the IdP boundary, so a + // poisoned holder.UserName cannot bleed into them. Pin the + // invariant so a future refactor cannot accidentally mix them. + if warning.HolderOpenId != "ou_alice" || warning.FreshOpenId != "ou_bob" { + t.Errorf("open_id fields corrupted: holder=%q fresh=%q", warning.HolderOpenId, warning.FreshOpenId) + } +} diff --git a/cmd/auth/login_holder_test.go b/cmd/auth/login_holder_test.go new file mode 100644 index 000000000..3add1ba0b --- /dev/null +++ b/cmd/auth/login_holder_test.go @@ -0,0 +1,285 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" +) + +// Legacy single-user install: no holder named, accept whatever server returned. +func TestVerifyHolder_NoHolder_NoOp(t *testing.T) { + warning, abortErr := verifyHolder("", "", "", "ou_alice", "") + if abortErr != nil { + t.Errorf("expected nil abortErr, got %v", abortErr) + } + if warning != nil { + t.Errorf("expected nil warning, got %#v", warning) + } +} + +func TestVerifyHolder_HolderMatches_NoOp(t *testing.T) { + warning, abortErr := verifyHolder("ou_alice", "Alice", "flag", "ou_alice", "Alice") + if abortErr != nil { + t.Errorf("expected nil abortErr for matching holder, got %v", abortErr) + } + if warning != nil { + t.Errorf("expected nil warning for matching holder, got %#v", warning) + } +} + +// Mismatch error must attribute to --user so operator knows which knob to fix. +func TestVerifyHolder_FlagMismatch_FlagAttribution(t *testing.T) { + warning, abortErr := verifyHolder("ou_alice", "Alice", "flag", "ou_bob", "Bob") + if abortErr == nil { + t.Fatal("expected abortErr for flag mismatch") + } + if warning != nil { + t.Errorf("flag-source mismatch must NOT emit a soft warning (it is a hard abort): %#v", warning) + } + var cfgErr *errs.ConfigError + if !errors.As(abortErr, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T", abortErr) + } + if cfgErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want SubtypeInvalidArgument", cfgErr.Subtype) + } + if !strings.Contains(cfgErr.Hint, "--user") { + t.Errorf("Hint should mention --user attribution: %q", cfgErr.Hint) + } + if !strings.Contains(cfgErr.Message, "ou_alice") || !strings.Contains(cfgErr.Message, "ou_bob") { + t.Errorf("Message should contain both holder and fresh ids: %q", cfgErr.Message) + } + // Hard-abort branches stay open_id-only — the failure attribution is + // the machine-readable identifier the operator typed, not their name. + if strings.Contains(cfgErr.Message, "Alice") || strings.Contains(cfgErr.Message, "Bob") { + t.Errorf("flag-source abort message must stay open_id-only (not embed UserName): %q", cfgErr.Message) + } +} + +// Env-sourced mismatch must name LARKSUITE_CLI_OPEN_ID first, not --user. +func TestVerifyHolder_EnvMismatch_EnvAttribution(t *testing.T) { + warning, abortErr := verifyHolder("ou_alice", "Alice", "env", "ou_bob", "Bob") + if abortErr == nil { + t.Fatal("expected abortErr for env mismatch") + } + if warning != nil { + t.Errorf("env-source mismatch must NOT emit a soft warning (it is a hard abort): %#v", warning) + } + var cfgErr *errs.ConfigError + if !errors.As(abortErr, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T", abortErr) + } + if !strings.Contains(cfgErr.Hint, "LARKSUITE_CLI_OPEN_ID") { + t.Errorf("Hint should mention LARKSUITE_CLI_OPEN_ID: %q", cfgErr.Hint) + } + if strings.Contains(cfgErr.Hint, "--user") && !strings.Contains(cfgErr.Hint, "re-run with --user") && !strings.Contains(cfgErr.Hint, "re-run with `--user") { + // env var must be named before --user remediation + if i := strings.Index(cfgErr.Hint, "LARKSUITE_CLI_OPEN_ID"); i > 0 { + if j := strings.Index(cfgErr.Hint, "--user"); j > 0 && j < i { + t.Errorf("env env var should be named before --user: %q", cfgErr.Hint) + } + } + } + // Same hard-abort discipline as flag: open_id-only attribution. + if strings.Contains(cfgErr.Message, "Alice") || strings.Contains(cfgErr.Message, "Bob") { + t.Errorf("env-source abort message must stay open_id-only (not embed UserName): %q", cfgErr.Message) + } +} + +// Holder from AppConfig.CurrentUser (source ""): the legacy "logout-and- +// login-as-someone-else" workflow keeps working — login proceeds and a +// warning tells the operator about the new active-user semantics. +// +// Pre-fix this branch returned a hard abort, breaking the legacy workflow. +func TestVerifyHolder_ConfigCurrentUserMismatch_AdvisoryNotAbort(t *testing.T) { + warning, abortErr := verifyHolder("ou_alice", "Alice", "", "ou_bob", "Bob") + if abortErr != nil { + t.Fatalf("config-source mismatch must NOT abort (legacy workflow regression); got %v", abortErr) + } + if warning == nil { + t.Fatal("config-source mismatch must emit a warning so the operator knows about the active-user semantics") + } + // Typed-field contract: the JSON-mode payload keys on these fields, so + // pin them at the unit level. The Message field is the human-readable + // stderr line — substring-checked below. + if warning.HolderOpenId != "ou_alice" { + t.Errorf("warning.HolderOpenId = %q, want ou_alice", warning.HolderOpenId) + } + if warning.HolderUserName != "Alice" { + t.Errorf("warning.HolderUserName = %q, want Alice", warning.HolderUserName) + } + if warning.FreshOpenId != "ou_bob" { + t.Errorf("warning.FreshOpenId = %q, want ou_bob", warning.FreshOpenId) + } + if warning.FreshUserName != "Bob" { + t.Errorf("warning.FreshUserName = %q, want Bob", warning.FreshUserName) + } + // Message-field contract — the operator must learn: + // 1. The implied holder (ou_alice — what the active user *was*) + // 2. The fresh user (ou_bob — who they actually authorized as) + // 3. The new state ("active user stays" — Bob is appended, Alice + // remains active) + // 4. The remediation (auth users use ou_bob) + // 5. Must NOT contradict itself by suggesting "re-run without --user" + // — the operator already did not pass --user. + // 6. Must use the [lark-cli] brand prefix every other lark-cli WARN + // line uses, so operator stderr-filters keep working. + // 7. When display names are present, render "Alice (ou_alice)" so a + // human reader can map open_ids to people. + wantSubs := []string{ + "[lark-cli]", // brand prefix + "WARN", + "auth login", + "Alice", // implied holder display name + "Bob", // fresh user display name + "ou_alice", // implied holder open_id (still grep-friendly) + "ou_bob", // fresh user open_id + "Alice (ou_alice)", // explicit format contract + "Bob (ou_bob)", + "active user", // names the semantics + "auth users use", // remediation + } + for _, sub := range wantSubs { + if !strings.Contains(warning.Message, sub) { + t.Errorf("warning.Message missing %q\nfull message: %s", sub, warning.Message) + } + } + if strings.Contains(warning.Message, "re-run without") { + t.Errorf("warning must not suggest 're-run without ...' — operator did not pass --user; got: %s", warning.Message) + } + if strings.Contains(warning.Message, "LARKSUITE_CLI_OPEN_ID") { + t.Errorf("config-source warning must not mention env var: %s", warning.Message) + } +} + +// Soft-warning falls back to bare open_id when display names are unknown +// (e.g. AppConfig row was scrubbed mid-flight, or the user-info call did +// not return a name). Must NOT render "(ou_xxx)" empty-name artifacts. +func TestVerifyHolder_ConfigCurrentUserMismatch_NoNamesFallback(t *testing.T) { + warning, _ := verifyHolder("ou_alice", "", "", "ou_bob", "") + if warning == nil { + t.Fatal("expected warning") + } + // Typed fields still propagate even when names are empty. + if warning.HolderOpenId != "ou_alice" || warning.FreshOpenId != "ou_bob" { + t.Errorf("typed open_ids missing: %#v", warning) + } + if warning.HolderUserName != "" || warning.FreshUserName != "" { + t.Errorf("typed user names should be empty: %#v", warning) + } + // Must contain bare open_ids… + if !strings.Contains(warning.Message, "ou_alice") || !strings.Contains(warning.Message, "ou_bob") { + t.Errorf("warning missing open_ids: %s", warning.Message) + } + // …but NOT the parenthesized-empty-name artifact "(ou_alice)" with a + // preceding space (which would be the format used when the name slot + // is non-empty). + if strings.Contains(warning.Message, "Alice (ou_alice)") || strings.Contains(warning.Message, " (ou_alice)") { + t.Errorf("warning leaked an empty-name artifact: %s", warning.Message) + } + if strings.Contains(warning.Message, "Bob (ou_bob)") || strings.Contains(warning.Message, " (ou_bob)") { + t.Errorf("warning leaked an empty-name artifact: %s", warning.Message) + } +} + +// Flag beats env and config. +func TestResolveLoginHolder_FlagWins(t *testing.T) { + app := &core.AppConfig{CurrentUser: "ou_config"} + openId, src, name := resolveLoginHolder("ou_flag", "flag", app) + if openId != "ou_flag" || src != "flag" || name != "" { + t.Errorf("got (%q,%q,%q), want (ou_flag,flag,)", openId, src, name) + } +} + +func TestResolveLoginHolder_EnvBeatsConfig(t *testing.T) { + app := &core.AppConfig{CurrentUser: "ou_config"} + openId, src, name := resolveLoginHolder("ou_env", "env", app) + if openId != "ou_env" || src != "env" || name != "" { + t.Errorf("got (%q,%q,%q), want (ou_env,env,)", openId, src, name) + } +} + +// Falls through to AppConfig.CurrentUser with source "" — and recovers the +// matching UserName from app.Users[] so the soft advisory has a name to +// render. +func TestResolveLoginHolder_FallsThroughToConfig(t *testing.T) { + app := &core.AppConfig{ + CurrentUser: "ou_config", + Users: []core.AppUser{{UserOpenId: "ou_config", UserName: "ConfiguredUser"}}, + } + openId, src, name := resolveLoginHolder("", "", app) + if openId != "ou_config" || src != "" { + t.Errorf("got (%q,%q), want (ou_config,)", openId, src) + } + if name != "ConfiguredUser" { + t.Errorf("expected ConfiguredUser, got %q", name) + } +} + +// CurrentUser fallback when the matching row was scrubbed: name is empty, +// open_id and source still right. +func TestResolveLoginHolder_FallsThroughToConfig_NoMatchingRow(t *testing.T) { + app := &core.AppConfig{CurrentUser: "ou_orphan"} + openId, src, name := resolveLoginHolder("", "", app) + if openId != "ou_orphan" || src != "" || name != "" { + t.Errorf("got (%q,%q,%q), want (ou_orphan,,)", openId, src, name) + } +} + +// Legacy single-user install path: nothing anywhere → no holder. +func TestResolveLoginHolder_NoHolder_AllEmpty(t *testing.T) { + openId, src, name := resolveLoginHolder("", "", nil) + if openId != "" || src != "" || name != "" { + t.Errorf("got (%q,%q,%q), want (,,)", openId, src, name) + } + // And with an app whose CurrentUser is also empty: + openId, src, name = resolveLoginHolder("", "", &core.AppConfig{}) + if openId != "" || src != "" || name != "" { + t.Errorf("got (%q,%q,%q), want (,,)", openId, src, name) + } +} + +// --user matches a stored row by UserOpenId — name is recovered from that row. +func TestResolveLoginHolder_FlagWithStoredRow_ReturnsName(t *testing.T) { + app := &core.AppConfig{ + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + } + openId, src, name := resolveLoginHolder("ou_alice", "flag", app) + if openId != "ou_alice" || src != "flag" || name != "Alice" { + t.Errorf("got (%q,%q,%q), want (ou_alice,flag,Alice)", openId, src, name) + } +} + +// Fail-closed: an unknown holderSource is a programmer bug at the +// resolver — verifyHolder must abort with an internal error rather than +// silently downgrade to soft-advisory. This keeps the explicit/implicit +// contract self-enforcing: a refactor that introduces a new label like +// "profile" or "keychain" can't quietly leak a fresh token by routing +// through the default arm. +func TestVerifyHolder_UnknownSource_FailsClosed(t *testing.T) { + warning, abortErr := verifyHolder("ou_alice", "Alice", "profile", "ou_bob", "Bob") + if abortErr == nil { + t.Fatal("expected abortErr on unknown holderSource") + } + if warning != nil { + t.Errorf("unknown source must NOT emit a warning (it's a programmer bug, not an operator nudge): %#v", warning) + } + // The error message must name the offending source so a developer can + // fix the producer without spelunking through the codebase. + if !strings.Contains(abortErr.Error(), `"profile"`) { + t.Errorf("error must name the unknown source verbatim, got: %v", abortErr) + } + // Also pin the structural classification — internal errors flow + // through the dispatcher with a distinct exit code from + // SubtypeInvalidArgument hard aborts. + var internalErr *errs.InternalError + if !errors.As(abortErr, &internalErr) { + t.Errorf("expected *errs.InternalError, got %T: %v", abortErr, abortErr) + } +} diff --git a/cmd/auth/login_holder_username_test.go b/cmd/auth/login_holder_username_test.go new file mode 100644 index 000000000..bbb78f721 --- /dev/null +++ b/cmd/auth/login_holder_username_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" +) + +// Regression: --user accepts an open_id OR a username (cmd/global_flags.go +// documents both). When the operator passes the stored UserName, +// resolveLoginHolder MUST translate it to the matching UserOpenId so +// verifyHolder's equality check sees apples-to-apples open_ids. +// +// Pre-fix, `lark-cli auth login --user Alice` (where Alice was already +// stored as ou_alice) was rejected with "login holder mismatch: +// requested user \"Alice\" but authorized user is \"ou_alice\"" because +// resolveLoginHolder returned "Alice" verbatim and verifyHolder did +// plain string equality. +func TestResolveLoginHolder_UserName_TranslatesToOpenId(t *testing.T) { + app := &core.AppConfig{ + Users: []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }, + } + + openId, src, name := resolveLoginHolder("Alice", "flag", app) + if openId != "ou_alice" { + t.Errorf("expected username Alice translated to ou_alice; got %q", openId) + } + if src != "flag" { + t.Errorf("source must be preserved (flag); got %q", src) + } + if name != "Alice" { + t.Errorf("expected matched name Alice, got %q", name) + } + + // Same translation for env-source attribution. + openId, src, name = resolveLoginHolder("Bob", "env", app) + if openId != "ou_bob" || src != "env" || name != "Bob" { + t.Errorf("env Bob: got (%q,%q,%q), want (ou_bob,env,Bob)", openId, src, name) + } +} + +// Brand-new open_id that doesn't yet exist in app.Users[] must pass +// through verbatim — first-time multi-user login of ou_charlie should +// not be rejected at the holder-resolution step. +func TestResolveLoginHolder_NewOpenId_PassesThrough(t *testing.T) { + app := &core.AppConfig{ + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + } + openId, src, name := resolveLoginHolder("ou_charlie", "flag", app) + if openId != "ou_charlie" || src != "flag" || name != "" { + t.Errorf("brand-new ou_charlie should pass through: got (%q,%q,%q)", openId, src, name) + } +} + +// End-to-end on the holder-resolve + holder-verify pair: username re-login +// must be accepted. Pre-fix this combo aborted with SubtypeInvalidArgument. +func TestVerifyHolder_AfterResolveLoginHolder_UserNameAccepted(t *testing.T) { + app := &core.AppConfig{ + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + } + holder, src, name := resolveLoginHolder("Alice", "flag", app) + if warning, abortErr := verifyHolder(holder, name, src, "ou_alice", "Alice"); abortErr != nil { + var cfgErr *errs.ConfigError + if errors.As(abortErr, &cfgErr) && cfgErr.Subtype == errs.SubtypeInvalidArgument { + t.Fatalf("username re-login was rejected by verifyHolder — translation regression: %v", abortErr) + } + t.Fatalf("unexpected abortErr: %v", abortErr) + } else if warning != nil { + t.Errorf("matching username should not emit a warning: %#v", warning) + } +} + +// Username translation must also work when the source is env (e.g. +// LARKSUITE_CLI_OPEN_ID=Alice). +func TestVerifyHolder_AfterResolveLoginHolder_UserNameAcceptedFromEnv(t *testing.T) { + app := &core.AppConfig{ + Users: []core.AppUser{{UserOpenId: "ou_bob", UserName: "Bob"}}, + } + holder, src, name := resolveLoginHolder("Bob", "env", app) + if warning, abortErr := verifyHolder(holder, name, src, "ou_bob", "Bob"); abortErr != nil { + t.Errorf("LARKSUITE_CLI_OPEN_ID= re-login rejected: %v", abortErr) + } else if warning != nil { + t.Errorf("matching env username should not emit a warning: %#v", warning) + } +} + +// Counter-test: when the operator passes a username that does NOT match +// the freshly-authorized user, verifyHolder must STILL reject. This +// guards against the translation step hiding genuine mismatches. +func TestVerifyHolder_AfterResolveLoginHolder_UserNameDifferentAuthorizedUser(t *testing.T) { + app := &core.AppConfig{ + Users: []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }, + } + // Operator passes --user Alice but authorizes as Bob — must reject. + holder, src, name := resolveLoginHolder("Alice", "flag", app) + warning, abortErr := verifyHolder(holder, name, src, "ou_bob", "Bob") + if abortErr == nil { + t.Fatal("expected mismatch error when authorized user differs from --user, got nil") + } + if warning != nil { + t.Errorf("flag-source mismatch must not emit warning; got %#v", warning) + } + var cfgErr *errs.ConfigError + if !errors.As(abortErr, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T", abortErr) + } + if cfgErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want SubtypeInvalidArgument", cfgErr.Subtype) + } +} diff --git a/cmd/auth/login_legacy_workflow_test.go b/cmd/auth/login_legacy_workflow_test.go new file mode 100644 index 000000000..d17934340 --- /dev/null +++ b/cmd/auth/login_legacy_workflow_test.go @@ -0,0 +1,568 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/zalando/go-keyring" + + "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +// Legacy upgrade workflow regression: pre-multi-user lark-cli let +// `auth login` silently REPLACE the single stored user. Operators +// scripted that workflow ("logout, login as the next person, run +// stuff") for years. +// +// After the multi-user port introduced verifyHolder, a fresh +// authorization whose upstream open_id disagreed with +// AppConfig.CurrentUser was treated identically to a --user / env +// mismatch — a hard abort with SubtypeInvalidArgument. That broke +// the legacy workflow with NO security benefit: the operator did +// not declare any explicit target user, the only signal of intent +// was the stale CurrentUser left over from a prior login. +// +// The fix splits verifyHolder by holderSource: +// - "flag" / "env" stay hard aborts (operator declared an explicit +// target — typo / phishing / redirect guard). +// - "" (implied via AppConfig.CurrentUser) is now a soft advisory: +// login proceeds, the new user is appended to Users[], the +// active user (CurrentUser) does not silently switch, and a +// stderr WARN tells the operator how to switch via +// `auth users use`. +// +// This test pins down the legacy-workflow contract: +// +// Pre-state: Users=[Alice], CurrentUser=Alice, no --user / env +// Action: `auth login`, upstream returns Bob's open_id +// Post-state: Users=[Alice, Bob], CurrentUser=Alice, advisory in stderr +// Exit: 0 (success) +func TestAuthLoginRun_LegacyReLoginWorkflow_AdvisoryNotAbort(t *testing.T) { + keyring.MockInit() + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + }) + f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + stubLoginHTTP(t, reg, "ou_bob", "Bob") + + // Critical: NO --user, NO LARKSUITE_CLI_OPEN_ID. The implied holder + // is AppConfig.CurrentUser=ou_alice. Upstream returns ou_bob. + // Pre-fix this combo aborted hard. Post-fix it must proceed with + // an advisory. + f.Invocation = cmdutil.InvocationContext{Profile: "default"} + + if err := authLoginRun(&LoginOptions{ + Factory: f, Ctx: context.Background(), Scope: "im:message:send", + }); err != nil { + t.Fatalf("legacy re-login workflow regressed — got abort error: %v", err) + } + + // Bob must be appended; Alice must remain; CurrentUser must not switch. + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + users := saved.Apps[0].Users + if len(users) != 2 { + t.Fatalf("Users len = %d, want 2 (Alice preserved + Bob appended); got %#v", len(users), users) + } + if users[0].UserOpenId != "ou_alice" || users[1].UserOpenId != "ou_bob" { + t.Errorf("Users = [%q,%q], want [ou_alice,ou_bob]", users[0].UserOpenId, users[1].UserOpenId) + } + if saved.Apps[0].CurrentUser != "ou_alice" { + t.Errorf("CurrentUser = %q, want ou_alice (must NOT silently switch)", saved.Apps[0].CurrentUser) + } + // Bob's token must have landed (login actually proceeded). + if got := larkauth.GetStoredToken("cli_test", "ou_bob"); got == nil { + t.Error("Bob's token slot is empty — login did not actually proceed") + } + + // Advisory must reach stderr and contain the user-facing contract: + // names both users, says "active user stays", points at users use, + // AND uses the brand prefix every other lark-cli WARN line uses + // (operators grep stderr for `[lark-cli]` to filter CI logs). + stderrStr := stderr.String() + wantSubs := []string{ + "[lark-cli]", + "WARN", + "auth login", + "ou_alice", + "ou_bob", + "active user", + "auth users use", + } + for _, sub := range wantSubs { + if !strings.Contains(stderrStr, sub) { + t.Errorf("stderr missing %q\nfull stderr:\n%s", sub, stderrStr) + } + } + // The advisory MUST NOT contradict itself — operator did not pass --user. + if strings.Contains(stderrStr, "re-run without") { + t.Errorf("advisory must not suggest 're-run without' (operator did not pass --user); stderr:\n%s", stderrStr) + } +} + +// Counter-test: an EXPLICIT --user mismatch (not the legacy workflow) +// must STILL abort. The split is by holderSource, not just "is there +// a CurrentUser at all" — the soft path is intentionally narrow. +func TestAuthLoginRun_ExplicitFlagMismatch_StillAborts(t *testing.T) { + keyring.MockInit() + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + }) + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + stubLoginHTTP(t, reg, "ou_actually_bob", "Bob") + + // Operator typed --user ou_alice, but the device authorized ou_bob. + // This is a typo / phishing guard — must hard-abort regardless of the + // soft-mismatch carve-out for implied holders. + f.Invocation = cmdutil.InvocationContext{ + Profile: "default", UserOpenId: "ou_alice", UserSource: "flag", + } + + err := authLoginRun(&LoginOptions{ + Factory: f, Ctx: context.Background(), Scope: "im:message:send", + }) + if err == nil { + t.Fatal("expected hard abort on flag-source mismatch") + } + if !strings.Contains(err.Error(), "login holder mismatch") { + t.Errorf("expected 'login holder mismatch' error, got: %v", err) + } + // Post-state: nothing persisted for the upstream user (pre-write abort). + if got := larkauth.GetStoredToken("cli_test", "ou_actually_bob"); got != nil { + t.Errorf("ou_actually_bob token persisted despite flag-mismatch abort: %#v", got) + } +} + +// Counter-test: an EXPLICIT env-source mismatch (LARKSUITE_CLI_OPEN_ID +// set in the environment) must ALSO hard-abort. Env-source is the primary +// CI phishing/redirect-guard scenario — operator runs `auth login` in CI +// with the env var pinning who the upstream identity must be, attacker- +// controlled device authorizes a different open_id. A regression that +// silently downgrades env-source to advisory would leak a fresh token +// into the wrong user slot. +// +// The unit-level TestVerifyHolder_EnvMismatch_EnvAttribution proves the +// abort branch in verifyHolder; this test proves the same outcome at the +// integration level by routing through enforceLoginHolderGate inside +// authLoginRun. +func TestAuthLoginRun_ExplicitEnvMismatch_StillAborts(t *testing.T) { + keyring.MockInit() + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + }) + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + stubLoginHTTP(t, reg, "ou_actually_bob", "Bob") + + // Operator's env had LARKSUITE_CLI_OPEN_ID=ou_alice; the device + // authorized ou_actually_bob. UserSource: "env" is what the bootstrap + // resolver stamps for env-sourced overrides. + f.Invocation = cmdutil.InvocationContext{ + Profile: "default", UserOpenId: "ou_alice", UserSource: "env", + } + + err := authLoginRun(&LoginOptions{ + Factory: f, Ctx: context.Background(), Scope: "im:message:send", + }) + if err == nil { + t.Fatal("expected hard abort on env-source mismatch") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *errs.ConfigError on env-source mismatch, got %T: %v", err, err) + } + if cfgErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want SubtypeInvalidArgument", cfgErr.Subtype) + } + // The hint must name the env var so the operator knows which knob to + // fix; otherwise they'll think it's a --user issue and waste time. + if !strings.Contains(cfgErr.Hint, "LARKSUITE_CLI_OPEN_ID") { + t.Errorf("env-source hint must mention LARKSUITE_CLI_OPEN_ID, got: %q", cfgErr.Hint) + } + // Post-state: the upstream user's token must NOT have been persisted — + // the gate runs BEFORE SetStoredToken. + if got := larkauth.GetStoredToken("cli_test", "ou_actually_bob"); got != nil { + t.Errorf("ou_actually_bob token persisted despite env-mismatch abort: %#v", got) + } + // And no row in Users[]. + saved, _ := core.LoadMultiAppConfig() + for _, u := range saved.Apps[0].Users { + if u.UserOpenId == "ou_actually_bob" { + t.Errorf("config grew an ou_actually_bob row despite env-source mismatch: %#v", u) + } + } +} + +// Poll-path coverage: --no-wait + --device-code is the documented +// workflow for headless / SSH agents, and authLoginPollDeviceCode runs +// the same holder gate as the immediate path. If a refactor drops or +// drifts the gate at the second site, the legacy-workflow regression +// returns silently for the headless workflow with no integration test +// catching it. The gate now lives in enforceLoginHolderGate so the two +// sites cannot drift, but pin the contract end-to-end on the poll path +// too — defense in depth. +func TestAuthLoginPollDeviceCode_LegacyReLoginWorkflow_AdvisoryNotAbort(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + t.Setenv("HOME", t.TempDir()) + + // Pre-state: Alice in CurrentUser. Bob authorizes via the device flow, + // but the operator did NOT pass --user — implied holder is ou_alice, + // upstream is ou_bob. Pre-fix the poll-path gate aborted; post-fix it + // must emit the advisory and proceed. + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + // The poll path hits ONLY the OAuth token endpoint (device-code grant) + // and the user-info endpoint — never device-authorization. Register + // just those two; the registry's Verify() check fails on unmatched + // stubs, so registering device-authorization here would falsely fail. + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: larkauth.PathOAuthTokenV2, + Body: map[string]interface{}{ + "access_token": "user-access-token", + "refresh_token": "refresh-token", + "expires_in": 7200, + "refresh_token_expires_in": 604800, + "scope": "im:message:send offline_access", + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: larkauth.PathUserInfoV1, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "open_id": "ou_bob", + "union_id": "uid_ou_bob", + "name": "Bob", + }, + }, + }) + + // Cache the requested-scope record the way --no-wait would have. + if err := saveLoginRequestedScope("device-code", "im:message:send"); err != nil { + t.Fatalf("saveLoginRequestedScope: %v", err) + } + + // Critical: NO --user, NO env. Implied holder is AppConfig.CurrentUser=ou_alice. + f.Invocation = cmdutil.InvocationContext{Profile: "default"} + + if err := authLoginRun(&LoginOptions{ + Factory: f, + Ctx: context.Background(), + DeviceCode: "device-code", + }); err != nil { + t.Fatalf("poll-path legacy re-login regressed — got abort error: %v", err) + } + + // Same post-state contract as the immediate-path test: + saved, _ := core.LoadMultiAppConfig() + users := saved.Apps[0].Users + if len(users) != 2 { + t.Fatalf("Users len = %d, want 2 (Alice preserved + Bob appended); got %#v", len(users), users) + } + if users[0].UserOpenId != "ou_alice" || users[1].UserOpenId != "ou_bob" { + t.Errorf("Users = [%q,%q], want [ou_alice,ou_bob]", users[0].UserOpenId, users[1].UserOpenId) + } + if saved.Apps[0].CurrentUser != "ou_alice" { + t.Errorf("CurrentUser = %q, want ou_alice (poll path must not silently switch)", saved.Apps[0].CurrentUser) + } + if got := larkauth.GetStoredToken("cli_test", "ou_bob"); got == nil { + t.Error("Bob's token slot is empty — poll-path login did not actually proceed") + } + stderrStr := stderr.String() + for _, sub := range []string{"[lark-cli]", "WARN", "ou_alice", "ou_bob", "active user", "auth users use"} { + if !strings.Contains(stderrStr, sub) { + t.Errorf("poll-path stderr missing %q\nfull stderr:\n%s", sub, stderrStr) + } + } +} + +// JSON-mode integration coverage for S1: a legacy-workflow re-login on a +// JSON invocation must surface the `holder_mismatch_warning` structured +// field on the success payload AND keep emitting the human-readable WARN +// to stderr. The two channels are independent — JSON consumers branch on +// the structured field; humans tailing 2>&1 still see the same advisory. +func TestAuthLoginRun_LegacyReLoginWorkflow_JSONIncludesHolderMismatchWarning(t *testing.T) { + keyring.MockInit() + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + }) + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + stubLoginHTTP(t, reg, "ou_bob", "Bob") + f.Invocation = cmdutil.InvocationContext{Profile: "default"} + + if err := authLoginRun(&LoginOptions{ + Factory: f, Ctx: context.Background(), Scope: "im:message:send", JSON: true, + }); err != nil { + t.Fatalf("legacy re-login regressed in JSON mode: %v", err) + } + + // JSON mode emits NDJSON: the device_authorization line, then the + // authorization_complete line. The structured holder warning rides on + // the latter, so parse the last non-empty line. + payload := lastJSONLine(t, stdout.String()) + raw, ok := payload["holder_mismatch_warning"] + if !ok { + t.Fatalf("payload missing holder_mismatch_warning; payload=%v", payload) + } + got, ok := raw.(map[string]interface{}) + if !ok { + t.Fatalf("holder_mismatch_warning = %T, want object", raw) + } + if got["type"] != "holder_currentuser_mismatch" { + t.Errorf("type = %v, want holder_currentuser_mismatch", got["type"]) + } + if got["holder_open_id"] != "ou_alice" || got["fresh_open_id"] != "ou_bob" { + t.Errorf("typed open_ids wrong: %v / %v", got["holder_open_id"], got["fresh_open_id"]) + } + if got["holder_user_name"] != "Alice" || got["fresh_user_name"] != "Bob" { + t.Errorf("typed user names wrong: %v / %v", got["holder_user_name"], got["fresh_user_name"]) + } + + // Stderr WARN must still be there for humans tailing 2>&1. + stderrStr := stderr.String() + for _, sub := range []string{"[lark-cli]", "WARN", "ou_alice", "ou_bob", "active user"} { + if !strings.Contains(stderrStr, sub) { + t.Errorf("JSON mode must still emit human WARN to stderr; missing %q\nfull stderr:\n%s", sub, stderrStr) + } + } +} + +// JSON-mode counter-test: a clean login (no holder mismatch) must NOT +// emit `holder_mismatch_warning`. JSON consumers branch on key presence — +// silent emission of an empty object would falsely trigger downstream +// alerting on every routine login. +func TestAuthLoginRun_CleanLogin_JSONOmitsHolderMismatchWarning(t *testing.T) { + keyring.MockInit() + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + }) + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + // Upstream returns the SAME user — no mismatch. + stubLoginHTTP(t, reg, "ou_alice", "Alice") + f.Invocation = cmdutil.InvocationContext{Profile: "default"} + + if err := authLoginRun(&LoginOptions{ + Factory: f, Ctx: context.Background(), Scope: "im:message:send", JSON: true, + }); err != nil { + t.Fatalf("clean re-login failed: %v", err) + } + + payload := lastJSONLine(t, stdout.String()) + if _, ok := payload["holder_mismatch_warning"]; ok { + t.Errorf("clean login leaked holder_mismatch_warning: %v", payload["holder_mismatch_warning"]) + } +} + +// lastJSONLine parses the last non-empty newline-delimited JSON object +// from stdout. authLoginRun in JSON mode emits one NDJSON event per +// state transition (device_authorization, then authorization_complete); +// integration tests checking the success payload key on the last event. +func lastJSONLine(t *testing.T, raw string) map[string]interface{} { + t.Helper() + lines := strings.Split(strings.TrimRight(raw, "\n"), "\n") + if len(lines) == 0 { + t.Fatalf("stdout is empty") + } + last := lines[len(lines)-1] + var payload map[string]interface{} + if err := json.Unmarshal([]byte(last), &payload); err != nil { + t.Fatalf("Unmarshal last NDJSON line: %v\nline=%s", err, last) + } + return payload +} + +// Poll-path × JSON-mode cell of the test matrix: the headless --no-wait / +// --device-code workflow run with --json must ALSO surface the structured +// `holder_mismatch_warning` field. The poll path runs through +// authLoginPollDeviceCode, which dispatches to enforceLoginHolderGate — +// the same helper the immediate path uses, but a future refactor that +// drops the JSON wiring at this site (e.g. a poll-only payload helper +// that forgets the holderWarning param) would silently regress only the +// headless workflow. The immediate-path JSON test plus the poll-path +// stderr test cannot together prove this cell; this test does. +func TestAuthLoginPollDeviceCode_LegacyReLoginWorkflow_JSONIncludesHolderMismatchWarning(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + t.Setenv("HOME", t.TempDir()) + + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + // Same two-stub pattern as the stderr-only poll-path test: only token + // + user-info; device-authorization is never hit on the poll path and + // the registry's Verify() check fails on unmatched stubs. + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: larkauth.PathOAuthTokenV2, + Body: map[string]interface{}{ + "access_token": "user-access-token", + "refresh_token": "refresh-token", + "expires_in": 7200, + "refresh_token_expires_in": 604800, + "scope": "im:message:send offline_access", + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: larkauth.PathUserInfoV1, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "open_id": "ou_bob", + "union_id": "uid_ou_bob", + "name": "Bob", + }, + }, + }) + + if err := saveLoginRequestedScope("device-code", "im:message:send"); err != nil { + t.Fatalf("saveLoginRequestedScope: %v", err) + } + + // Critical for THIS cell: NO --user, NO env (legacy workflow), JSON: true, + // DeviceCode set (poll path). All four conditions must coincide for + // the regression we're guarding against to be reachable. + f.Invocation = cmdutil.InvocationContext{Profile: "default"} + if err := authLoginRun(&LoginOptions{ + Factory: f, + Ctx: context.Background(), + DeviceCode: "device-code", + JSON: true, + }); err != nil { + t.Fatalf("poll-path JSON legacy re-login regressed: %v", err) + } + + // The poll path emits a SINGLE NDJSON line (no device_authorization + // event — that already happened in the prior --no-wait invocation). + // The structured warning rides on that one line. lastJSONLine handles + // both cases — it just takes the last non-empty line. + payload := lastJSONLine(t, stdout.String()) + if payload["event"] != "authorization_complete" { + t.Errorf("event = %v, want authorization_complete", payload["event"]) + } + raw, ok := payload["holder_mismatch_warning"] + if !ok { + t.Fatalf("poll-path JSON payload missing holder_mismatch_warning; payload=%v", payload) + } + got, ok := raw.(map[string]interface{}) + if !ok { + t.Fatalf("holder_mismatch_warning = %T, want object", raw) + } + if got["type"] != "holder_currentuser_mismatch" { + t.Errorf("poll-path type = %v, want holder_currentuser_mismatch", got["type"]) + } + if got["holder_open_id"] != "ou_alice" || got["fresh_open_id"] != "ou_bob" { + t.Errorf("poll-path open_ids wrong: %v / %v", got["holder_open_id"], got["fresh_open_id"]) + } + if got["holder_user_name"] != "Alice" || got["fresh_user_name"] != "Bob" { + t.Errorf("poll-path user names wrong: %v / %v", got["holder_user_name"], got["fresh_user_name"]) + } + + // Stderr WARN must STILL be emitted on the poll path even in JSON + // mode — the dual-channel contract is the same. A regression that + // silenced stderr when stdout was JSON would hide the advisory from + // humans who tail 2>&1 their headless invocations. + stderrStr := stderr.String() + for _, sub := range []string{"[lark-cli]", "WARN", "ou_alice", "ou_bob", "active user"} { + if !strings.Contains(stderrStr, sub) { + t.Errorf("poll-path JSON mode must still emit human WARN to stderr; missing %q\nfull stderr:\n%s", sub, stderrStr) + } + } + + // Persistence post-state on the poll path's JSON cell mirrors the + // stderr-only poll test: Bob appended, Alice preserved, CurrentUser + // unchanged. JSON-vs-stderr is just an output-channel switch; it + // must not bend the multi-user write contract. + saved, _ := core.LoadMultiAppConfig() + users := saved.Apps[0].Users + if len(users) != 2 || users[0].UserOpenId != "ou_alice" || users[1].UserOpenId != "ou_bob" { + t.Errorf("Users post-state wrong on poll JSON path: %#v", users) + } + if saved.Apps[0].CurrentUser != "ou_alice" { + t.Errorf("CurrentUser = %q, want ou_alice (poll JSON path must not silently switch)", saved.Apps[0].CurrentUser) + } + if got := larkauth.GetStoredToken("cli_test", "ou_bob"); got == nil { + t.Error("Bob's token slot is empty — poll JSON path login did not actually proceed") + } +} diff --git a/cmd/auth/login_new_user_test.go b/cmd/auth/login_new_user_test.go new file mode 100644 index 000000000..d3fabf44c --- /dev/null +++ b/cmd/auth/login_new_user_test.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "strings" + "testing" + + "github.com/zalando/go-keyring" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Regression: pre-fix authLoginRun called f.Config(), which goes through +// the credential provider's strict three-rung resolver. With +// `--user ou_new_user` against a profile holding only ou_alice, the +// resolver erred at the user-rung selector ("user 'ou_new_user' not +// found in profile ...") BEFORE the device flow could even start. +// +// The new-user login path was structurally unreachable: the CLI +// effectively required the operator to first manually edit config.json +// to register the new open_id, then run login. +// +// Fix: authLoginRun resolves at the profile rung only via +// core.ResolveProfileConfigForLogin. Strict user-rung reconciliation +// still happens — but in verifyHolder, AFTER the upstream open_id is +// known (so the comparison is "did you authorize as the user you +// asked for?", not "is this user already in the config?"). + +// TestAuthLoginRun_NewUserOverride_ReachesDeviceFlow proves login +// gets past the resolve when --user names an open_id not in Users[]. +// The device-flow HTTP layer doesn't run here (we don't stub it), so +// the test asserts the error is NOT the user-rung pre-check error; +// any later error (network, mock-missing) is acceptable evidence +// that the resolve was not the gate. +func TestAuthLoginRun_NewUserOverride_ReachesDeviceFlow(t *testing.T) { + keyring.MockInit() + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + }) + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + stubLoginHTTP(t, reg, "ou_new_user", "Newcomer") + + // --user ou_new_user names someone NOT in Users[]. + f.Invocation = cmdutil.InvocationContext{Profile: "default", UserOpenId: "ou_new_user", UserSource: "flag"} + + err := authLoginRun(&LoginOptions{ + Factory: f, Ctx: context.Background(), Scope: "im:message:send", + }) + if err != nil { + // Pre-fix: this would fail with the user-rung resolution + // error. Post-fix: the resolve passes and the device flow + // runs to completion (we stubbed every HTTP leg). + if strings.Contains(err.Error(), "user \"ou_new_user\" not found") { + t.Fatalf("login still failing on user-rung pre-check: %v", err) + } + // Any other error is fine for this test: we only care that + // the resolve was not the gate. Print it for debugging. + t.Logf("login returned a non-resolve error (acceptable for this regression test): %v", err) + } + + // Stronger evidence the device flow ran: ou_new_user must be in + // Users[] now. (If the resolve gated, syncLoginUserToProfile never + // ran and Users[] would still be just [ou_alice].) + saved, ferr := core.LoadMultiAppConfig() + if ferr != nil { + t.Fatalf("LoadMultiAppConfig: %v", ferr) + } + if len(saved.Apps) == 0 { + t.Fatalf("config has no apps after login") + } + users := saved.Apps[0].Users + foundNew := false + for _, u := range users { + if u.UserOpenId == "ou_new_user" { + foundNew = true + break + } + } + if !foundNew { + t.Errorf("ou_new_user was not persisted; the resolve gated the device flow. Users=%v", users) + } +} + +// Counter-test: --user ou_new_user paired with an UPSTREAM open_id of +// ou_alice (mismatch) must STILL reach verifyHolder and abort there +// with SubtypeInvalidArgument. Pre-fix, the resolve gate aborted +// upstream so this code path was unreachable; post-fix verifyHolder +// is the sole authority on holder reconciliation. +func TestAuthLoginRun_NewUserOverride_MismatchAtVerifyHolder(t *testing.T) { + keyring.MockInit() + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + }) + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + // Operator asks for ou_new_user, but the device they authorize on + // reports a different open_id — a phishing / typo guard. + stubLoginHTTP(t, reg, "ou_actually_alice", "Alice") + + f.Invocation = cmdutil.InvocationContext{Profile: "default", UserOpenId: "ou_new_user", UserSource: "flag"} + + err := authLoginRun(&LoginOptions{ + Factory: f, Ctx: context.Background(), Scope: "im:message:send", + }) + if err == nil { + t.Fatalf("expected verifyHolder mismatch error") + } + if !strings.Contains(err.Error(), "login holder mismatch") { + t.Fatalf("expected verifyHolder mismatch, got: %v", err) + } +} diff --git a/cmd/auth/login_postflock_r2_test.go b/cmd/auth/login_postflock_r2_test.go new file mode 100644 index 000000000..957a88241 --- /dev/null +++ b/cmd/auth/login_postflock_r2_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/zalando/go-keyring" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/core" +) + +// Regression for the second post-flock-load wrap site: +// syncLoginUserToProfile reloads under the flock and previously +// collapsed any error into errs.NewInternalError(SubtypeStorage, ...). +// If config.json was rewritten by a newer lark-cli between the +// pre-login resolve and the post-flock reload, the operator would +// see a generic "load config" storage error instead of the +// upgrade-required hint, while their access token had ALREADY been +// written to the keychain — leaving them in a state where the next +// command would hit R2 again, only via a different code path. +// +// Pin core.PassThroughOrNotConfigured(err) here so the +// *core.ConfigError + Hint reach the dispatcher. +func TestSyncLoginUserToProfile_R2ForwardIncompat_PassesUpgradeHint(t *testing.T) { + keyring.MockInit() + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + t.Setenv("HOME", t.TempDir()) + + // Seed a future-schema config — syncLoginUserToProfile reads it + // post-flock, so the R2 envelope must come back from THIS path. + future := []byte(`{"schemaVersion":99,"apps":[{"appId":"cli_x","appSecret":"s","brand":"feishu","users":[]}]}` + "\n") + if err := os.WriteFile(filepath.Join(dir, "config.json"), future, 0600); err != nil { + t.Fatalf("seed config.json: %v", err) + } + + root := larkauth.NewLocalRoot(core.GetConfigDir()) + var errOut bytes.Buffer + err := syncLoginUserToProfile( + root, + "target", "cli_x", + "ou_alice", "uni_alice", "Alice", + "contact:user.email:readonly", + time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), + &errOut, + ) + if err == nil { + t.Fatal("syncLoginUserToProfile must surface R2 upgrade error from a future schema, got nil") + } + // The OUTER envelope must be *core.ConfigError so the dispatcher's + // PromoteConfigError step routes it to SubtypeInvalidConfig with the + // upgrade hint. Pre-fix the outer was *errs.InternalError(SubtypeStorage) + // — errors.As would still walk through WithCause and find the inner + // ConfigError, so the assertion has to be on the concrete top type, not + // on errors.As reachability. + if _, ok := err.(*core.ConfigError); !ok { + t.Fatalf("expected outer *core.ConfigError so dispatcher routes the R2 hint; got %T: %v", err, err) + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *core.ConfigError preserved by PassThroughOrNotConfigured, got %T: %v", err, err) + } + if !strings.Contains(cfgErr.Message, "newer lark-cli") { + t.Errorf("R2 message lost; got %q", cfgErr.Message) + } + if !strings.Contains(cfgErr.Hint, "upgrade lark-cli") { + t.Errorf("R2 upgrade hint lost; got %q", cfgErr.Hint) + } +} diff --git a/cmd/auth/login_result.go b/cmd/auth/login_result.go index 84eccc97d..9334e61a7 100644 --- a/cmd/auth/login_result.go +++ b/cmd/auth/login_result.go @@ -140,15 +140,39 @@ func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary } // writeLoginSuccess emits the successful login payload in either JSON or text -// format together with the computed scope breakdown. -func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) { +// format together with the computed scope breakdown. holderWarning is non-nil +// only when the soft-mismatch advisory fired in enforceLoginHolderGate; it is +// surfaced as a structured `holder_mismatch_warning` field in JSON mode so +// non-human consumers can key on the active-user-stays semantics without +// parsing the stderr WARN line. +// +// Returns a non-nil error only on the JSON branch when the encoder fails to +// write the success line — broken pipe (`auth login --json | head -1`), +// closed stdout (a tee dying mid-stream), full disk on a redirected file, +// etc. The token is already persisted by the time this function runs, so +// the error is purely an observability signal: a script that keys on exit +// code to confirm event delivery would otherwise see exit 0 despite a +// truncated payload. Mirrors the pattern at login.go:296-300 / :315-319 +// where the device_authorization event surfaces encoder errors as +// SubtypeSDKError; the success event must do the same so the entire NDJSON +// stream's write-failure semantics stay symmetric. +func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary, holderWarning *holderMismatchWarning) error { if summary == nil { summary = &loginScopeSummary{} } if opts.JSON { - b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil)) - fmt.Fprintln(f.IOStreams.Out, string(b)) - return + // SetEscapeHTML(false) keeps the encoding policy uniform across the + // whole NDJSON stream — the device_authorization line in login.go + // already disables HTML escaping; using package-level json.Marshal + // here would silently flip <, >, & to their entity escapes on the + // success line, breaking byte-comparing consumers that round-trip + // the two lines. + enc := json.NewEncoder(f.IOStreams.Out) + enc.SetEscapeHTML(false) + if err := enc.Encode(authorizationCompletePayload(openId, userName, summary, nil, holderWarning)); err != nil { + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write authorization_complete JSON: %v", err).WithCause(err) + } + return nil } fmt.Fprintln(f.IOStreams.ErrOut) @@ -157,19 +181,33 @@ func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, op if len(summary.Missing) == 0 && msg.StatusHint != "" { fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint) } + return nil } // handleLoginScopeIssue prints or returns a structured missing-scope result // while preserving a successful login outcome when authorization completed. -func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error { +// holderWarning is threaded through so a login that fires BOTH a soft +// holder-mismatch AND a missing-scope issue still surfaces the holder +// warning as a structured field on the success-side JSON payload. +// +// On the loginSucceeded + JSON branch, an encoder write failure is +// surfaced as a SubtypeSDKError instead of the usual ErrBare ExitAuth — +// the operator needs to see that the success-with-issue line was never +// delivered, not just that exit code was non-zero. Without this, a +// pipeline keyed on exit code would interpret "encoder failed" as +// "auth issue surfaced" and miss the silent truncation. +func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string, holderWarning *holderMismatchWarning) error { if issue == nil { return nil } loginSucceeded := openId != "" if opts.JSON { if loginSucceeded { - b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue)) - fmt.Fprintln(f.IOStreams.Out, string(b)) + enc := json.NewEncoder(f.IOStreams.Out) + enc.SetEscapeHTML(false) + if err := enc.Encode(authorizationCompletePayload(openId, userName, issue.Summary, issue, holderWarning)); err != nil { + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write authorization_complete JSON: %v", err).WithCause(err) + } return output.ErrBare(output.ExitAuth) } return errs.NewPermissionError(errs.SubtypeMissingScope, "%s", issue.Message). @@ -197,8 +235,11 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory } // authorizationCompletePayload builds the JSON payload for a completed login, -// optionally attaching a warning when requested scopes are missing. -func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} { +// optionally attaching a warning when requested scopes are missing and/or +// when the operator's implied holder disagreed with the freshly-authorized +// identity (soft mismatch). The two warnings are independent — a login can +// surface both at once. +func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue, holderWarning *holderMismatchWarning) map[string]interface{} { if summary == nil { summary = &loginScopeSummary{} } @@ -220,5 +261,20 @@ func authorizationCompletePayload(openId, userName string, summary *loginScopeSu "hint": issue.Hint, } } + if holderWarning != nil { + // Distinct field name from the scope warning so JSON consumers can + // branch on each independently. The `type` discriminator stays for + // symmetry with `warning` and forward-compatibility (future holder + // warning subtypes — e.g. "holder_active_user_drift" — would slot in + // here without restructuring the payload). + payload["holder_mismatch_warning"] = map[string]interface{}{ + "type": "holder_currentuser_mismatch", + "message": holderWarning.Message, + "holder_open_id": holderWarning.HolderOpenId, + "holder_user_name": holderWarning.HolderUserName, + "fresh_open_id": holderWarning.FreshOpenId, + "fresh_user_name": holderWarning.FreshUserName, + } + } return payload } diff --git a/cmd/auth/login_result_test.go b/cmd/auth/login_result_test.go index 5f14d40dc..5926337c9 100644 --- a/cmd/auth/login_result_test.go +++ b/cmd/auth/login_result_test.go @@ -4,8 +4,11 @@ package auth import ( + "encoding/json" "errors" + "fmt" "reflect" + "strings" "testing" "github.com/larksuite/cli/errs" @@ -40,6 +43,7 @@ func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) { }, "", // openId empty -> loginSucceeded = false "tester", + nil, // no holder warning ) if err == nil { @@ -59,3 +63,256 @@ func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) { t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing) } } + +// TestWriteLoginSuccess_JSONIncludesHolderMismatchWarning pins the JSON +// surface contract for S1: when the soft holder-mismatch advisory fires +// (operator did not pass --user / env, AppConfig.CurrentUser left over +// from a prior login disagrees with the freshly-authorized identity), +// the success payload must carry a structured `holder_mismatch_warning` +// field so non-human consumers (CI dashboards, agent runtimes, etc.) can +// branch on the active-user-stays semantics without parsing the stderr +// WARN line. +func TestWriteLoginSuccess_JSONIncludesHolderMismatchWarning(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + holderWarning := &holderMismatchWarning{ + HolderOpenId: "ou_alice", + HolderUserName: "Alice", + FreshOpenId: "ou_bob", + FreshUserName: "Bob", + Message: "[lark-cli] [WARN] auth login: ... active user stays Alice ...", + } + + writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_bob", "Bob", &loginScopeSummary{ + Granted: []string{"im:message:send"}, + }, holderWarning) + + var payload map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String()) + } + + raw, ok := payload["holder_mismatch_warning"] + if !ok { + t.Fatalf("payload missing holder_mismatch_warning field; full payload: %s", stdout.String()) + } + got, ok := raw.(map[string]interface{}) + if !ok { + t.Fatalf("holder_mismatch_warning is %T, want object", raw) + } + + // Discriminator — symmetric with the existing scope `warning.type` + // field so consumers can switch on a single key. + if got["type"] != "holder_currentuser_mismatch" { + t.Errorf("type = %v, want holder_currentuser_mismatch", got["type"]) + } + // Typed identity fields — the whole point of S1 is letting consumers + // key on these without regex'ing the message string. + if got["holder_open_id"] != "ou_alice" { + t.Errorf("holder_open_id = %v, want ou_alice", got["holder_open_id"]) + } + if got["holder_user_name"] != "Alice" { + t.Errorf("holder_user_name = %v, want Alice", got["holder_user_name"]) + } + if got["fresh_open_id"] != "ou_bob" { + t.Errorf("fresh_open_id = %v, want ou_bob", got["fresh_open_id"]) + } + if got["fresh_user_name"] != "Bob" { + t.Errorf("fresh_user_name = %v, want Bob", got["fresh_user_name"]) + } + // Message stays for humans tailing 2>&1 of a JSON-mode invocation + // against a TTY (rare but documented), and as a compatibility shim for + // consumers that key on text already. Pin that the brand prefix + // survives JSON encoding intact. + msg, _ := got["message"].(string) + if !strings.Contains(msg, "[lark-cli]") { + t.Errorf("message lost brand prefix: %q", msg) + } + + // Conversely: the missing-scope warning field MUST stay nil when only + // a holder-mismatch fired. Crossing the two warnings here would let a + // future regression silently emit `warning.type=missing_scope` when + // the actual issue was the holder, breaking dashboards. + if _, scopeWarn := payload["warning"]; scopeWarn { + t.Errorf("payload should not have a `warning` field when only holder mismatch fired; got %v", payload["warning"]) + } + // Sanity — the holder mismatch must not corrupt the rest of the success payload. + if payload["event"] != "authorization_complete" { + t.Errorf("event = %v, want authorization_complete", payload["event"]) + } + if payload["user_open_id"] != "ou_bob" { + t.Errorf("user_open_id = %v, want ou_bob (the freshly-authorized id, NOT the implied holder)", payload["user_open_id"]) + } +} + +// Counter-test: a clean login (no holder mismatch) MUST NOT carry a +// holder_mismatch_warning key at all. JSON consumers branch on key +// existence — emitting `holder_mismatch_warning: null` would force them +// to also nil-check, and silent emission of an empty object would falsely +// trigger downstream alerting. +func TestWriteLoginSuccess_JSONOmitsHolderMismatchWarningOnCleanLogin(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_bob", "Bob", &loginScopeSummary{ + Granted: []string{"im:message:send"}, + }, nil) + + var payload map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String()) + } + if _, ok := payload["holder_mismatch_warning"]; ok { + t.Errorf("clean login must omit holder_mismatch_warning; got %v", payload["holder_mismatch_warning"]) + } +} + +// Two-warning test: a single login that fires both the soft holder +// mismatch AND a missing-scope issue must carry BOTH structured fields +// independently. The fields share neither namespace nor lifecycle, so a +// consumer can react to one without the other. +func TestHandleLoginScopeIssue_JSONCarriesBothWarnings(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + holderWarning := &holderMismatchWarning{ + HolderOpenId: "ou_alice", + HolderUserName: "Alice", + FreshOpenId: "ou_bob", + FreshUserName: "Bob", + Message: "[lark-cli] [WARN] auth login: holder mismatch ...", + } + + err := handleLoginScopeIssue( + &LoginOptions{JSON: true}, + getLoginMsg("en"), + f, + &loginScopeIssue{ + Message: "scopes missing", + Hint: "re-login with --scope im:message:send", + Summary: &loginScopeSummary{ + Requested: []string{"im:message:send", "docx:document"}, + Granted: []string{"docx:document"}, + Missing: []string{"im:message:send"}, + }, + }, + "ou_bob", // openId non-empty -> loginSucceeded = true (JSON path) + "Bob", + holderWarning, + ) + // The scope-issue success-with-error path returns ErrBare so the + // dispatcher can set the auth exit code without re-printing. + if err == nil { + t.Fatal("expected ErrBare auth-exit error from handleLoginScopeIssue") + } + + var payload map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String()) + } + if _, ok := payload["warning"]; !ok { + t.Errorf("payload missing scope warning; full payload: %s", stdout.String()) + } + if _, ok := payload["holder_mismatch_warning"]; !ok { + t.Errorf("payload missing holder_mismatch_warning; full payload: %s", stdout.String()) + } + // The two warnings must not collide — different `type` discriminators. + scopeWarn, _ := payload["warning"].(map[string]interface{}) + holderWarn, _ := payload["holder_mismatch_warning"].(map[string]interface{}) + if scopeWarn["type"] == holderWarn["type"] { + t.Errorf("scope and holder warnings share the same type discriminator: %v", scopeWarn["type"]) + } +} + +// End-to-end on the M1+S1 contract: when verifyHolder constructs the +// warning, it sanitizes the human-readable Message but leaves the typed +// HolderUserName / FreshUserName fields raw (per the dual-channel +// contract — stderr/Message gets escape-stripped, JSON typed fields +// keep raw bytes for consumers to escape themselves). +// +// authorizationCompletePayload then copies those struct fields verbatim +// into the JSON map. The unit test on verifyHolder pins the struct +// shape, and TestWriteLoginSuccess_JSONIncludesHolderMismatchWarning +// pins the JSON envelope, but neither asserts the COMPOSITION end-to- +// end. A future regression that called SanitizeForTerminal on the +// typed JSON fields (or stopped sanitizing the Message) would slip +// through both unit tests independently. This test pins the seam. +// +// We construct the holderMismatchWarning with the same shape verifyHolder +// would emit — Message is the post-sanitize string (no \x1b/\x07/\r), +// HolderUserName / FreshUserName carry raw escape bytes (what app.Users[] +// would have stored from a poisoned IdP response), open_ids untouched — +// then call writeLoginSuccess in JSON mode and assert the resulting +// payload preserves both invariants in transit. +func TestWriteLoginSuccess_JSON_SanitizesMessageButPreservesTypedFields(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + const rawHolderName = "Al\x1b[31mice\x1b[0m" + const rawFreshName = "Bo\x07b" + + holderWarning := &holderMismatchWarning{ + HolderOpenId: "ou_alice", + HolderUserName: rawHolderName, // raw bytes — exactly what verifyHolder stores + FreshOpenId: "ou_bob", + FreshUserName: rawFreshName, + // Message is the sanitized text — escape-stripped, with names + // embedded only via the cleaned formatHolderLabel output. + Message: "[lark-cli] [WARN] auth login: the active profile's currentUser is Alice (ou_alice) but the device you authorized was Bob (ou_bob). active user stays Alice (ou_alice). Run `lark-cli auth users use ou_bob` to switch the active user.", + } + + if err := writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_bob", "Bob", &loginScopeSummary{ + Granted: []string{"im:message:send"}, + }, holderWarning); err != nil { + t.Fatalf("writeLoginSuccess error: %v", err) + } + + // json.Decoder over the raw bytes — Unmarshal into map[string]interface{} + // would also work but we want to exercise the same path a JSON consumer + // piping `auth login --json` would hit. + var payload map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("Unmarshal: %v\nstdout: %q", err, stdout.String()) + } + + got, ok := payload["holder_mismatch_warning"].(map[string]interface{}) + if !ok { + t.Fatalf("holder_mismatch_warning is %T, want map[string]interface{}: %v", payload["holder_mismatch_warning"], payload) + } + + // (a) The JSON `message` field is the sanitized stderr line — must + // have NO escape bytes. This is the regression class where a future + // refactor copies the wrong field, or accidentally rebuilds Message + // from raw typed fields. + msg, _ := got["message"].(string) + for _, banned := range []string{"\x1b", "[31m", "[0m", "\x07"} { + if strings.Contains(msg, banned) { + t.Errorf("JSON message field leaked escape %q: %q", banned, msg) + } + } + if !strings.Contains(msg, "[lark-cli]") { + t.Errorf("JSON message must keep brand prefix: %q", msg) + } + + // (b) The JSON typed fields preserve RAW bytes byte-for-byte. JSON's + // own escaping (e.g.  for \x1b) is a wire-format transformation + // that Unmarshal reverses — so after Unmarshal the Go string has the + // raw escape byte back. Asserting equality (not just substring) + // proves no sanitizer leaked into the typed-field path. + if got["holder_user_name"] != rawHolderName { + t.Errorf("holder_user_name lost raw bytes: got %q (% x), want %q (% x)", + got["holder_user_name"], []byte(fmt.Sprint(got["holder_user_name"])), + rawHolderName, []byte(rawHolderName)) + } + if got["fresh_user_name"] != rawFreshName { + t.Errorf("fresh_user_name lost raw bytes: got %q (% x), want %q (% x)", + got["fresh_user_name"], []byte(fmt.Sprint(got["fresh_user_name"])), + rawFreshName, []byte(rawFreshName)) + } + // open_ids stay verbatim by contract — IdP-validated upstream, no + // sanitization at this seam. + if got["holder_open_id"] != "ou_alice" || got["fresh_open_id"] != "ou_bob" { + t.Errorf("open_id fields drifted: %v / %v", got["holder_open_id"], got["fresh_open_id"]) + } +} diff --git a/cmd/auth/login_step8_test.go b/cmd/auth/login_step8_test.go new file mode 100644 index 000000000..dc3018a82 --- /dev/null +++ b/cmd/auth/login_step8_test.go @@ -0,0 +1,276 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/zalando/go-keyring" + + "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +func stubLoginConfigStep8(t *testing.T, multi *core.MultiAppConfig) { + t.Helper() + keyring.MockInit() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } +} + +// stubLoginHTTP registers happy-path device-flow + token + user-info stubs; +// callers can override by re-registering the same URL after this call. +func stubLoginHTTP(t *testing.T, reg *httpmock.Registry, openId, name string) { + t.Helper() + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: larkauth.PathDeviceAuthorization, + Body: map[string]interface{}{ + "device_code": "device-code", + "user_code": "user-code", + "verification_uri": "https://example.com/verify", + "verification_uri_complete": "https://example.com/verify?code=123", + "expires_in": 240, + "interval": 0, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: larkauth.PathOAuthTokenV2, + Body: map[string]interface{}{ + "access_token": "user-access-token", + "refresh_token": "refresh-token", + "expires_in": 7200, + "refresh_token_expires_in": 604800, + "scope": "im:message:send offline_access", + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: larkauth.PathUserInfoV1, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "open_id": openId, + "union_id": "uid_" + openId, + "name": name, + }, + }, + }) +} + +// Different-user login appends rather than replacing — guards against the +// legacy REPLACE semantics where Bob's login would wipe Alice. +func TestAuthLoginRun_Step8_UpsertNewUserAppendsRow(t *testing.T) { + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + }) + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + AppSecret: "secret", + Brand: core.BrandFeishu, + }) + stubLoginHTTP(t, reg, "ou_bob", "Bob") + + f.Invocation = cmdutil.InvocationContext{Profile: "default", UserOpenId: "ou_bob", UserSource: "flag"} + + if err := authLoginRun(&LoginOptions{ + Factory: f, Ctx: context.Background(), Scope: "im:message:send", + }); err != nil { + t.Fatalf("authLoginRun: %v", err) + } + + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + users := saved.Apps[0].Users + if len(users) != 2 { + t.Fatalf("Users len = %d, want 2 (Alice preserved + Bob appended); got %#v", len(users), users) + } + if users[0].UserOpenId != "ou_alice" || users[1].UserOpenId != "ou_bob" { + t.Errorf("Users order = [%q,%q], want [ou_alice,ou_bob]", users[0].UserOpenId, users[1].UserOpenId) + } + if saved.Apps[0].CurrentUser != "ou_alice" { + t.Errorf("CurrentUser = %q, want ou_alice (must NOT silently switch on different-user login)", saved.Apps[0].CurrentUser) + } + // Regression guard: deleted destructive cleanup loop must not touch Alice's slot. + if got := larkauth.GetStoredToken("cli_test", "ou_alice"); got != nil { + t.Errorf("Alice's slot was somehow populated: %#v", got) + } + if got := larkauth.GetStoredToken("cli_test", "ou_bob"); got == nil { + t.Fatal("Bob's token slot is empty after his login") + } +} + +// --user disagrees with upstream open_id: must abort before SetStoredToken +// with a flag-attributed SubtypeInvalidArgument error. +func TestAuthLoginRun_Step8_HolderMismatch_FlagPath(t *testing.T) { + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{Name: "default", AppId: "cli_test"}}, + }) + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + stubLoginHTTP(t, reg, "ou_actually_bob", "Bob") + + f.Invocation = cmdutil.InvocationContext{Profile: "default", UserOpenId: "ou_alice", UserSource: "flag"} + + err := authLoginRun(&LoginOptions{ + Factory: f, Ctx: context.Background(), Scope: "im:message:send", + }) + if err == nil { + t.Fatal("expected holder-mismatch error, got nil") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) + } + if cfgErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want SubtypeInvalidArgument", cfgErr.Subtype) + } + if !strings.Contains(cfgErr.Hint, "--user") { + t.Errorf("flag-source hint should mention --user: %q", cfgErr.Hint) + } + // Pre-write abort: nothing persisted for the upstream user. + if got := larkauth.GetStoredToken("cli_test", "ou_actually_bob"); got != nil { + t.Errorf("Bob's token was stored despite holder mismatch: %#v", got) + } + saved, _ := core.LoadMultiAppConfig() + for _, u := range saved.Apps[0].Users { + if u.UserOpenId == "ou_actually_bob" { + t.Errorf("config grew an ou_actually_bob row despite mismatch: %#v", u) + } + } +} + +// Holder mismatch + downstream sync failure must leave the prior token slot +// intact. We exercise restoreStoredToken's contract directly via the unit +// tests below (synthesizing a mid-flight failure here is not feasible cleanly). +func TestRestoreStoredToken_PriorPresent_RestoresPrior(t *testing.T) { + keyring.MockInit() + prior := &larkauth.StoredUAToken{ + AppId: "cli_test", UserOpenId: "ou_alice", + AccessToken: "prior-tok", RefreshToken: "prior-r", + ExpiresAt: time.Now().Add(time.Hour).UnixMilli(), + } + // Caller's "set then fail" sequence: write the new token, then rollback. + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_test", UserOpenId: "ou_alice", AccessToken: "new-tok", + }); err != nil { + t.Fatalf("SetStoredToken (new): %v", err) + } + restoreStoredToken("cli_test", "ou_alice", prior) + got := larkauth.GetStoredToken("cli_test", "ou_alice") + if got == nil { + t.Fatal("token slot empty after restore") + } + if got.AccessToken != "prior-tok" { + t.Errorf("AccessToken = %q, want prior-tok (restore failed)", got.AccessToken) + } +} + +func TestRestoreStoredToken_PriorAbsent_RemovesNew(t *testing.T) { + keyring.MockInit() + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_test", UserOpenId: "ou_alice", AccessToken: "new-tok", + }); err != nil { + t.Fatalf("SetStoredToken: %v", err) + } + restoreStoredToken("cli_test", "ou_alice", nil) + if got := larkauth.GetStoredToken("cli_test", "ou_alice"); got != nil { + t.Errorf("token slot non-empty after restore (prior was nil — should have removed): %#v", got) + } +} + +// First login on an empty profile lands the user and stamps CurrentUser. +func TestAuthLoginRun_Step8_FirstUserStampsCurrentUser(t *testing.T) { + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{Name: "default", AppId: "cli_test"}}, + }) + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + stubLoginHTTP(t, reg, "ou_alice", "Alice") + f.Invocation = cmdutil.InvocationContext{Profile: "default"} + + if err := authLoginRun(&LoginOptions{ + Factory: f, Ctx: context.Background(), Scope: "im:message:send", + }); err != nil { + t.Fatalf("authLoginRun: %v", err) + } + saved, _ := core.LoadMultiAppConfig() + if saved.Apps[0].CurrentUser != "ou_alice" { + t.Errorf("CurrentUser = %q, want ou_alice", saved.Apps[0].CurrentUser) + } + if len(saved.Apps[0].Users) != 1 || saved.Apps[0].Users[0].UnionId != "uid_ou_alice" { + t.Errorf("Users[0] missing union_id capture: %#v", saved.Apps[0].Users) + } + if saved.Apps[0].Users[0].FirstAuthAt == nil { + t.Error("FirstAuthAt must be stamped on first login") + } + if saved.Apps[0].Users[0].LastScopes == "" { + t.Error("LastScopes should reflect granted scopes") + } +} + +// Re-login of the active user updates the row in place; CurrentUser unchanged, +// FirstAuthAt sticky. +func TestAuthLoginRun_Step8_ReLoginActiveUserRefreshes(t *testing.T) { + firstAuth := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + stubLoginConfigStep8(t, &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "cli_test", + CurrentUser: "ou_alice", + Users: []core.AppUser{{ + UserOpenId: "ou_alice", UserName: "old-name", + FirstAuthAt: &firstAuth, + }}, + }}, + }) + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + stubLoginHTTP(t, reg, "ou_alice", "Alice (refreshed)") + f.Invocation = cmdutil.InvocationContext{Profile: "default"} + + if err := authLoginRun(&LoginOptions{ + Factory: f, Ctx: context.Background(), Scope: "im:message:send", + }); err != nil { + t.Fatalf("authLoginRun: %v", err) + } + saved, _ := core.LoadMultiAppConfig() + if len(saved.Apps[0].Users) != 1 { + t.Fatalf("re-login produced duplicate row: %#v", saved.Apps[0].Users) + } + u := saved.Apps[0].Users[0] + if u.UserName != "Alice (refreshed)" { + t.Errorf("UserName = %q, want refreshed", u.UserName) + } + if u.FirstAuthAt == nil || !u.FirstAuthAt.Equal(firstAuth) { + t.Errorf("FirstAuthAt = %v, want sticky %v", u.FirstAuthAt, firstAuth) + } +} diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 3409f297b..42ca83769 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -369,7 +369,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) { NewlyGranted: []string{"im:message:send"}, AlreadyGranted: []string{"im:message:reply"}, Granted: []string{"im:message:send", "im:message:reply"}, - }) + }, nil) var data map[string]interface{} if err := json.Unmarshal(stdout.Bytes(), &data); err != nil { @@ -399,7 +399,7 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) { Missing: []string{"im:message:send"}, Granted: []string{"base:app:copy"}, }, - }, "ou_user", "tester") + }, "ou_user", "tester", nil) if err == nil { t.Fatal("expected error, got nil") } @@ -441,7 +441,7 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) { Missing: []string{"im:message:send"}, Granted: []string{"base:app:copy"}, }, - }, "ou_user", "tester") + }, "ou_user", "tester", nil) if err == nil { t.Fatal("expected error, got nil") } @@ -473,7 +473,7 @@ func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) { writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{ Granted: []string{"offline_access"}, - }) + }, nil) var data map[string]interface{} if err := json.Unmarshal(stdout.Bytes(), &data); err != nil { @@ -558,7 +558,7 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f, _, stderr, _ := cmdutil.TestFactory(t, nil) - writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary) + writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary, nil) got := stderr.String() for _, want := range tt.expectedPresent { @@ -812,7 +812,7 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope Requested: []string{"im:message:send"}, NewlyGranted: []string{"im:message:send"}, Granted: []string{"im:message:send"}, - }) + }, nil) got := stderr.String() for _, want := range []string{ @@ -834,6 +834,19 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) { keyring.MockInit() setupLoginConfigDir(t) + // authLoginRun now resolves at the profile rung from disk (so + // `auth login --user ou_new` is reachable for fresh users); seed + // a minimal config.json so the resolve succeeds. + multi := &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + {Name: "default", AppId: "cli_test"}, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + if err := saveLoginRequestedScope("device-code", "im:message:send"); err != nil { t.Fatalf("saveLoginRequestedScope() error = %v", err) } @@ -877,6 +890,17 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) { keyring.MockInit() setupLoginConfigDir(t) + // Profile-rung resolve reads from disk (see authLoginRun); seed config. + multi := &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + {Name: "default", AppId: "cli_test"}, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + original := pollDeviceToken t.Cleanup(func() { pollDeviceToken = original }) pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult { diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 1e864fd7d..ae843a053 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -4,7 +4,11 @@ package auth import ( + "context" + "errors" "fmt" + "os" + "time" "github.com/spf13/cobra" @@ -15,18 +19,26 @@ import ( "github.com/larksuite/cli/internal/output" ) -// LogoutOptions holds all inputs for auth logout. type LogoutOptions struct { Factory *cmdutil.Factory } -// NewCmdAuthLogout creates the auth logout subcommand. +// NewCmdAuthLogout creates the auth logout subcommand: wipes the whole +// profile. For per-user logout, use `auth users logout `. Shares the +// login flock so a concurrent login cannot interleave. func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Command { opts := &LogoutOptions{Factory: f} cmd := &cobra.Command{ Use: "logout", - Short: "Log out (clear token)", + Short: "Wipe all users' tokens, sidecar profiles, and index rows in the active profile", + Long: `logout wipes every user from the active profile: + - Removes their UAT entries from the OS keychain + - Clears AppConfig.Users and AppConfig.CurrentUser + - Deletes every sidecar UserProfile JSON + - Removes every user index row + +For per-user surgical logout, use ` + "`lark-cli auth users logout `" + `.`, RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { return runF(opts) @@ -42,27 +54,102 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr func authLogoutRun(opts *LogoutOptions) error { f := opts.Factory - multi, _ := core.LoadMultiAppConfig() + // Pre-lock peek: short-circuit no-config / not-logged-in before + // grabbing the flock, so a stale lock can't block status reads. + multi, err := core.LoadMultiAppConfig() + if err != nil { + // R2 / parse / permission errors must NOT be silently coerced into + // "no config" (the legacy behaviour). Pass them through; only the + // genuine missing-file case takes the idempotent-success branch. + if !errors.Is(err, os.ErrNotExist) { + return core.PassThroughOrNotConfigured(err) + } + multi = nil + } if multi == nil || len(multi.Apps) == 0 { fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.") return nil } - app := multi.CurrentAppConfig(f.Invocation.Profile) if app == nil || len(app.Users) == 0 { fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.") return nil } + profileName := app.ProfileName() - for _, user := range app.Users { - if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil { - fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err) + // Shared login flock; SingleUser() is intentional — wipe-all is + // config-file-scoped, so it locks out everyone for the duration. + root := loginRoot() + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "auth logout: acquire flock: %v", err).WithCause(err) + } + defer lk.Release() + + // Reload under the flock for R-M-W safety. Same R2 transparency rule + // as the pre-lock peek — surface the structured envelope, don't coerce. + multi, err = core.LoadMultiAppConfig() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.") + return nil } + return core.PassThroughOrNotConfigured(err) + } + if multi == nil || len(multi.Apps) == 0 { + // Idempotent: lost the row to a concurrent wiper. + fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.") + return nil } + idx := multi.FindAppIndex(profileName) + if idx < 0 { + fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.") + return nil + } + app = &multi.Apps[idx] + if len(app.Users) == 0 { + fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.") + return nil + } + + // Snapshot victims; we still need their open_ids for the sidecar+index sweep. + victims := make([]core.AppUser, len(app.Users)) + copy(victims, app.Users) + + // Keychain: warn-not-fatal so a transient hiccup can't desync config + + // keychain — operator can re-run to mop up. Delete is idempotent on "not found". + for _, u := range victims { + if err := larkauth.RemoveStoredToken(app.AppId, u.UserOpenId); err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth logout: remove keychain entry %s: %v\n", u.UserOpenId, err) + } + } + + // Drop all users in one save so a crash mid-loop can't leave half-removed users on disk. app.Users = []core.AppUser{} + app.CurrentUser = "" if err := core.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } - output.PrintSuccess(f.IOStreams.ErrOut, "Logged out") + + // Sidecar profiles + index rows: best-effort, both rebuild on next login. + // Swept AFTER config save so we never delete on-disk state for a row the + // save might have failed to drop. + for _, u := range victims { + ctx := larkauth.ForUser(app.AppId, u.UserOpenId) + if err := larkauth.DeleteUserProfileFor(root, ctx); err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth logout: delete sidecar profile %s: %v\n", u.UserOpenId, err) + } + if err := larkauth.DeleteUser(root, ctx); err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth logout: delete index row %s: %v\n", u.UserOpenId, err) + } + } + + if len(victims) == 1 { + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Logged out %s (%s)", victims[0].UserName, victims[0].UserOpenId)) + } else { + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Logged out %d users from profile %q", len(victims), profileName)) + } return nil } diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go new file mode 100644 index 000000000..39862f250 --- /dev/null +++ b/cmd/auth/logout_test.go @@ -0,0 +1,256 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/zalando/go-keyring" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// stubLogoutConfig writes a `target` profile with the given users and pre-stashes +// keychain token + sidecar profile + index row for each, so logout has state to wipe. +func stubLogoutConfig(t *testing.T, currentUser string, users []core.AppUser) (*cmdutil.Factory, string, string) { + t.Helper() + keyring.MockInit() + cfgDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir) + t.Setenv("HOME", t.TempDir()) + + multi := &core.MultiAppConfig{ + CurrentApp: "target", + Apps: []core.AppConfig{{ + Name: "target", + AppId: "app-target", + Brand: core.BrandFeishu, + CurrentUser: currentUser, + Users: users, + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + root := larkauth.NewLocalRoot(cfgDir) + for _, u := range users { + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "app-target", UserOpenId: u.UserOpenId, AccessToken: "tok-" + u.UserOpenId, + }); err != nil { + t.Fatalf("SetStoredToken(%s): %v", u.UserOpenId, err) + } + ctx := larkauth.ForUser("app-target", u.UserOpenId) + now := time.Now().UTC() + if err := larkauth.SaveUserProfileFor(root, ctx, larkauth.UserProfile{ + UserOpenId: u.UserOpenId, UserName: u.UserName, CachedAt: now, FirstAuthAt: now, + }); err != nil { + t.Fatalf("SaveUserProfileFor(%s): %v", u.UserOpenId, err) + } + if err := larkauth.RecordUserActivity(root, ctx, nil); err != nil { + t.Fatalf("RecordUserActivity(%s): %v", u.UserOpenId, err) + } + } + + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "target", AppID: "app-target", AppSecret: "secret", Brand: core.BrandFeishu, + }) + f.Invocation = cmdutil.InvocationContext{Profile: "target"} + return f, cfgDir, "app-target" +} + +// userIndexHas reports whether users.json contains a row for openID. Uses the +// public Root API so the test is robust to on-disk format changes. +func userIndexHas(t *testing.T, cfgDir, appID, openID string) bool { + t.Helper() + root := larkauth.NewLocalRoot(cfgDir) + all, err := larkauth.UserIndexEntries(root) + if err != nil { + t.Fatalf("UserIndexEntries: %v", err) + } + for _, e := range all { + if e.AppId == appID && e.UserOpenId == openID { + return true + } + } + return false +} + +// Short-circuits before flock. +func TestAuthLogoutRun_NoConfig(t *testing.T) { + keyring.MockInit() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("authLogoutRun: %v", err) + } + stderr := f.IOStreams.ErrOut.(interface{ String() string }).String() + if !strings.Contains(stderr, "No configuration found") { + t.Errorf("stderr = %q, want 'No configuration found.'", stderr) + } +} + +// TestAuthLogoutRun_NotLoggedIn: profile exists, no users. +func TestAuthLogoutRun_NotLoggedIn(t *testing.T) { + f, _, _ := stubLogoutConfig(t, "", nil) + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("authLogoutRun: %v", err) + } + stderr := f.IOStreams.ErrOut.(interface{ String() string }).String() + if !strings.Contains(stderr, "Not logged in") { + t.Errorf("stderr = %q, want 'Not logged in.'", stderr) + } +} + +// Headline behavior: keychain, config, sidecar JSON, and index row are all cleared. +func TestAuthLogoutRun_WipesKeychainConfigSidecarAndIndex(t *testing.T) { + f, cfgDir, appID := stubLogoutConfig(t, "ou_alice", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }) + + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("authLogoutRun: %v", err) + } + + // Config: Users empty, CurrentUser cleared. + saved, _ := core.LoadMultiAppConfig() + if len(saved.Apps[0].Users) != 0 { + t.Errorf("Users = %#v, want empty", saved.Apps[0].Users) + } + if saved.Apps[0].CurrentUser != "" { + t.Errorf("CurrentUser = %q, want empty", saved.Apps[0].CurrentUser) + } + + // Keychain: both users gone. + for _, openID := range []string{"ou_alice", "ou_bob"} { + if got := larkauth.GetStoredToken(appID, openID); got != nil { + t.Errorf("token for %s still present: %#v", openID, got) + } + } + + // Sidecar profile JSONs: gone for both. + for _, openID := range []string{"ou_alice", "ou_bob"} { + path := filepath.Join(cfgDir, "users", appID, openID, "user_profile.json") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("sidecar %s still present (err=%v)", path, err) + } + } + + // User index rows: gone for both. + for _, openID := range []string{"ou_alice", "ou_bob"} { + if userIndexHas(t, cfgDir, appID, openID) { + t.Errorf("index row %s still present", openID) + } + } + + stderr := f.IOStreams.ErrOut.(interface{ String() string }).String() + if !strings.Contains(stderr, "Logged out 2 users") { + t.Errorf("stderr = %q, want 'Logged out 2 users'", stderr) + } +} + +// Success line names the user when only one was wiped. +func TestAuthLogoutRun_SingleUserPhrasing(t *testing.T) { + f, _, _ := stubLogoutConfig(t, "ou_solo", []core.AppUser{ + {UserOpenId: "ou_solo", UserName: "Solo"}, + }) + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("authLogoutRun: %v", err) + } + stderr := f.IOStreams.ErrOut.(interface{ String() string }).String() + if !strings.Contains(stderr, "Solo") || !strings.Contains(stderr, "ou_solo") { + t.Errorf("stderr = %q, want phrasing naming Solo (ou_solo)", stderr) + } +} + +// Wiping `target` must not touch a sibling profile's users/keychain/index. +func TestAuthLogoutRun_PreservesOtherProfiles(t *testing.T) { + keyring.MockInit() + cfgDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir) + t.Setenv("HOME", t.TempDir()) + + multi := &core.MultiAppConfig{ + CurrentApp: "target", + Apps: []core.AppConfig{ + { + Name: "target", AppId: "app-target", Brand: core.BrandFeishu, + CurrentUser: "ou_alice", + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }, + { + Name: "other", AppId: "app-other", Brand: core.BrandFeishu, + CurrentUser: "ou_carol", + Users: []core.AppUser{{UserOpenId: "ou_carol", UserName: "Carol"}}, + }, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + root := larkauth.NewLocalRoot(cfgDir) + for _, u := range []struct{ app, oid, name string }{ + {"app-target", "ou_alice", "Alice"}, + {"app-other", "ou_carol", "Carol"}, + } { + _ = larkauth.SetStoredToken(&larkauth.StoredUAToken{AppId: u.app, UserOpenId: u.oid, AccessToken: "tok"}) + ctx := larkauth.ForUser(u.app, u.oid) + now := time.Now().UTC() + _ = larkauth.SaveUserProfileFor(root, ctx, larkauth.UserProfile{UserOpenId: u.oid, UserName: u.name, CachedAt: now, FirstAuthAt: now}) + _ = larkauth.RecordUserActivity(root, ctx, nil) + } + + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "target", AppID: "app-target", AppSecret: "secret", Brand: core.BrandFeishu, + }) + f.Invocation = cmdutil.InvocationContext{Profile: "target"} + + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("authLogoutRun: %v", err) + } + + saved, _ := core.LoadMultiAppConfig() + // `target` wiped. + if len(saved.Apps[0].Users) != 0 { + t.Errorf("target.Users = %#v, want empty", saved.Apps[0].Users) + } + // `other` untouched. + if len(saved.Apps[1].Users) != 1 || saved.Apps[1].Users[0].UserOpenId != "ou_carol" { + t.Errorf("other.Users = %#v, want Carol preserved", saved.Apps[1].Users) + } + if saved.Apps[1].CurrentUser != "ou_carol" { + t.Errorf("other.CurrentUser = %q, want ou_carol preserved", saved.Apps[1].CurrentUser) + } + // Carol's keychain + sidecar + index row preserved. + if got := larkauth.GetStoredToken("app-other", "ou_carol"); got == nil { + t.Error("Carol's token wiped") + } + if !userIndexHas(t, cfgDir, "app-other", "ou_carol") { + t.Error("Carol's index row wiped") + } +} + +// Running logout twice is a no-op the second time and does not error. +func TestAuthLogoutRun_IsIdempotent(t *testing.T) { + f, _, _ := stubLogoutConfig(t, "ou_alice", []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}) + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("first logout: %v", err) + } + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("second logout: %v", err) + } + stderr := f.IOStreams.ErrOut.(interface{ String() string }).String() + if !strings.Contains(stderr, "Not logged in") { + t.Errorf("second logout stderr = %q, want 'Not logged in.'", stderr) + } +} diff --git a/cmd/auth/users.go b/cmd/auth/users.go new file mode 100644 index 000000000..3e676382b --- /dev/null +++ b/cmd/auth/users.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" +) + +// NewCmdAuthUsers creates the `auth users` subcommand group. +// `auth logout` clears the entire profile; `auth users logout ` is +// the per-user surgical version. +func NewCmdAuthUsers(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "users", + Short: "Manage logged-in users within the active profile", + Long: `users groups operator commands that act on the per-profile user +list maintained by lark-cli's multi-user auth surface (see +` + "`lark-cli auth login`" + `). + +Subcommands: + list show all users in the active profile, marking the active one + use switch the active user (sets currentUser in config) + logout wipe one user's tokens, sidecar profile, and index row + +The legacy ` + "`lark-cli auth logout`" + ` continues to clear the entire +profile (all users in one shot); ` + "`lark-cli auth users logout `" + ` +is the per-user surgical version.`, + } + cmdutil.SetRisk(cmd, "read") + cmd.AddCommand(NewCmdAuthUsersList(f, nil)) + cmd.AddCommand(NewCmdAuthUsersUse(f, nil)) + cmd.AddCommand(NewCmdAuthUsersLogout(f, nil)) + return cmd +} diff --git a/cmd/auth/users_list.go b/cmd/auth/users_list.go new file mode 100644 index 000000000..c4eb129aa --- /dev/null +++ b/cmd/auth/users_list.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +type UsersListOptions struct { + Factory *cmdutil.Factory +} + +// NewCmdAuthUsersList creates the `auth users list` subcommand. Unlike +// `auth list`, output includes the active-user marker plus FirstAuthAt, +// LastUsed, and LastScopes metadata. +func NewCmdAuthUsersList(f *cmdutil.Factory, runF func(*UsersListOptions) error) *cobra.Command { + opts := &UsersListOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "list", + Short: "List all users in the active profile", + Long: `list shows every user in the active profile with their token freshness, FirstAuthAt, LastUsed, and the active marker.`, + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return authUsersListRun(opts) + }, + } + cmdutil.SetRisk(cmd, "read") + return cmd +} + +func authUsersListRun(opts *UsersListOptions) error { + f := opts.Factory + + multi, err := core.LoadMultiAppConfig() + if err != nil { + // users list is also a read-only probe — keep exit 0 with a + // stderr hint when the file is simply missing. Anything else + // (R2 forward-incompat, parse error) must surface with envelope + // intact: a *core.ConfigError must reach the dispatcher with + // its upgrade Hint preserved. + if errors.Is(err, os.ErrNotExist) { + printNotConfiguredHint(f.IOStreams.ErrOut) + return nil + } + return core.PassThroughOrNotConfigured(err) + } + if multi == nil || len(multi.Apps) == 0 { + printNotConfiguredHint(f.IOStreams.ErrOut) + return nil + } + app := multi.CurrentAppConfig(f.Invocation.Profile) + if app == nil || len(app.Users) == 0 { + fmt.Fprintln(f.IOStreams.ErrOut, "No users in this profile. Run `lark-cli auth login` to add one.") + return nil + } + + // Active user marker; mirrors ResolveConfigFromMulti precedence + // (invocation > config > Users[0]). + active := resolveActiveUserOpenId(f, app) + + items := make([]map[string]interface{}, 0, len(app.Users)) + for _, u := range app.Users { + stored := larkauth.GetStoredToken(app.AppId, u.UserOpenId) + status := "no_token" + if stored != nil { + status = larkauth.TokenStatus(stored) + } + row := map[string]interface{}{ + "userName": u.UserName, + "userOpenId": u.UserOpenId, + "unionId": u.UnionId, + "appId": app.AppId, + "tokenStatus": status, + "active": u.UserOpenId == active, + "lastScopes": u.LastScopes, + } + if u.FirstAuthAt != nil { + row["firstAuthAt"] = u.FirstAuthAt.UTC() + } + if u.LastUsed != nil { + row["lastUsed"] = u.LastUsed.UTC() + } + items = append(items, row) + } + output.PrintJson(f.IOStreams.Out, items) + return nil +} + +// resolveActiveUserOpenId picks the active AppUser for marking. Precedence +// matches ResolveConfigFromMulti: invocation override, then CurrentUser, +// then Users[0]. Stale references fall through. +func resolveActiveUserOpenId(f *cmdutil.Factory, app *core.AppConfig) string { + if u := f.Invocation.UserOpenId; u != "" { + if hit := app.FindUser(u); hit != nil { + return hit.UserOpenId + } + } + if app.CurrentUser != "" { + if hit := app.FindUser(app.CurrentUser); hit != nil { + return hit.UserOpenId + } + } + if len(app.Users) > 0 { + return app.Users[0].UserOpenId + } + return "" +} diff --git a/cmd/auth/users_logout.go b/cmd/auth/users_logout.go new file mode 100644 index 000000000..517904385 --- /dev/null +++ b/cmd/auth/users_logout.go @@ -0,0 +1,165 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +type UsersLogoutOptions struct { + Factory *cmdutil.Factory + Target string // open_id or user_name +} + +// NewCmdAuthUsersLogout wipes one user's tokens, AppUser row, sidecar +// profile, and index entry. If the user was active, CurrentUser is +// cleared rather than auto-switched. +func NewCmdAuthUsersLogout(f *cmdutil.Factory, runF func(*UsersLogoutOptions) error) *cobra.Command { + opts := &UsersLogoutOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "logout ", + Short: "Wipe one user's tokens, sidecar profile, and index row", + Long: `logout removes a single user from the active profile: + - Removes their UAT entry from the OS keychain + - Removes their AppUser row from config.json + - Deletes their sidecar UserProfile JSON + - Removes their user index entry + +If the user was the active CurrentUser for the profile, CurrentUser +is cleared. The next `+"`auth login`"+` or `+"`auth users use`"+` re-stamps it. + +For "log out the entire profile" semantics see `+"`lark-cli auth logout`"+`.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Target = args[0] + if runF != nil { + return runF(opts) + } + return authUsersLogoutRun(opts) + }, + } + cmdutil.SetRisk(cmd, "write") + return cmd +} + +func authUsersLogoutRun(opts *UsersLogoutOptions) error { + f := opts.Factory + target := strings.TrimSpace(opts.Target) + if target == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + " required"). + WithParam("") + } + + root := loginRoot() + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "users logout: acquire flock: %v", err).WithCause(err) + } + defer lk.Release() + + multi, err := core.LoadMultiAppConfig() + if err != nil { + return core.PassThroughOrNotConfigured(err) + } + if multi == nil || len(multi.Apps) == 0 { + return core.NotConfiguredError() + } + // Use CurrentAppConfig so --profile=ghost reports cleanly instead of + // silently falling back to Apps[0] and deleting a user from the wrong + // profile. Matches logout.go / users_list.go resolution policy. + app := multi.CurrentAppConfig(f.Invocation.Profile) + if app == nil { + if f.Invocation.Profile != "" { + // Operator typed a non-existent profile: this is an argument + // problem, not a "config doesn't exist" problem. SubtypeNotConfigured + // would route AI agents to `config init`, which would clobber the + // working profiles. Use SubtypeInvalidArgument to match the + // users_use.go contract. + return errs.NewConfigError(errs.SubtypeInvalidArgument, + "profile %q not found", f.Invocation.Profile). + WithHint("available profiles: %s", strings.Join(multi.ProfileNames(), ", ")) + } + // Config has Apps but no resolvable active — the file is in a + // half-set state, not "no config". SubtypeInvalidConfig points the + // operator at `profile use ` rather than re-init. + return errs.NewConfigError(errs.SubtypeInvalidConfig, "no active profile to log out within") + } + + uIdx := app.FindUserIndex(target) + if uIdx < 0 { + hint := "available users: " + if names := app.UserNames(); len(names) > 0 { + hint += strings.Join(names, ", ") + } else { + hint += "(none)" + } + return errs.NewConfigError(errs.SubtypeInvalidArgument, + "user %q not found in profile %q", target, app.ProfileName()). + WithHint(hint) + } + victim := app.Users[uIdx] + // Capture the active-user signal BEFORE we mutate Users[] / clear + // CurrentUser. This drives the post-save warning: when the victim was + // the active user AND other users remain, the *next* command silently + // dispatches as the new Users[0] via the empty-CurrentUser fallback. + // Without the warning, the operator cannot tell their effective + // identity changed — a stealth foot-gun. + victimWasActive := app.CurrentUser == victim.UserOpenId + + // warn-not-fatal: a keychain hiccup must not leave config + keychain desynced. + if err := larkauth.RemoveStoredToken(app.AppId, victim.UserOpenId); err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] users logout: remove keychain entry: %v\n", err) + } + + // Clear CurrentUser rather than auto-switching: silently changing the active + // user during a removal would surprise the operator. (The empty-CurrentUser + // → Users[0] fallback in ResolveConfigFromMulti still applies on the next + // command — see the post-save warning below for the operator-facing nudge.) + app.Users = append(app.Users[:uIdx], app.Users[uIdx+1:]...) + if app.CurrentUser == victim.UserOpenId { + app.CurrentUser = "" + } + if err := core.SaveMultiAppConfig(multi); err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err) + } + + // Best-effort: sidecar and index rebuild on next login. + ctx := larkauth.ForUser(app.AppId, victim.UserOpenId) + if err := larkauth.DeleteUserProfileFor(root, ctx); err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] users logout: delete sidecar profile: %v\n", err) + } + if err := larkauth.DeleteUser(root, ctx); err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] users logout: delete index row: %v\n", err) + } + + // Active-user safety nudge. Cleared CurrentUser + non-empty Users[] means + // the next resolve picks the new Users[0]. Tell the operator BEFORE they + // run a command and discover the silent identity shift after the fact. + // We deliberately don't pre-pick CurrentUser ourselves: an explicit + // `auth users use` keeps the choice in the operator's hands. + if victimWasActive && len(app.Users) > 0 { + next := app.Users[0] + fmt.Fprintf(f.IOStreams.ErrOut, + "[lark-cli] [WARN] users logout: %s (%s) was the active user; the next command will dispatch as %s (%s) via the Users[0] fallback. Run `lark-cli auth users use ` to choose explicitly.\n", + victim.UserName, victim.UserOpenId, next.UserName, next.UserOpenId) + } + + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Logged out: %s (%s)", victim.UserName, victim.UserOpenId)) + return nil +} diff --git a/cmd/auth/users_logout_active_user_test.go b/cmd/auth/users_logout_active_user_test.go new file mode 100644 index 000000000..404e5abd9 --- /dev/null +++ b/cmd/auth/users_logout_active_user_test.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/core" +) + +// Regression: pre-fix, logging out the *active* user cleared CurrentUser +// without warning. The next command silently dispatched as the new +// Users[0] via the empty-CurrentUser → Users[0] fallback in +// ResolveConfigFromMulti — operator could not tell their effective +// identity had changed. +// +// Fix: when the victim was the active user AND other users remain, emit +// a stderr WARN naming the new fallback user and pointing at +// `auth users use` for an explicit pick. We do NOT auto-pick a new +// CurrentUser ourselves: the choice stays with the operator, the warn +// just makes the silent shift loud. +func TestAuthUsersLogoutRun_ActiveUser_EmitsFallbackWarning(t *testing.T) { + f, _ := stubUsersConfig(t, "ou_alice", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }) + + if err := authUsersLogoutRun(&UsersLogoutOptions{Factory: f, Target: "ou_alice"}); err != nil { + t.Fatalf("authUsersLogoutRun: %v", err) + } + stderr := f.IOStreams.ErrOut.(interface{ String() string }).String() + + // The warn must name the victim, the next-up user, and mention the + // remediation command. Substring checks (not exact match) so future + // copy tweaks don't break the test for the wrong reason. + wantSubs := []string{ + "WARN", + "users logout", + "ou_alice", // victim open_id + "active user", // victim role + "ou_bob", // next-up open_id + "Users[0]", // names the fallback mechanism + "auth users use", // remediation + } + for _, sub := range wantSubs { + if !strings.Contains(stderr, sub) { + t.Errorf("stderr missing %q\nfull stderr:\n%s", sub, stderr) + } + } +} + +// Negative: removing a NON-active user must NOT emit the fallback warning +// — CurrentUser is unchanged so the next command still resolves the same +// active identity. A spurious warning here would dilute the signal of the +// real one above. +func TestAuthUsersLogoutRun_NonActiveUser_NoFallbackWarning(t *testing.T) { + f, _ := stubUsersConfig(t, "ou_alice", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }) + + if err := authUsersLogoutRun(&UsersLogoutOptions{Factory: f, Target: "ou_bob"}); err != nil { + t.Fatalf("authUsersLogoutRun: %v", err) + } + stderr := f.IOStreams.ErrOut.(interface{ String() string }).String() + + if strings.Contains(stderr, "active user") { + t.Errorf("stderr should not name a fallback when removing a non-active user; got:\n%s", stderr) + } + if strings.Contains(stderr, "Users[0]") { + t.Errorf("stderr should not mention the Users[0] fallback when removing a non-active user; got:\n%s", stderr) + } +} + +// Negative: removing the *only* user (who happens to be active) leaves +// Users[] empty. There is no fallback to warn about — the next command +// will hit a real "no users" error path, which is its own clear signal. +// Emitting the fallback warn here would be misleading. +func TestAuthUsersLogoutRun_LastUser_NoFallbackWarning(t *testing.T) { + f, _ := stubUsersConfig(t, "ou_alice", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + }) + + if err := authUsersLogoutRun(&UsersLogoutOptions{Factory: f, Target: "ou_alice"}); err != nil { + t.Fatalf("authUsersLogoutRun: %v", err) + } + stderr := f.IOStreams.ErrOut.(interface{ String() string }).String() + + if strings.Contains(stderr, "Users[0]") { + t.Errorf("stderr should not mention Users[0] fallback when no users remain; got:\n%s", stderr) + } + if strings.Contains(stderr, "the next command will dispatch as") { + t.Errorf("stderr should not name a non-existent fallback user; got:\n%s", stderr) + } + + // Sanity: config matches the empty-Users invariant the warn-skip relies on. + saved, _ := core.LoadMultiAppConfig() + if len(saved.Apps[0].Users) != 0 { + t.Errorf("expected Users[] empty after logging out the only user; got %#v", saved.Apps[0].Users) + } + if saved.Apps[0].CurrentUser != "" { + t.Errorf("CurrentUser = %q, want empty", saved.Apps[0].CurrentUser) + } +} diff --git a/cmd/auth/users_logout_profile_fallback_test.go b/cmd/auth/users_logout_profile_fallback_test.go new file mode 100644 index 000000000..5cd8fa746 --- /dev/null +++ b/cmd/auth/users_logout_profile_fallback_test.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/zalando/go-keyring" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Regression: --profile=ghost must NOT silently fall back to Apps[0] +// (which would DELETE a user from the wrong profile). Behaviour must +// match logout.go / users_list.go: no fallback when an explicit profile +// is named. +func TestUsersLogout_ExplicitProfileNotFound_DoesNotFallbackToAppsZero(t *testing.T) { + keyring.MockInit() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + // Two profiles: alpha holds the only user. If the bug returns, + // --profile=ghost would resolve to Apps[0]=alpha and delete u_alice. + cfg := &core.MultiAppConfig{ + Apps: []core.AppConfig{ + { + Name: "alpha", AppId: "cli_a", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }, + { + Name: "beta", AppId: "cli_b", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{}, + }, + }, + CurrentApp: "alpha", + } + if err := core.SaveMultiAppConfig(cfg); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + var out, errOut bytes.Buffer + streams := &cmdutil.IOStreams{Out: &out, ErrOut: &errOut} + f := &cmdutil.Factory{ + IOStreams: streams, + Invocation: cmdutil.InvocationContext{Profile: "ghost"}, + } + + opts := &UsersLogoutOptions{Factory: f, Target: "ou_alice"} + err := authUsersLogoutRun(opts) + if err == nil { + t.Fatal("expected profile-not-found error for --profile=ghost, got nil — fallback bug returned") + } + if !strings.Contains(err.Error(), "ghost") { + t.Errorf("error must mention the requested profile %q; got %v", "ghost", err) + } + + // And alpha's user must still exist on disk — the bug would have + // deleted it before the error was returned. + reloaded, lerr := core.LoadMultiAppConfig() + if lerr != nil { + t.Fatalf("reload: %v", lerr) + } + alpha := reloaded.FindApp("alpha") + if alpha == nil || len(alpha.Users) != 1 || alpha.Users[0].UserOpenId != "ou_alice" { + t.Fatalf("alpha.Users corrupted by --profile=ghost fallback: %#v", alpha) + } +} + +// Sanity: typed *core.ConfigError still surfaces (not a generic error) +// so dispatcher promotion keeps working. +func TestUsersLogout_ExplicitProfileNotFound_ErrorIsTyped(t *testing.T) { + keyring.MockInit() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + cfg := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "alpha", AppId: "cli_a", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_a"}}, + }}, + } + if err := core.SaveMultiAppConfig(cfg); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + streams := &cmdutil.IOStreams{Out: &out, ErrOut: &errOut} + f := &cmdutil.Factory{IOStreams: streams, Invocation: cmdutil.InvocationContext{Profile: "ghost"}} + opts := &UsersLogoutOptions{Factory: f, Target: "ou_a"} + + err := authUsersLogoutRun(opts) + if err == nil { + t.Fatal("expected error") + } + // Don't pin the exact concrete type (errs.ConfigError vs core.ConfigError + // — the cmd uses errs.NewConfigError); just require it's non-nil and + // names the profile, which is the user-facing contract. + var cfgErr *core.ConfigError + if errors.As(err, &cfgErr) { + if !strings.Contains(cfgErr.Message+cfgErr.Hint, "ghost") { + t.Errorf("core.ConfigError must name the bad profile; got msg=%q hint=%q", cfgErr.Message, cfgErr.Hint) + } + } +} + +// Regression for A9: a non-existent profile name must NOT route to +// SubtypeNotConfigured (which would invite `config init` and clobber +// the working profiles). It's an InvalidArgument — the operator typed +// a name that does not exist. Mirrors the users_use.go contract. +func TestUsersLogout_ExplicitProfileNotFound_SubtypeIsInvalidArgument(t *testing.T) { + keyring.MockInit() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + cfg := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "alpha", AppId: "cli_a", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_a"}}, + }}, + } + if err := core.SaveMultiAppConfig(cfg); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + streams := &cmdutil.IOStreams{Out: &out, ErrOut: &errOut} + f := &cmdutil.Factory{IOStreams: streams, Invocation: cmdutil.InvocationContext{Profile: "ghost"}} + opts := &UsersLogoutOptions{Factory: f, Target: "ou_a"} + + err := authUsersLogoutRun(opts) + if err == nil { + t.Fatal("expected error for ghost profile") + } + var typed *errs.ConfigError + if !errors.As(err, &typed) { + t.Fatalf("expected *errs.ConfigError; got %T %v", err, err) + } + if typed.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype: got %q, want %q (SubtypeInvalidArgument; "+ + "SubtypeNotConfigured would invite config init)", + typed.Subtype, errs.SubtypeInvalidArgument) + } +} diff --git a/cmd/auth/users_test.go b/cmd/auth/users_test.go new file mode 100644 index 000000000..3d277d39f --- /dev/null +++ b/cmd/auth/users_test.go @@ -0,0 +1,253 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/zalando/go-keyring" + + "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// stubUsersConfig builds a temp config dir + multi-app config with `target` +// profile populated from users (insertion order preserved), and returns a +// Factory wired to that profile. +func stubUsersConfig(t *testing.T, currentUser string, users []core.AppUser) (*cmdutil.Factory, *core.MultiAppConfig) { + t.Helper() + keyring.MockInit() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + multi := &core.MultiAppConfig{ + CurrentApp: "target", + Apps: []core.AppConfig{{ + Name: "target", + AppId: "app-target", + Brand: core.BrandFeishu, + CurrentUser: currentUser, + Users: users, + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "target", AppID: "app-target", AppSecret: "secret", Brand: core.BrandFeishu, + }) + f.Invocation = cmdutil.InvocationContext{Profile: "target"} + return f, multi +} + +func TestAuthUsersListRun_ActiveMarker(t *testing.T) { + now := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + f, _ := stubUsersConfig(t, "ou_bob", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice", FirstAuthAt: &now, LastUsed: &now, LastScopes: "im:message:send"}, + {UserOpenId: "ou_bob", UserName: "Bob", FirstAuthAt: &now, LastUsed: &now}, + }) + + if err := authUsersListRun(&UsersListOptions{Factory: f}); err != nil { + t.Fatalf("authUsersListRun: %v", err) + } + stdout := f.IOStreams.Out.(interface{ String() string }).String() + + var got []map[string]interface{} + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("decode JSON: %v\noutput: %s", err, stdout) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0]["userOpenId"] != "ou_alice" || got[1]["userOpenId"] != "ou_bob" { + t.Errorf("order = %v, want [alice,bob]", got) + } + if got[0]["active"] != false || got[1]["active"] != true { + t.Errorf("active markers wrong: %v", got) + } + if got[0]["lastScopes"] != "im:message:send" { + t.Errorf("alice.lastScopes = %v, want im:message:send", got[0]["lastScopes"]) + } + if got[0]["firstAuthAt"] == nil { + t.Error("firstAuthAt missing") + } +} + +// TestAuthUsersListRun_EmptyProfile: hint to stderr, no error (matches +// `auth list` empty-state contract). +func TestAuthUsersListRun_EmptyProfile(t *testing.T) { + f, _ := stubUsersConfig(t, "", nil) + if err := authUsersListRun(&UsersListOptions{Factory: f}); err != nil { + t.Fatalf("authUsersListRun: %v", err) + } + stderr := f.IOStreams.ErrOut.(interface{ String() string }).String() + if !strings.Contains(stderr, "No users in this profile") { + t.Errorf("stderr missing empty-state hint: %q", stderr) + } +} + +// TestAuthUsersListRun_OverrideMarksRequestedUser: --user override wins over +// AppConfig.CurrentUser when picking the active row. +func TestAuthUsersListRun_OverrideMarksRequestedUser(t *testing.T) { + f, _ := stubUsersConfig(t, "ou_alice", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }) + f.Invocation.UserOpenId = "ou_bob" + + if err := authUsersListRun(&UsersListOptions{Factory: f}); err != nil { + t.Fatalf("authUsersListRun: %v", err) + } + stdout := f.IOStreams.Out.(interface{ String() string }).String() + var got []map[string]interface{} + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got[1]["active"] != true { + t.Errorf("--user ou_bob should mark Bob active, got %v", got) + } +} + +func TestAuthUsersUseRun_SwitchesActive(t *testing.T) { + f, _ := stubUsersConfig(t, "ou_alice", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }) + if err := authUsersUseRun(&UsersUseOptions{Factory: f, Target: "ou_bob"}); err != nil { + t.Fatalf("authUsersUseRun: %v", err) + } + saved, _ := core.LoadMultiAppConfig() + if saved.Apps[0].CurrentUser != "ou_bob" { + t.Errorf("CurrentUser = %q, want ou_bob", saved.Apps[0].CurrentUser) + } +} + +// TestAuthUsersUseRun_ResolvesByName: open_id-first then UserName fallback. +func TestAuthUsersUseRun_ResolvesByName(t *testing.T) { + f, _ := stubUsersConfig(t, "ou_alice", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }) + if err := authUsersUseRun(&UsersUseOptions{Factory: f, Target: "Bob"}); err != nil { + t.Fatalf("authUsersUseRun: %v", err) + } + saved, _ := core.LoadMultiAppConfig() + if saved.Apps[0].CurrentUser != "ou_bob" { + t.Errorf("CurrentUser = %q, want ou_bob (resolved by name)", saved.Apps[0].CurrentUser) + } +} + +func TestAuthUsersUseRun_NoOpIfAlreadyActive(t *testing.T) { + f, _ := stubUsersConfig(t, "ou_alice", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + }) + if err := authUsersUseRun(&UsersUseOptions{Factory: f, Target: "ou_alice"}); err != nil { + t.Fatalf("authUsersUseRun: %v", err) + } + stderr := f.IOStreams.ErrOut.(interface{ String() string }).String() + if !strings.Contains(stderr, "Already active") { + t.Errorf("stderr should mention 'Already active': %q", stderr) + } +} + +func TestAuthUsersUseRun_MissTypedError(t *testing.T) { + f, _ := stubUsersConfig(t, "ou_alice", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + }) + err := authUsersUseRun(&UsersUseOptions{Factory: f, Target: "ou_ghost"}) + if err == nil { + t.Fatal("expected error for unknown user") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) || cfgErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected ConfigError(InvalidArgument), got %T %v", err, err) + } + if !strings.Contains(cfgErr.Hint, "Alice") { + t.Errorf("hint should list available users: %q", cfgErr.Hint) + } +} + +func TestAuthUsersUseRun_EmptyTarget(t *testing.T) { + f, _ := stubUsersConfig(t, "", []core.AppUser{{UserOpenId: "ou_alice"}}) + err := authUsersUseRun(&UsersUseOptions{Factory: f, Target: " "}) + if err == nil { + t.Fatal("expected error for whitespace-only target") + } + var vErr *errs.ValidationError + if !errors.As(err, &vErr) || vErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("expected ValidationError(InvalidArgument), got %T %v", err, err) + } +} + +// TestAuthUsersLogoutRun_RemovesUserAndToken: keychain token, config row, +// sidecar profile, and index entry are all wiped. +func TestAuthUsersLogoutRun_RemovesUserAndToken(t *testing.T) { + f, _ := stubUsersConfig(t, "ou_alice", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }) + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "app-target", UserOpenId: "ou_bob", AccessToken: "tok", + }); err != nil { + t.Fatalf("SetStoredToken: %v", err) + } + + if err := authUsersLogoutRun(&UsersLogoutOptions{Factory: f, Target: "ou_bob"}); err != nil { + t.Fatalf("authUsersLogoutRun: %v", err) + } + saved, _ := core.LoadMultiAppConfig() + if len(saved.Apps[0].Users) != 1 || saved.Apps[0].Users[0].UserOpenId != "ou_alice" { + t.Errorf("Users = %#v, want only Alice remaining", saved.Apps[0].Users) + } + if saved.Apps[0].CurrentUser != "ou_alice" { + t.Errorf("CurrentUser unchanged because we logged out a non-active user, got %q", saved.Apps[0].CurrentUser) + } + if got := larkauth.GetStoredToken("app-target", "ou_bob"); got != nil { + t.Errorf("Bob's token still in keychain: %#v", got) + } +} + +// TestAuthUsersLogoutRun_ClearsCurrentUserIfActive: logging out the active +// user clears CurrentUser; no auto-switch to another row. +func TestAuthUsersLogoutRun_ClearsCurrentUserIfActive(t *testing.T) { + f, _ := stubUsersConfig(t, "ou_alice", []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }) + if err := authUsersLogoutRun(&UsersLogoutOptions{Factory: f, Target: "ou_alice"}); err != nil { + t.Fatalf("authUsersLogoutRun: %v", err) + } + saved, _ := core.LoadMultiAppConfig() + if saved.Apps[0].CurrentUser != "" { + t.Errorf("CurrentUser = %q, want empty (no auto-switch)", saved.Apps[0].CurrentUser) + } + if len(saved.Apps[0].Users) != 1 || saved.Apps[0].Users[0].UserOpenId != "ou_bob" { + t.Errorf("Users = %#v, want only Bob", saved.Apps[0].Users) + } +} + +func TestAuthUsersLogoutRun_MissTypedError(t *testing.T) { + f, _ := stubUsersConfig(t, "ou_alice", []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}) + err := authUsersLogoutRun(&UsersLogoutOptions{Factory: f, Target: "ou_ghost"}) + if err == nil { + t.Fatal("expected error for unknown user") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) || cfgErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected ConfigError(InvalidArgument), got %T %v", err, err) + } +} + +func TestAuthUsersLogoutRun_EmptyTarget(t *testing.T) { + f, _ := stubUsersConfig(t, "", []core.AppUser{{UserOpenId: "ou_alice"}}) + err := authUsersLogoutRun(&UsersLogoutOptions{Factory: f, Target: ""}) + if err == nil { + t.Fatal("expected error for empty target") + } +} diff --git a/cmd/auth/users_use.go b/cmd/auth/users_use.go new file mode 100644 index 000000000..f87429c18 --- /dev/null +++ b/cmd/auth/users_use.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// UsersUseOptions holds inputs for `auth users use`. +type UsersUseOptions struct { + Factory *cmdutil.Factory + Target string // open_id or user_name +} + +// NewCmdAuthUsersUse creates the `auth users use ` subcommand. +// Resolution matches open_id first, then user_name. +func NewCmdAuthUsersUse(f *cmdutil.Factory, runF func(*UsersUseOptions) error) *cobra.Command { + opts := &UsersUseOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "use ", + Short: "Switch the active user within the current profile", + Long: `use sets the active user for the current profile. Subsequent commands that +do not pass --user resolve through AppConfig.CurrentUser.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Target = args[0] + if runF != nil { + return runF(opts) + } + return authUsersUseRun(opts) + }, + } + cmdutil.SetRisk(cmd, "write") + return cmd +} + +func authUsersUseRun(opts *UsersUseOptions) error { + f := opts.Factory + target := strings.TrimSpace(opts.Target) + if target == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + " required"). + WithParam("") + } + + multi, err := core.LoadMultiAppConfig() + if err != nil { + return core.PassThroughOrNotConfigured(err) + } + if multi == nil || len(multi.Apps) == 0 { + return core.NotConfiguredError() + } + // Use CurrentAppConfig so --profile=ghost reports cleanly instead of + // silently falling back to Apps[0] and writing CurrentUser to the wrong + // profile. Matches users_logout.go / users_list.go resolution policy. + app := multi.CurrentAppConfig(f.Invocation.Profile) + if app == nil { + if f.Invocation.Profile != "" { + return errs.NewConfigError(errs.SubtypeInvalidArgument, + "profile %q not found", f.Invocation.Profile). + WithHint("available profiles: %s", strings.Join(multi.ProfileNames(), ", ")) + } + return errs.NewConfigError(errs.SubtypeInvalidConfig, "no active profile to switch within") + } + + user := app.FindUser(target) + if user == nil { + hint := "available users: " + if names := app.UserNames(); len(names) > 0 { + hint += strings.Join(names, ", ") + } else { + hint += "(none — run `lark-cli auth login` to add one)" + } + return errs.NewConfigError(errs.SubtypeInvalidArgument, + "user %q not found in profile %q", target, app.ProfileName()). + WithHint(hint) + } + + // Share login's flock so concurrent `auth users use` and `auth login` + // cannot interleave reads/writes of the config file. + root := loginRoot() + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "users use: acquire flock: %v", err).WithCause(err) + } + defer lk.Release() + + // Reload under the flock — state may have changed since the pre-lock load. + multi, err = core.LoadMultiAppConfig() + if err != nil { + return core.PassThroughOrNotConfigured(err) + } + idx := multi.FindAppIndex(app.ProfileName()) + if idx < 0 { + return errs.NewConfigError(errs.SubtypeInvalidConfig, + "profile %q vanished during users use", app.ProfileName()) + } + app = &multi.Apps[idx] + + // Re-find under the flock — a concurrent logout could have removed it. + user = app.FindUser(target) + if user == nil { + return errs.NewConfigError(errs.SubtypeInvalidArgument, + "user %q vanished during users use", target) + } + + if app.CurrentUser == user.UserOpenId { + // still report so scripts can rely on stable JSON + fmt.Fprintf(f.IOStreams.ErrOut, "Already active: %s (%s)\n", user.UserName, user.UserOpenId) + return nil + } + app.CurrentUser = user.UserOpenId + + if err := core.SaveMultiAppConfig(multi); err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err) + } + + // Best-effort: bump LastUsed so `users list` reflects the active pick. + if err := larkauth.RecordUserActivity(root, larkauth.ForUser(app.AppId, user.UserOpenId), nil); err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth users use: record activity: %v\n", err) + } + + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Active user: %s (%s)", user.UserName, user.UserOpenId)) + return nil +} diff --git a/cmd/auth/users_use_profile_fallback_test.go b/cmd/auth/users_use_profile_fallback_test.go new file mode 100644 index 000000000..9ec65d699 --- /dev/null +++ b/cmd/auth/users_use_profile_fallback_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/zalando/go-keyring" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Regression: --profile=ghost must NOT silently fall back to Apps[0] +// (which would WRITE CurrentUser onto the wrong profile). Behaviour must +// match users_logout.go / users_list.go: no fallback when an explicit +// profile is named. +// +// Pre-fix, users_use ran a hand-rolled FindAppIndex chain that fell +// through to idx=0 whenever the requested profile didn't resolve, so +// `--profile=ghost auth users use alice` rewrote Apps[0].CurrentUser to +// alice if alice happened to also exist in Apps[0]. Sibling commands had +// already migrated to multi.CurrentAppConfig(profile); this test pins +// the same migration for users_use. +func TestUsersUse_ExplicitProfileNotFound_DoesNotFallbackToAppsZero(t *testing.T) { + keyring.MockInit() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + // Two profiles: alpha holds the candidate user, with CurrentUser set + // to a *different* user. If the bug returns, --profile=ghost would + // resolve to Apps[0]=alpha and silently switch alpha.CurrentUser. + cfg := &core.MultiAppConfig{ + Apps: []core.AppConfig{ + { + Name: "alpha", AppId: "cli_a", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }, + CurrentUser: "ou_bob", + }, + { + Name: "beta", AppId: "cli_b", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{}, + }, + }, + CurrentApp: "alpha", + } + if err := core.SaveMultiAppConfig(cfg); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + var out, errOut bytes.Buffer + streams := &cmdutil.IOStreams{Out: &out, ErrOut: &errOut} + f := &cmdutil.Factory{ + IOStreams: streams, + Invocation: cmdutil.InvocationContext{Profile: "ghost"}, + } + + opts := &UsersUseOptions{Factory: f, Target: "ou_alice"} + err := authUsersUseRun(opts) + if err == nil { + t.Fatal("expected profile-not-found error for --profile=ghost, got nil — fallback bug returned") + } + if !strings.Contains(err.Error(), "ghost") { + t.Errorf("error must mention the requested profile %q; got %v", "ghost", err) + } + + // Alpha's CurrentUser must still be ou_bob — the bug would have + // rewritten it to ou_alice before the error was returned. + reloaded, lerr := core.LoadMultiAppConfig() + if lerr != nil { + t.Fatalf("reload: %v", lerr) + } + alpha := reloaded.FindApp("alpha") + if alpha == nil { + t.Fatal("alpha vanished from config") + } + if alpha.CurrentUser != "ou_bob" { + t.Errorf("alpha.CurrentUser mutated by --profile=ghost fallback: got %q, want %q", + alpha.CurrentUser, "ou_bob") + } +} + +// Sanity: typed *core.ConfigError still surfaces (not a generic error) +// so dispatcher promotion keeps working, and the subtype is the +// canonical SubtypeInvalidArgument for "operator named a non-existent +// profile" (matching the SubtypeInvalidArgument convention used at +// users_logout.go for an unknown user). +func TestUsersUse_ExplicitProfileNotFound_ErrorIsTyped(t *testing.T) { + keyring.MockInit() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + cfg := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "alpha", AppId: "cli_a", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_a"}}, + }}, + } + if err := core.SaveMultiAppConfig(cfg); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + streams := &cmdutil.IOStreams{Out: &out, ErrOut: &errOut} + f := &cmdutil.Factory{IOStreams: streams, Invocation: cmdutil.InvocationContext{Profile: "ghost"}} + opts := &UsersUseOptions{Factory: f, Target: "ou_a"} + + err := authUsersUseRun(opts) + if err == nil { + t.Fatal("expected error") + } + + // Wire envelope subtype is the AI-routing axis; SubtypeNotConfigured + // would invite `config init`, which would clobber the working profiles. + // SubtypeInvalidArgument is the right signal for "operator typed a + // profile name that doesn't exist". + var typed *errs.ConfigError + if !errors.As(err, &typed) { + t.Fatalf("expected *errs.ConfigError; got %T %v", err, err) + } + if typed.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype: got %q, want %q (SubtypeInvalidArgument)", typed.Subtype, errs.SubtypeInvalidArgument) + } +} diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 841a88409..ac247db70 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -6,14 +6,46 @@ package cmd import ( "errors" "io" + "os" + "strings" + "sync" + "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/migrate" "github.com/spf13/pflag" ) +// migrateOnce gates MaybeMigrate to a single run per process: cobra +// re-parses global flags on completion/help paths, and we must not +// migrate twice. Errors are swallowed so a failed migration can never +// block command dispatch; the migrator retries on the next invocation. +var migrateOnce sync.Once + +// runMigrationOnce is a var so tests can replace it with a no-op. +var runMigrationOnce = func() { + migrateOnce.Do(func() { + root := larkauth.NewLocalRoot(core.GetConfigDir()) + _ = migrate.MaybeMigrate(root, os.Stderr) + }) +} + // BootstrapInvocationContext extracts global invocation options before // the real command tree is built, so provider-backed config resolution sees // the correct profile from the start. +// +// User-selection precedence: +// 1. --user flag (trimmed). An explicit empty value is a hard error, +// never a silent fallthrough to env. +// 2. LARKSUITE_CLI_OPEN_ID (trimmed), only when --user was not passed. +// 3. Empty; downstream core.ResolveConfigFromMulti walks +// AppConfig.CurrentUser then Users[0]. +// +// The env var is read here ONLY — credential and resolver layers stay +// env-agnostic (enforced by canary tests). func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) { var globals GlobalOptions @@ -26,5 +58,30 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) { return cmdutil.InvocationContext{}, err } - return cmdutil.InvocationContext{Profile: globals.Profile}, nil + + // Run migration after flag parse (so [WARN] lines are visible) but + // before buildInternal (so subcommands see the migrated state). + runMigrationOnce() + + var ( + userOverride string + userSource string + ) + if fs.Changed("user") { + u := strings.TrimSpace(globals.User) + if u == "" { + return cmdutil.InvocationContext{}, errs.NewConfigError(errs.SubtypeInvalidArgument, + "--user requires a non-empty value"). + WithHint("run `lark-cli auth users list` to see available users") + } + userOverride, userSource = u, "flag" + } else if u := strings.TrimSpace(os.Getenv(envvars.CliOpenID)); u != "" { + userOverride, userSource = u, "env" + } + + return cmdutil.InvocationContext{ + Profile: globals.Profile, + UserOpenId: userOverride, + UserSource: userSource, + }, nil } diff --git a/cmd/bootstrap_user_test.go b/cmd/bootstrap_user_test.go new file mode 100644 index 000000000..75f42830d --- /dev/null +++ b/cmd/bootstrap_user_test.go @@ -0,0 +1,199 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/envvars" +) + +// User-selection precedence: --user flag > LARKSUITE_CLI_OPEN_ID env > unset. +// The bootstrap layer is the only reader of the env var; downstream resolvers +// stay env-agnostic (locked separately by canary tests). + +func TestBootstrap_UserFlag_Pre(t *testing.T) { + clearUserEnv(t) + inv, err := BootstrapInvocationContext([]string{"--user", "ou_alice", "auth", "status"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext: %v", err) + } + if inv.UserOpenId != "ou_alice" { + t.Errorf("UserOpenId = %q, want ou_alice", inv.UserOpenId) + } + if inv.UserSource != "flag" { + t.Errorf("UserSource = %q, want flag", inv.UserSource) + } +} + +// --user=value after a subcommand requires Interspersed=true. +func TestBootstrap_UserFlag_Post(t *testing.T) { + clearUserEnv(t) + inv, err := BootstrapInvocationContext([]string{"auth", "status", "--user=ou_alice"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext: %v", err) + } + if inv.UserOpenId != "ou_alice" { + t.Errorf("UserOpenId = %q, want ou_alice", inv.UserOpenId) + } + if inv.UserSource != "flag" { + t.Errorf("UserSource = %q, want flag", inv.UserSource) + } +} + +// Explicit --user= must error rather than fall through to env (typo trap). +func TestBootstrap_UserFlag_EmptyValue_Errors(t *testing.T) { + clearUserEnv(t) + t.Setenv(envvars.CliOpenID, "ou_from_env") + + inv, err := BootstrapInvocationContext([]string{"--user=", "auth", "status"}) + if err == nil { + t.Fatalf("expected error, got inv=%+v", inv) + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) + } + if cfgErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidArgument) + } + if !strings.Contains(cfgErr.Message, "--user requires a non-empty value") { + t.Errorf("Message missing key text: %q", cfgErr.Message) + } + if inv.UserOpenId != "" || inv.UserSource != "" { + t.Errorf("InvocationContext should be zero-valued on error, got %+v", inv) + } +} + +// Whitespace-only --user is the same explicit-blank typo case. +func TestBootstrap_UserFlag_WhitespaceFlag_Errors(t *testing.T) { + clearUserEnv(t) + t.Setenv(envvars.CliOpenID, "ou_from_env") + + _, err := BootstrapInvocationContext([]string{"--user= ", "auth"}) + if err == nil { + t.Fatal("expected error for whitespace-only --user, got nil") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) || cfgErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("expected ConfigError(SubtypeInvalidArgument), got %T %v", err, err) + } +} + +func TestBootstrap_UserEnv_Used(t *testing.T) { + t.Setenv(envvars.CliOpenID, "ou_bob") + inv, err := BootstrapInvocationContext([]string{"auth", "status"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext: %v", err) + } + if inv.UserOpenId != "ou_bob" { + t.Errorf("UserOpenId = %q, want ou_bob", inv.UserOpenId) + } + if inv.UserSource != "env" { + t.Errorf("UserSource = %q, want env", inv.UserSource) + } +} + +// Whitespace env is treated as unset (asymmetric with empty flag, which errors). +func TestBootstrap_UserEnv_WhitespaceTreatedAsUnset(t *testing.T) { + t.Setenv(envvars.CliOpenID, " ") + inv, err := BootstrapInvocationContext([]string{"auth"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext: %v", err) + } + if inv.UserOpenId != "" { + t.Errorf("UserOpenId = %q, want empty (whitespace env is unset)", inv.UserOpenId) + } + if inv.UserSource != "" { + t.Errorf("UserSource = %q, want empty", inv.UserSource) + } +} + +// Flag wins over env, and Source must reflect flag so error hints attribute correctly. +func TestBootstrap_FlagBeatsEnv(t *testing.T) { + t.Setenv(envvars.CliOpenID, "ou_bob") + inv, err := BootstrapInvocationContext([]string{"--user", "ou_alice", "auth"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext: %v", err) + } + if inv.UserOpenId != "ou_alice" { + t.Errorf("UserOpenId = %q, want ou_alice", inv.UserOpenId) + } + if inv.UserSource != "flag" { + t.Errorf("UserSource = %q, want flag (env should not have been read)", inv.UserSource) + } +} + +// Legacy single-user path: empty UserOpenId so resolver walks CurrentUser then Users[0]. +func TestBootstrap_BothUnset(t *testing.T) { + clearUserEnv(t) + inv, err := BootstrapInvocationContext([]string{"auth"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext: %v", err) + } + if inv.UserOpenId != "" || inv.UserSource != "" { + t.Errorf("expected empty user fields, got %+v", inv) + } +} + +// Orthogonal selectors compose. +func TestBootstrap_UserAndProfileTogether(t *testing.T) { + clearUserEnv(t) + inv, err := BootstrapInvocationContext([]string{"--profile", "p1", "--user", "u1", "auth"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext: %v", err) + } + if inv.Profile != "p1" { + t.Errorf("Profile = %q, want p1", inv.Profile) + } + if inv.UserOpenId != "u1" || inv.UserSource != "flag" { + t.Errorf("user fields = (%q,%q), want (u1,flag)", inv.UserOpenId, inv.UserSource) + } +} + +// --help with --user must not error (matches --profile + --help behavior). +func TestBootstrap_UserFlag_HelpStillWorks(t *testing.T) { + clearUserEnv(t) + inv, err := BootstrapInvocationContext([]string{"--user", "ou_alice", "--help"}) + if err != nil { + t.Fatalf("--help with --user should not error, got: %v", err) + } + if inv.UserOpenId != "ou_alice" { + t.Errorf("UserOpenId = %q, want ou_alice", inv.UserOpenId) + } +} + +// Whitespace around a real flag value is trimmed. +func TestBootstrap_UserFlag_Trimmed(t *testing.T) { + clearUserEnv(t) + inv, err := BootstrapInvocationContext([]string{"--user", " ou_alice ", "auth"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext: %v", err) + } + if inv.UserOpenId != "ou_alice" { + t.Errorf("UserOpenId = %q, want ou_alice (trimmed)", inv.UserOpenId) + } +} + +// Env values are trimmed too — flag and env paths agree on what "empty" means. +func TestBootstrap_UserEnv_Trimmed(t *testing.T) { + t.Setenv(envvars.CliOpenID, " ou_bob ") + inv, err := BootstrapInvocationContext([]string{"auth"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext: %v", err) + } + if inv.UserOpenId != "ou_bob" { + t.Errorf("UserOpenId = %q, want ou_bob (trimmed)", inv.UserOpenId) + } +} + +// clearUserEnv unsets LARKSUITE_CLI_OPEN_ID so a developer's shell env can't +// leak into flag-only tests. +func clearUserEnv(t *testing.T) { + t.Helper() + t.Setenv(envvars.CliOpenID, "") +} diff --git a/cmd/config/bind.go b/cmd/config/bind.go index 2ec7843f2..110a1f399 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -4,21 +4,23 @@ package config import ( + "context" "encoding/json" "fmt" "os" "strings" + "time" "github.com/charmbracelet/huh" "github.com/spf13/cobra" "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" ) @@ -131,6 +133,26 @@ func configBindRun(opts *BindOptions) error { core.SetCurrentWorkspace(core.Workspace(source)) targetConfigPath := core.GetConfigPath() + // Serialise the entire bind flow against any concurrent config mutator. + // Lock OUTSIDE the TUI prompts: reconcileExistingBinding reads bytes + // pre-prompt and commitBinding writes post-prompt, so the TUI window is + // itself the race surface. 30s wait is acceptable because the caller is + // already gate-keeping on human input. + // + // IMPORTANT: acquire AFTER SetCurrentWorkspace. NewLocalRoot captures + // configDir eagerly, so a lock taken pre-switch lands at + // /locks/login.lock while the write lands at + // /config.json — peer auth login in the target workspace would + // take /locks/login.lock and race the same target file. + root := larkauth.NewLocalRoot(core.GetConfigDir()) + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "config bind: acquire flock: %v", err).WithCause(err) + } + defer lk.Release() + existing, err := reconcileExistingBinding(opts, source, targetConfigPath) if err != nil { return err @@ -240,6 +262,17 @@ func finalizeSource(opts *BindOptions) (string, error) { // how to proceed. In TUI mode the user is prompted to keep or replace. In flag // mode the existing binding is silently overwritten — commitBinding will emit a // notice on success so the caller still sees that a rebind happened. +// +// R2 schema-version gate: bytes that parse to a SchemaVersion newer than this +// binary supports MUST refuse the rebind, not silently clobber. The +// downstream renderers (tuiConflictPrompt, priorLang, hasStrictBotLock, +// cleanupKeychainFromData) keep their fault-tolerant `Unmarshal != nil → +// empty` behavior because the bytes that reach them have already cleared +// this gate. Garbled JSON is left to those renderers (the rebind will +// overwrite it cleanly anyway, matching the rest of the bind flow's +// lenient handling) — the gate fires only on a parseable but +// future-binary file, where silent overwrite would lose newer-only fields. +// // See existingBinding for the returned fields. func reconcileExistingBinding(opts *BindOptions, source, configPath string) (existingBinding, error) { oldConfigData, _ := vfs.ReadFile(configPath) @@ -247,6 +280,24 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi return existingBinding{}, nil } + // R2 forward-incompat refusal. We can't go through core.LoadMultiAppConfig + // because (a) it errors on len(Apps)==0 which is a perfectly valid pre-bind + // shape (the user already ran an earlier bind that wrote zero apps for some + // reason — corrupt-but-parseable, the bind flow tolerates it), and (b) it + // reads from core.GetConfigPath() which would re-resolve through the + // workspace machinery rather than honor configPath directly. Inline the + // version-check; the rest of the load is left to the lenient renderers + // downstream. + var probe core.MultiAppConfig + if err := json.Unmarshal(oldConfigData, &probe); err == nil && probe.SchemaVersion > core.CurrentSchemaVersion { + return existingBinding{}, &core.ConfigError{ + Code: 3, Type: "config", + Message: fmt.Sprintf("config.json at %s was written by a newer lark-cli (schemaVersion %d > supported %d); refusing to overwrite", + configPath, probe.SchemaVersion, core.CurrentSchemaVersion), + Hint: "upgrade lark-cli before re-binding this workspace, or rebind in a different workspace to avoid losing fields the newer binary populated", + } + } + if opts.IsTUI { action, err := tuiConflictPrompt(opts, source, configPath) if err != nil { @@ -410,14 +461,22 @@ func priorLang(previousConfigBytes []byte) i18n.Lang { func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error { multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}} - if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil { - return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err) - } - data, err := json.MarshalIndent(multi, "", " ") - if err != nil { - return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err) - } - if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil { + // core.SaveMultiAppConfig stamps SchemaVersion = CurrentSchemaVersion and + // uses the same atomic-write contract as the inline path used to. The + // previous inline `json.MarshalIndent + validate.AtomicWrite` skipped the + // stamp, leaving the post-bind file at SchemaVersion=0 (legacy). On the + // next load that file looked legacy, defeating the gate's whole purpose + // the moment any forward-incompat field was added. Going through the + // canonical save closes that gap, and keeps the bind path on the same + // serialization trunk as every other config writer (init, login, + // profile add/remove, users use). + // + // configPath was set up front from core.GetConfigPath() after + // SetCurrentWorkspace; SaveMultiAppConfig reads core.GetConfigPath() + // internally and resolves to the same path. The MkdirAll is also + // internal to SaveMultiAppConfig (vfs.MkdirAll on GetConfigDir()), so + // the explicit workspace mkdir is no longer needed. + if err := core.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err) } @@ -475,10 +534,23 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB // cleanupKeychainFromData removes keychain entries referenced by a previous // config snapshot, skipping any entry whose keychain ID is still in use by -// the new app config. This prevents rebinding the same appId from deleting -// the secret that ForStorage just wrote (old and new secret share the same -// keychain key, derived from appId). Best-effort: errors are silently -// ignored (same contract as config init's cleanup). +// the new app config. Two classes of entry are swept: +// +// - AppSecret: the workspace-app credential. Skipped when the new app +// reuses the same keychain ID (rebinding the same appId), since old +// and new secret share the key — without the skip, ForStorage's +// freshly-written secret would be clobbered. +// - Per-user artifacts: every (appId, userOpenId) pair the previous +// config listed under Users[] — including the keychain UAT, the +// sidecar profile JSON on disk, and the user_index.json row. +// binder.Build returns Users: [] for every source today, so on +// rebind the entire Users[] of the prior workspace is replaced and +// any artifact not swept here orphans forever. Skipping is +// per-(appId, userOpenId) so a future binder that propagates users +// to the new config does not destroy still-referenced state. +// +// Best-effort: errors are silently ignored (same contract as +// cleanupOldConfig in cmd/config/init.go). func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) { var multi core.MultiAppConfig if err := json.Unmarshal(data, &multi); err != nil { @@ -488,11 +560,25 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core if keep != nil && keep.AppSecret.Ref != nil && keep.AppSecret.Ref.Source == "keychain" { keepID = keep.AppSecret.Ref.ID } + // keepUsers indexes (appId, userOpenId) pairs the new config still + // references — any UAT outside this set is stale. + keepUsers := make(map[string]struct{}) + if keep != nil { + for _, u := range keep.Users { + keepUsers[keep.AppId+"\x00"+u.UserOpenId] = struct{}{} + } + } + root := larkauth.NewLocalRoot(core.GetConfigDir()) for _, app := range multi.Apps { - if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID { - continue + if keepID == "" || app.AppSecret.Ref == nil || app.AppSecret.Ref.Source != "keychain" || app.AppSecret.Ref.ID != keepID { + core.RemoveSecretStore(app.AppSecret, kc) + } + for _, user := range app.Users { + if _, ok := keepUsers[app.AppId+"\x00"+user.UserOpenId]; ok { + continue + } + _ = larkauth.PurgeUserArtifacts(root, app.AppId, user.UserOpenId) } - core.RemoveSecretStore(app.AppSecret, kc) } } diff --git a/cmd/config/bind_lock_path_test.go b/cmd/config/bind_lock_path_test.go new file mode 100644 index 000000000..4cd1a5d66 --- /dev/null +++ b/cmd/config/bind_lock_path_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" +) + +// TestConfigBindRun_LockPath_FollowsTargetWorkspace pins the post-fix invariant: +// configBindRun's flock MUST land under the TARGET workspace's locks dir, not +// the workspace the process started in. Pre-fix, NewLocalRoot was constructed +// with core.GetConfigDir() before SetCurrentWorkspace, so a `bind --source X` +// invoked from a Local shell took the lock at /locks/login.lock while +// commitBinding wrote /X/config.json — leaving a peer `auth login` in +// workspace X (which takes /X/locks/login.lock) free to race the same +// target file. +// +// We assert the post-fix layout by inspecting the locks/ directory tree after +// the bind run: the TARGET workspace's locks/ MUST exist (gofrs/flock creates +// the file lazily on Acquire and leaves it behind after Release), while the +// SOURCE//locks/ MUST NOT have a login.lock file from this run. +// +// Hermes is the cleanest fixture because its env-detection path (HERMES_HOME +// + .env) is already exercised by the existing bind suite. +func TestConfigBindRun_LockPath_FollowsTargetWorkspace(t *testing.T) { + cases := []struct { + name string + source string + wantSubdir string // relative to configDir + }{ + { + name: "hermes", + source: "hermes", + wantSubdir: "hermes", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveWorkspace(t) + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + t.Setenv("HOME", t.TempDir()) // keep keychain mocks isolated + clearAgentEnv(t) + + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile( + filepath.Join(hermesHome, ".env"), + []byte("FEISHU_APP_ID=cli_lockpath\nFEISHU_APP_SECRET=lock_secret\n"), + 0600, + ); err != nil { + t.Fatalf("write .env: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f, Source: tc.source}); err != nil { + t.Fatalf("configBindRun: %v", err) + } + + // gofrs/flock leaves the lock file on disk after Release; presence of + // login.lock is therefore a faithful witness of where Acquire was rooted. + targetLock := filepath.Join(configDir, tc.wantSubdir, "locks", "login.lock") + if _, err := os.Stat(targetLock); err != nil { + t.Errorf("target-workspace login.lock missing at %q: %v\n"+ + "expected the bind flock to be rooted on the target workspace, "+ + "not the workspace the process started in", targetLock, err) + } + + // The SOURCE workspace (Local in this fixture) MUST NOT carry a + // login.lock from this bind run. If it does, the flock was acquired + // pre-SetCurrentWorkspace and the cross-workspace serialisation + // guarantee is broken. + sourceLock := filepath.Join(configDir, "locks", "login.lock") + if _, err := os.Stat(sourceLock); err == nil { + t.Errorf("source-workspace login.lock present at %q; "+ + "flock was acquired BEFORE SetCurrentWorkspace, regressing "+ + "the cross-workspace serialisation fix", sourceLock) + } + }) + } +} + +// TestConfigBindRun_LockPath_OpenClaw mirrors the Hermes test for the OpenClaw +// auto-detect path. Kept separate because the env-detection fixtures differ +// (OPENCLAW_HOME + openclaw.json vs HERMES_HOME + .env), and the failure mode +// for one is not symptomatic of the other. +func TestConfigBindRun_LockPath_OpenClaw(t *testing.T) { + saveWorkspace(t) + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + t.Setenv("HOME", t.TempDir()) + clearAgentEnv(t) + + openclawHome := t.TempDir() + t.Setenv("OPENCLAW_HOME", openclawHome) + t.Setenv("OPENCLAW_CONFIG_PATH", "") + t.Setenv("OPENCLAW_STATE_DIR", "") + + openclawDir := filepath.Join(openclawHome, ".openclaw") + if err := os.MkdirAll(openclawDir, 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + cfg := `{"channels":{"feishu":{"appId":"cli_lockpath_oc","appSecret":"lock_oc_secret","domain":"feishu"}}}` + if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(cfg), 0600); err != nil { + t.Fatalf("write openclaw.json: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}); err != nil { + t.Fatalf("configBindRun: %v", err) + } + + targetLock := filepath.Join(configDir, "openclaw", "locks", "login.lock") + if _, err := os.Stat(targetLock); err != nil { + t.Errorf("openclaw login.lock missing at %q: %v", targetLock, err) + } + + sourceLock := filepath.Join(configDir, "locks", "login.lock") + if _, err := os.Stat(sourceLock); err == nil { + t.Errorf("source-workspace login.lock present at %q; cross-workspace "+ + "serialisation fix has regressed", sourceLock) + } +} diff --git a/cmd/config/bind_r2_test.go b/cmd/config/bind_r2_test.go new file mode 100644 index 000000000..e90a30a80 --- /dev/null +++ b/cmd/config/bind_r2_test.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// TestConfigBindRun_R2_RefusesForwardIncompatConfig is the read-side gate. +// +// Pre-fix `reconcileExistingBinding` read the existing config via raw +// vfs.ReadFile + json.Unmarshal, bypassing core.LoadMultiAppConfig's +// SchemaVersion gate. A workspace whose config.json was written by a newer +// lark-cli (schemaVersion > CurrentSchemaVersion) would be silently +// overwritten on rebind, dropping any newer-only fields the future binary +// had populated. +// +// The fix probes SchemaVersion before either the TUI conflict prompt or the +// flag-mode silent overwrite path, and returns a *core.ConfigError that +// reaches the user verbatim (configBindRun returns it without wrapping — +// see bind.go:158). +func TestConfigBindRun_R2_RefusesForwardIncompatConfig(t *testing.T) { + saveWorkspace(t) + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + // Pre-create hermes workspace config tagged with a future schemaVersion. + // Only schemaVersion matters here — the rest of the body just needs to + // be parseable JSON for the json.Unmarshal probe to succeed. + hermesDir := filepath.Join(configDir, "hermes") + if err := os.MkdirAll(hermesDir, 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + hermesConfigPath := filepath.Join(hermesDir, "config.json") + if err := os.WriteFile(hermesConfigPath, + []byte(`{"schemaVersion":2,"apps":[{"appId":"future_app"}]}`), 0600); err != nil { + t.Fatalf("write: %v", err) + } + + // Stage a Hermes source so resolveAccount would otherwise succeed — + // proving the refusal fires at reconcile, not later. + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), + []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) + if err == nil { + t.Fatal("expected refusal for schemaVersion > CurrentSchemaVersion, got nil") + } + + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error type = %T, want *core.ConfigError", err) + } + if cfgErr.Code != 3 { + t.Errorf("Code = %d, want 3 (config exit code)", cfgErr.Code) + } + if cfgErr.Type != "config" { + t.Errorf("Type = %q, want %q", cfgErr.Type, "config") + } + if !strings.Contains(cfgErr.Message, "schemaVersion 2") { + t.Errorf("message missing 'schemaVersion 2': %q", cfgErr.Message) + } + if !strings.Contains(cfgErr.Message, "refusing to overwrite") { + t.Errorf("message missing 'refusing to overwrite': %q", cfgErr.Message) + } + if !strings.Contains(cfgErr.Hint, "upgrade lark-cli") { + t.Errorf("hint missing 'upgrade lark-cli': %q", cfgErr.Hint) + } + + // Critical: the file must be untouched. Pre-fix it would have been + // silently overwritten with the new flag-mode binding. + got, err := os.ReadFile(hermesConfigPath) + if err != nil { + t.Fatalf("read after refusal: %v", err) + } + if !strings.Contains(string(got), `"schemaVersion":2`) || + !strings.Contains(string(got), `"future_app"`) { + t.Errorf("config was overwritten despite refusal:\n%s", got) + } +} + +// TestConfigBindRun_R2_StampsCurrentSchemaVersion is the write-side gate. +// +// Pre-fix `commitBinding` wrote the new config via inline json.MarshalIndent +// + validate.AtomicWrite, skipping core.SaveMultiAppConfig's +// SchemaVersion-stamping. A successful bind would leave the file at +// SchemaVersion=0 (legacy), which defeats the read-side gate's whole +// purpose: as soon as any forward-incompat field is introduced in a future +// version, the file looks legacy and the gate silently lets it through. +// +// The fix migrates the write to core.SaveMultiAppConfig, which stamps +// SchemaVersion = CurrentSchemaVersion. Round-tripping via +// core.LoadMultiAppConfig confirms the stamp is durably persisted. +func TestConfigBindRun_R2_StampsCurrentSchemaVersion(t *testing.T) { + saveWorkspace(t) + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), + []byte("FEISHU_APP_ID=cli_stamp_test\nFEISHU_APP_SECRET=stamp_secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}); err != nil { + t.Fatalf("configBindRun: %v", err) + } + + // SetCurrentWorkspace was invoked during configBindRun; LoadMultiAppConfig + // reads from the workspace path the bind targeted. + loaded, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig after bind: %v", err) + } + if loaded.SchemaVersion != core.CurrentSchemaVersion { + t.Errorf("SchemaVersion = %d, want %d (CurrentSchemaVersion)", + loaded.SchemaVersion, core.CurrentSchemaVersion) + } + if len(loaded.Apps) != 1 || loaded.Apps[0].AppId != "cli_stamp_test" { + t.Errorf("apps = %+v, want single cli_stamp_test", loaded.Apps) + } +} diff --git a/cmd/config/bind_uat_cleanup_test.go b/cmd/config/bind_uat_cleanup_test.go new file mode 100644 index 000000000..3234c8eea --- /dev/null +++ b/cmd/config/bind_uat_cleanup_test.go @@ -0,0 +1,185 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "testing" + + "github.com/zalando/go-keyring" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Regression: pre-fix, cleanupKeychainFromData on `config bind` cleaned +// up only the AppSecret keychain entry. Per-user UATs were left +// orphaned in the keychain forever, because every binder.Build today +// returns Users: [], so commitBinding wholesale-replaces Apps[] (and +// the new app's Users[] is always empty). Symptom in the wild: users +// rebinding from app A to app B keep accumulating dead tokens under +// the lark-cli service in their OS keychain — observed dozens of +// stale entries from repeated rebinds. +// +// The fix extends cleanupKeychainFromData to sweep every (appId, +// userOpenId) UAT in the previous config, with a per-pair skip set so +// a future binder that propagates Users[] to the new config does not +// destroy still-referenced tokens. + +// TestCleanupKeychainFromData_RemovesStaleUAT_OnRebindToNewApp covers +// the dominant real case: rebinding from app A to a different app B, +// where A had logged-in users. Their UATs must be swept. +func TestCleanupKeychainFromData_RemovesStaleUAT_OnRebindToNewApp(t *testing.T) { + keyring.MockInit() + + // Seed prior UATs for two users under the OLD app. + for _, u := range []string{"ou_alice", "ou_bob"} { + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_old", UserOpenId: u, AccessToken: "tok_" + u, + }); err != nil { + t.Fatalf("seed UAT for %s: %v", u, err) + } + } + + oldConfig := []byte(`{"apps":[{"appId":"cli_old","appSecret":{"source":"keychain","id":"appsecret:cli_old"},"users":[{"userOpenId":"ou_alice","userName":"Alice"},{"userOpenId":"ou_bob","userName":"Bob"}]}]}`) + // New app: different appId entirely, fresh Users[] (matches binder.Build). + newApp := &core.AppConfig{ + AppId: "cli_new", + AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:cli_new"}}, + Users: []core.AppUser{}, + } + f, _, _, _ := cmdutil.TestFactory(t, nil) + + cleanupKeychainFromData(f.Keychain, oldConfig, newApp) + + for _, u := range []string{"ou_alice", "ou_bob"} { + if got := larkauth.GetStoredToken("cli_old", u); got != nil { + t.Errorf("UAT for (cli_old, %s) was not removed: %+v", u, got) + } + } +} + +// TestCleanupKeychainFromData_RemovesStaleUAT_OnRebindToSameAppEmptyUsers +// covers the today-binders case: rebinding to the SAME appId, but +// binder.Build returned Users: [] so the new config has no users. All +// prior users' UATs under that appId must be swept (the new config no +// longer references any of them). +// +// This is what happens whenever a workspace rebinds without any +// upstream change to the source's user list — and it is the +// reproducer for the orphan accumulation observed in production. +func TestCleanupKeychainFromData_RemovesStaleUAT_OnRebindToSameAppEmptyUsers(t *testing.T) { + keyring.MockInit() + + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_shared", UserOpenId: "ou_alice", AccessToken: "tok_a", + }); err != nil { + t.Fatalf("seed UAT: %v", err) + } + + oldConfig := []byte(`{"apps":[{"appId":"cli_shared","appSecret":{"source":"keychain","id":"appsecret:cli_shared"},"users":[{"userOpenId":"ou_alice","userName":"Alice"}]}]}`) + newApp := &core.AppConfig{ + AppId: "cli_shared", + AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:cli_shared"}}, + Users: []core.AppUser{}, // matches binder.Build today + } + f, _, _, _ := cmdutil.TestFactory(t, nil) + + cleanupKeychainFromData(f.Keychain, oldConfig, newApp) + + if got := larkauth.GetStoredToken("cli_shared", "ou_alice"); got != nil { + t.Errorf("stale UAT for (cli_shared, ou_alice) was not removed: %+v", got) + } +} + +// TestCleanupKeychainFromData_PreservesUATWhenNewConfigStillReferencesUser +// is the forward-compat lock: if a future binder propagates Users[] to +// the new config (or a hand-crafted keep argument carries the same +// (appId, userOpenId) pair), the corresponding UAT must NOT be +// destroyed. The skip set is per-(appId, userOpenId), not per-app. +func TestCleanupKeychainFromData_PreservesUATWhenNewConfigStillReferencesUser(t *testing.T) { + keyring.MockInit() + + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_shared", UserOpenId: "ou_alice", AccessToken: "tok_keep", + }); err != nil { + t.Fatalf("seed UAT: %v", err) + } + // A second user that will be removed (in old config but NOT in keep.Users). + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_shared", UserOpenId: "ou_bob", AccessToken: "tok_drop", + }); err != nil { + t.Fatalf("seed UAT: %v", err) + } + + oldConfig := []byte(`{"apps":[{"appId":"cli_shared","appSecret":{"source":"keychain","id":"appsecret:cli_shared"},"users":[{"userOpenId":"ou_alice","userName":"Alice"},{"userOpenId":"ou_bob","userName":"Bob"}]}]}`) + newApp := &core.AppConfig{ + AppId: "cli_shared", + AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:cli_shared"}}, + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + } + f, _, _, _ := cmdutil.TestFactory(t, nil) + + cleanupKeychainFromData(f.Keychain, oldConfig, newApp) + + // ou_alice survives — the new config still references this user. + if got := larkauth.GetStoredToken("cli_shared", "ou_alice"); got == nil { + t.Errorf("UAT for (cli_shared, ou_alice) was destroyed despite being in keep.Users") + } + // ou_bob must go — it's only in the old config. + if got := larkauth.GetStoredToken("cli_shared", "ou_bob"); got != nil { + t.Errorf("UAT for (cli_shared, ou_bob) was not removed: %+v", got) + } +} + +// TestCleanupKeychainFromData_DoesNotTouchUnrelatedUAT confirms the +// sweep is scoped: a UAT keyed under a DIFFERENT appId entirely (e.g. +// for an unrelated workspace/profile/app the user has logged into via +// some other CLI surface) is not collateral damage of this cleanup. +func TestCleanupKeychainFromData_DoesNotTouchUnrelatedUAT(t *testing.T) { + keyring.MockInit() + + // UAT for an app that is NOT in the old config under cleanup. + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_unrelated", UserOpenId: "ou_carol", AccessToken: "tok_unrelated", + }); err != nil { + t.Fatalf("seed UAT: %v", err) + } + + oldConfig := []byte(`{"apps":[{"appId":"cli_old","appSecret":{"source":"keychain","id":"appsecret:cli_old"},"users":[{"userOpenId":"ou_alice","userName":"Alice"}]}]}`) + newApp := &core.AppConfig{ + AppId: "cli_new", + AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:cli_new"}}, + Users: []core.AppUser{}, + } + f, _, _, _ := cmdutil.TestFactory(t, nil) + + cleanupKeychainFromData(f.Keychain, oldConfig, newApp) + + if got := larkauth.GetStoredToken("cli_unrelated", "ou_carol"); got == nil { + t.Errorf("unrelated UAT (cli_unrelated, ou_carol) was destroyed; sweep scope is too broad") + } +} + +// TestCleanupKeychainFromData_NilKeep is the all-old-removed case: +// when there is no new app to keep (caller passes keep=nil — e.g. a +// future "config unbind"), every UAT in the old config is swept. +func TestCleanupKeychainFromData_NilKeep(t *testing.T) { + keyring.MockInit() + + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_old", UserOpenId: "ou_alice", AccessToken: "tok", + }); err != nil { + t.Fatalf("seed UAT: %v", err) + } + + oldConfig := []byte(`{"apps":[{"appId":"cli_old","appSecret":{"source":"keychain","id":"appsecret:cli_old"},"users":[{"userOpenId":"ou_alice","userName":"Alice"}]}]}`) + f, _, _, _ := cmdutil.TestFactory(t, nil) + + cleanupKeychainFromData(f.Keychain, oldConfig, nil) + + if got := larkauth.GetStoredToken("cli_old", "ou_alice"); got != nil { + t.Errorf("UAT not removed when keep=nil: %+v", got) + } +} diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index 04645c4ea..7cebf77fa 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -329,6 +329,13 @@ func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing f, _, _, _ := cmdutil.TestFactory(t, nil) f.Keychain = kc + // Pre-create the locks dir so the flock acquired by configRemoveRun + // inside the read-only configDir can still take its file lock — only + // the config.json write should fail when we make the dir read-only. + if err := os.MkdirAll(filepath.Join(configDir, "locks"), 0700); err != nil { + t.Fatalf("pre-create locks dir: %v", err) + } + // Make subsequent config saves fail while keeping the existing config readable. if err := os.Chmod(configDir, 0500); err != nil { t.Fatalf("Chmod(%s) error = %v", configDir, err) diff --git a/cmd/config/default_as.go b/cmd/config/default_as.go index f1d5de4e7..29549d4f5 100644 --- a/cmd/config/default_as.go +++ b/cmd/config/default_as.go @@ -4,9 +4,12 @@ package config import ( + "context" "fmt" + "time" "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/spf13/cobra" @@ -20,6 +23,36 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command { Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // Read-only show path: skip the flock so a peer holding it across + // a TUI prompt (e.g. `config bind`) doesn't turn an instant status + // query into a 30s timeout. The set arm below saves and MUST + // take the lock. + if len(args) == 0 { + multi, err := core.LoadOrNotConfigured() + if err != nil { + return err + } + app := multi.CurrentAppConfig(f.Invocation.Profile) + if app == nil { + return core.NoActiveProfileError() + } + current := app.DefaultAs + if current == "" { + current = "auto" + } + fmt.Fprintf(f.IOStreams.Out, "default-as: %s\n", current) + return nil + } + + root := larkauth.NewLocalRoot(core.GetConfigDir()) + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "default-as: acquire flock: %v", err).WithCause(err) + } + defer lk.Release() + multi, err := core.LoadOrNotConfigured() if err != nil { return err @@ -30,15 +63,6 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command { return core.NoActiveProfileError() } - if len(args) == 0 { - current := app.DefaultAs - if current == "" { - current = "auto" - } - fmt.Fprintf(f.IOStreams.Out, "default-as: %s\n", current) - return nil - } - value := args[0] if value != "user" && value != "bot" && value != "auto" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value) diff --git a/cmd/config/init.go b/cmd/config/init.go index de8f7b355..89c0a279a 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -11,6 +11,7 @@ import ( "io" "os" "strings" + "time" "github.com/charmbracelet/huh" "github.com/spf13/cobra" @@ -140,18 +141,24 @@ func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool { return o.New || o.AppID != "" || o.AppSecretStdin } -// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID. +// cleanupOldConfig clears keychain entries (AppSecret + UAT + sidecar +// profile + index row) for all apps in existing config except the app +// whose AppId equals skipAppID. func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipAppID string) { if existing == nil { return } + root := larkauthRoot() for _, app := range existing.Apps { if app.AppId == skipAppID { continue } core.RemoveSecretStore(app.AppSecret, f.Keychain) for _, user := range app.Users { - auth.RemoveStoredToken(app.AppId, user.UserOpenId) + // Best-effort: the on-disk legs (sidecar / index) must not + // orphan when the keychain is cleaned up. Errors swallowed + // to match the pre-existing cleanup contract. + _ = auth.PurgeUserArtifacts(root, app.AppId, user.UserOpenId) } } } @@ -183,6 +190,99 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior))) } +// lockedSaveInit serialises the load-modify-save cycle of `config init` +// against any concurrent MultiAppConfig mutator (auth login, profile add, +// users use, config bind, peer config init). +// +// Pre-fix, configInitRun loaded the config pre-prompt and saved +// post-prompt with no flock; a peer `auth login` writing in that gap was +// silently overwritten when init's stale buffer was flushed. Symptom: +// users disappearing from a profile right after a successful login. +// +// Lock semantics mirror the documented MultiAppConfig serializer +// (cmd/auth/login.go.syncLoginUserToProfile and cmd/profile/add.go): +// SingleUser scope, "login" lock name, 30s wait. The pre-prompt load +// stays as-is — the prompts read its values for defaults and R2-refusal +// happens early — but the durable read is the post-flock one. +// +// Caller-supplied existingFallback is used when the file does not exist +// (init's first run); R2 / parse errors short-circuit the save. +func lockedSaveInit(profileName string, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { + root := larkauthRoot() + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(authSingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "config init: acquire flock: %v", err).WithCause(err) + } + defer lk.Release() + + existing, lerr := core.LoadMultiAppConfig() + if lerr != nil { + if !errors.Is(lerr, os.ErrNotExist) { + return core.PassThroughOrNotConfigured(lerr) + } + existing = nil + } + return saveInitConfig(profileName, existing, f, appId, secret, brand, lang) +} + +// lockedUpdateExistingProfile is the lock-wrapped sibling of +// updateExistingProfileWithoutSecret; it serialises the same R-M-W +// hazard as lockedSaveInit for the "interactive existing-profile, +// secret unchanged" path. +func lockedUpdateExistingProfile(f *cmdutil.Factory, profileName, appID string, brand core.LarkBrand, lang string) error { + root := larkauthRoot() + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(authSingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "config init: acquire flock: %v", err).WithCause(err) + } + defer lk.Release() + + existing, lerr := core.LoadMultiAppConfig() + if lerr != nil { + if !errors.Is(lerr, os.ErrNotExist) { + return core.PassThroughOrNotConfigured(lerr) + } + existing = nil + } + return updateExistingProfileWithoutSecret(existing, profileName, appID, brand, lang) +} + +// larkauthRoot / authSingleUser are tiny indirections so the rest of the +// file can avoid the `larkauth` import alias gymnastics — `auth` is already +// imported for RemoveStoredToken, and `auth.NewLocalRoot`/`auth.SingleUser` +// are the same package as larkauth. +func larkauthRoot() *auth.LocalRoot { return auth.NewLocalRoot(core.GetConfigDir()) } +func authSingleUser() auth.AuthContext { return auth.SingleUser() } + +// wrapLockedSaveInitErr classifies errors emerging from lockedSaveInit / +// lockedUpdateExistingProfile. lockedSaveInit already returns typed errors +// for the lock-acquisition (InternalError/Storage) and R2-load failure +// (*core.ConfigError) paths; this wrapper preserves both, plus typed +// errors leaking from the inner save-or-validate logic, and only wraps +// genuinely-untyped errors as InternalError/Storage so exit codes stay +// stable. +func wrapLockedSaveInitErr(err error) error { + if err == nil { + return nil + } + if errs.IsTyped(err) { + return err + } + var cfgErr *core.ConfigError + if errors.As(err, &cfgErr) { + return err + } + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return err + } + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) +} + // saveAsProfile appends or updates a named profile in the config. // If a profile with the same name exists, it updates it; otherwise appends. // When updating, cleans up old keychain secrets if AppId changed. @@ -196,10 +296,19 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr // Clean up old keychain secret and user tokens if AppId changed if multi.Apps[idx].AppId != appId { core.RemoveSecretStore(multi.Apps[idx].AppSecret, kc) + root := larkauthRoot() for _, user := range multi.Apps[idx].Users { - auth.RemoveStoredToken(multi.Apps[idx].AppId, user.UserOpenId) + _ = auth.PurgeUserArtifacts(root, multi.Apps[idx].AppId, user.UserOpenId) } multi.Apps[idx].Users = []core.AppUser{} + // CurrentUser is keyed on UserOpenId; once Users[] is wiped + // because the AppId pivoted under us, the dangling + // CurrentUser is a phantom reference that ResolveConfigFromMulti + // will silently fall back from to Users[0] (which no longer + // exists). Clear it so a subsequent `auth login` correctly + // re-stamps the active user via the empty-CurrentUser branch + // in syncLoginUserToProfile. + multi.Apps[idx].CurrentUser = "" } multi.Apps[idx].AppId = appId multi.Apps[idx].AppSecret = secret @@ -318,7 +427,15 @@ func configInitRun(opts *ConfigInitOptions) error { existing, err := core.LoadMultiAppConfig() if err != nil { - existing = nil // treat as empty + // CRITICAL: a *ConfigError from LoadMultiAppConfig means the file + // is parseable but semantically forbidden (today: R2 forward-incompat + // schema). Silently dropping it would let SaveMultiAppConfig stamp + // SchemaVersion back down and erase omitempty fields a newer binary + // populated. Refuse and surface the upgrade hint. + if !errors.Is(err, os.ErrNotExist) { + return core.PassThroughOrNotConfigured(err) + } + existing = nil } // Validate --profile name if set @@ -335,8 +452,8 @@ func configInitRun(opts *ConfigInitOptions) error { if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } - if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil { - return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) + if err := lockedSaveInit(opts.ProfileName, f, opts.AppID, secret, brand, opts.Lang); err != nil { + return wrapLockedSaveInitErr(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) printLangPreferenceConfirmation(opts) @@ -373,13 +490,12 @@ func configInitRun(opts *ConfigInitOptions) error { if result == nil { return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result") } - existing, _ := core.LoadMultiAppConfig() secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } - if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { - return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) + if err := lockedSaveInit(opts.ProfileName, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { + return wrapLockedSaveInitErr(err) } printLangPreferenceConfirmation(opts) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) @@ -400,20 +516,18 @@ func configInitRun(opts *ConfigInitOptions) error { WithParam("--app-id") } - existing, _ := core.LoadMultiAppConfig() - if result.AppSecret != "" { // New secret provided (either from "create" or "existing" with input) secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } - if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { - return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) + if err := lockedSaveInit(opts.ProfileName, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { + return wrapLockedSaveInitErr(err) } } else if result.Mode == "existing" && result.AppID != "" { // Existing app with unchanged secret — update app ID and brand only - if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil { + if err := wrapUpdateExistingProfileErr(lockedUpdateExistingProfile(f, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil { return err } } else { @@ -513,8 +627,8 @@ func configInitRun(opts *ConfigInitOptions) error { if err != nil { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } - if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { - return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) + if err := lockedSaveInit(opts.ProfileName, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { + return wrapLockedSaveInitErr(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) printLangPreferenceConfirmation(opts) diff --git a/cmd/config/init_currentuser_test.go b/cmd/config/init_currentuser_test.go new file mode 100644 index 000000000..9a974a10e --- /dev/null +++ b/cmd/config/init_currentuser_test.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keychain" +) + +// Regression: saveAsProfile previously wiped Users[] when AppId +// changed under an existing profile but left CurrentUser pointing +// at the old user_open_id. The dangling reference is invisible to +// `auth users list` (which reads Users[]) but very visible to +// ResolveConfigFromMulti's three-rung fallback — it walks past the +// stale CurrentUser and lands on Users[0]==nil, falling through to +// the "no users" branch silently. After a subsequent `auth login`, +// syncLoginUserToProfile saw a non-empty CurrentUser and skipped +// the empty-CurrentUser branch (which is the only branch that +// stamps the freshly-logged-in user as active for an empty +// profile), so the new user was added to Users[] but never +// activated. +// +// Fix: clear CurrentUser when we wipe Users[] for an AppId pivot. +func TestSaveAsProfile_AppIdChange_ClearsCurrentUser(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + existing := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "prod", + AppId: "cli_old", + AppSecret: core.PlainSecret("s-old"), + Brand: core.BrandFeishu, + Users: []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + }, + CurrentUser: "ou_alice", + }}, + } + + if err := saveAsProfile(existing, + keychain.KeychainAccess(&noopConfigKeychain{}), + "prod", "cli_new", + core.PlainSecret("s-new"), + core.BrandFeishu, "en", + ); err != nil { + t.Fatalf("saveAsProfile: %v", err) + } + + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + app := saved.FindApp("prod") + if app == nil { + t.Fatal("prod profile vanished") + } + if app.AppId != "cli_new" { + t.Errorf("AppId = %q, want cli_new", app.AppId) + } + if len(app.Users) != 0 { + t.Errorf("Users = %v, want [] (sweep on AppId change)", app.Users) + } + if app.CurrentUser != "" { + t.Errorf("CurrentUser = %q, want \"\" (must be cleared with the Users sweep — dangling open_id otherwise)", app.CurrentUser) + } +} + +// Counter-test: same AppId update path (Brand-only / Lang-only edit) +// must NOT touch CurrentUser. The clear is targeted at the AppId +// pivot, not a blanket reset on every profile edit. +func TestSaveAsProfile_SameAppId_PreservesCurrentUser(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + existing := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "prod", + AppId: "cli_x", + AppSecret: core.PlainSecret("s-old"), + Brand: core.BrandFeishu, + Users: []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + }, + CurrentUser: "ou_alice", + }}, + } + + if err := saveAsProfile(existing, + keychain.KeychainAccess(&noopConfigKeychain{}), + "prod", "cli_x", // same AppId + core.PlainSecret("s-new"), + core.BrandLark, "en", + ); err != nil { + t.Fatalf("saveAsProfile: %v", err) + } + + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatal(err) + } + app := saved.FindApp("prod") + if app == nil { + t.Fatal("prod vanished") + } + if len(app.Users) != 1 || app.Users[0].UserOpenId != "ou_alice" { + t.Errorf("Users wiped on same-AppId update: %v", app.Users) + } + if app.CurrentUser != "ou_alice" { + t.Errorf("CurrentUser cleared on same-AppId update: got %q, want ou_alice", app.CurrentUser) + } +} diff --git a/cmd/config/init_flock_test.go b/cmd/config/init_flock_test.go new file mode 100644 index 000000000..6e0fa3485 --- /dev/null +++ b/cmd/config/init_flock_test.go @@ -0,0 +1,201 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + larkauth "github.com/larksuite/cli/internal/auth" +) + +// Regression: configInitRun previously loaded the config pre-prompt and +// saved the same buffer post-prompt with no flock. A peer `auth login` +// (or `profile add`, `users use`, `config bind`, peer `config init`) +// writing in that gap had its update silently overwritten when init's +// stale buffer was flushed. Symptom in the wild: a user who ran +// `auth login` while another agent was paused on the init TUI vanished +// from Users[] the moment the operator hit Enter on init. +// +// These tests pin the contract: +// +// 1. lockedSaveInit blocks while the SingleUser/login lock is held by +// a peer; once the peer releases, the save lands. +// 2. lockedSaveInit re-loads the config inside the lock — a peer write +// that occurred between the pre-prompt load and lockedSaveInit is +// reflected in the saved file (no lost update). +// +// The lock primitive is the documented MultiAppConfig serialiser +// (cmd/auth/login.go.syncLoginUserToProfile, cmd/profile/add.go); +// the helper just routes init through it. + +// TestLockedSaveInit_BlocksOnPeerLock asserts the lock is actually +// acquired. With the flock removed, the goroutine returns immediately +// instead of waiting on the holder. +func TestLockedSaveInit_BlocksOnPeerLock(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + core.SetCurrentWorkspace(core.WorkspaceLocal) + + // Acquire the same lock from a peer goroutine, hold it for 250ms. + root := larkauth.NewLocalRoot(core.GetConfigDir()) + peerCtx, peerCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer peerCancel() + peerLock, err := root.Locks(larkauth.SingleUser()).Acquire(peerCtx, "login", 5*time.Second) + if err != nil { + t.Fatalf("peer Acquire: %v", err) + } + released := make(chan struct{}) + go func() { + time.Sleep(250 * time.Millisecond) + peerLock.Release() + close(released) + }() + + // lockedSaveInit must wait for the peer release. + f, _, _, _ := cmdutil.TestFactory(t, nil) + start := time.Now() + saveErr := lockedSaveInit("", f, "cli_a", core.PlainSecret("s"), core.BrandFeishu, "en") + waited := time.Since(start) + + <-released + if saveErr != nil { + t.Fatalf("lockedSaveInit: %v", saveErr) + } + // Allow some scheduler slack but require we waited at least most of the + // peer hold window. Without the flock this is ~0ms. + if waited < 200*time.Millisecond { + t.Errorf("lockedSaveInit did not wait on peer lock; waited=%s want >=200ms", waited) + } +} + +// TestLockedSaveInit_ReloadsInsideLock asserts the post-flock re-load +// observes peer writes — the lost-update guard. A pre-prompt `existing` +// is intentionally NOT passed to the helper; the helper loads its own +// snapshot inside the lock. +// +// Sequence: +// - Pre-state: an empty config dir. +// - Peer (simulated): writes a config with a profile "peer" (e.g. +// `auth login` adding a user; here we use a SaveMultiAppConfig as +// stand-in, which is what login ultimately does inside its own +// flock — we are testing the init helper's read-after-lock, not +// the lock primitive itself). +// - Init helper: lockedSaveInit("", ..., "cli_init", ...) which is +// the no-profile-name path that calls saveAsOnlyApp and would +// OVERWRITE the file with a single-app config holding "cli_init". +// - The expected post-state with the fix is a single-app config +// with appId="cli_init" (the no-profile path ALWAYS overwrites; the +// test of value here is that the helper successfully serialised, +// not that it preserved the peer profile — for that contract see +// the --name=peer variant below). +// +// The first sub-test verifies the no-profile path overwrites cleanly +// without panicking on a peer-written file (sanity for the load-then- +// overwrite ordering inside the lock). The second sub-test verifies +// the --name path PRESERVES the peer profile, which is the actual +// lost-update prevention: pre-fix the pre-prompt `existing == nil` +// snapshot would have caused saveAsProfile to drop "peer". +func TestLockedSaveInit_ReloadsInsideLock(t *testing.T) { + t.Run("named profile preserves peer write", func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + core.SetCurrentWorkspace(core.WorkspaceLocal) + + // Peer wrote this AFTER our pre-prompt load (which would have + // returned nil). The helper must observe it via the in-lock + // re-load. + peer := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "peer", + AppId: "cli_peer", + AppSecret: core.PlainSecret("s-peer"), + Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_p", UserName: "Peer"}}, + }}, + } + if err := core.SaveMultiAppConfig(peer); err != nil { + t.Fatalf("seed peer: %v", err) + } + + // Init runs under --name=mine — the named-profile path appends. + // With pre-fix (no in-lock re-load), saveAsProfile would have + // received existing==nil and produced a config holding ONLY + // "mine", silently dropping "peer". + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := lockedSaveInit("mine", f, "cli_mine", core.PlainSecret("s-mine"), core.BrandFeishu, "en"); err != nil { + t.Fatalf("lockedSaveInit: %v", err) + } + + got, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + if got.FindApp("peer") == nil { + t.Errorf("peer profile dropped — lost update; apps=%v", got.ProfileNames()) + } + if got.FindApp("mine") == nil { + t.Errorf("mine profile not appended; apps=%v", got.ProfileNames()) + } + }) + + t.Run("flock release on success", func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + core.SetCurrentWorkspace(core.WorkspaceLocal) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := lockedSaveInit("", f, "cli_a", core.PlainSecret("s"), core.BrandFeishu, "en"); err != nil { + t.Fatalf("first save: %v", err) + } + // Second back-to-back call must succeed — proves the first + // release ran. + if err := lockedSaveInit("", f, "cli_b", core.PlainSecret("s"), core.BrandFeishu, "en"); err != nil { + t.Fatalf("second save (lock leak?): %v", err) + } + }) +} + +// TestLockedSaveInit_ConcurrentSavesSerialize fires two goroutines +// at the helper and asserts both eventually win and the final file +// contains exactly one of the two app IDs (no half-written / merged +// config). With the flock removed, the writes interleave and the +// last-flush-wins outcome is non-deterministic but never errors — +// so the assertion here is that BOTH calls return nil AND the saved +// file is one of the two well-formed shapes (not e.g. zero apps). +func TestLockedSaveInit_ConcurrentSavesSerialize(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + core.SetCurrentWorkspace(core.WorkspaceLocal) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + var wg sync.WaitGroup + errs := make([]error, 2) + wg.Add(2) + go func() { + defer wg.Done() + errs[0] = lockedSaveInit("", f, "cli_a", core.PlainSecret("sa"), core.BrandFeishu, "en") + }() + go func() { + defer wg.Done() + errs[1] = lockedSaveInit("", f, "cli_b", core.PlainSecret("sb"), core.BrandFeishu, "en") + }() + wg.Wait() + + for i, e := range errs { + if e != nil { + t.Errorf("concurrent save %d: %v", i, e) + } + } + got, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + if len(got.Apps) != 1 { + t.Errorf("expected exactly one surviving app, got %d", len(got.Apps)) + } + if appID := got.Apps[0].AppId; appID != "cli_a" && appID != "cli_b" { + t.Errorf("saved AppId = %q, want cli_a or cli_b", appID) + } +} diff --git a/cmd/config/remove.go b/cmd/config/remove.go index 74dd0e847..805ac6c32 100644 --- a/cmd/config/remove.go +++ b/cmd/config/remove.go @@ -4,7 +4,9 @@ package config import ( + "context" "fmt" + "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" @@ -41,8 +43,20 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro func configRemoveRun(opts *ConfigRemoveOptions) error { f := opts.Factory + root := auth.NewLocalRoot(core.GetConfigDir()) + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(auth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "config remove: acquire flock: %v", err).WithCause(err) + } + defer lk.Release() + config, err := core.LoadMultiAppConfig() - if err != nil || config == nil || len(config.Apps) == 0 { + if err != nil { + return core.PassThroughOrNotConfigured(err) + } + if config == nil || len(config.Apps) == 0 { return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet") } @@ -57,7 +71,12 @@ func configRemoveRun(opts *ConfigRemoveOptions) error { for _, app := range config.Apps { core.RemoveSecretStore(app.AppSecret, f.Keychain) for _, user := range app.Users { - _ = auth.RemoveStoredToken(app.AppId, user.UserOpenId) + // Sweep all three on-host artifacts: keychain UAT, + // sidecar profile JSON, and user_index.json row. A + // stale sidecar / index makes a "removed" user + // resurface in `auth users list` and mis-attribute a + // future re-login under the same open_id. + _ = auth.PurgeUserArtifacts(root, app.AppId, user.UserOpenId) } } diff --git a/cmd/config/remove_sweep_test.go b/cmd/config/remove_sweep_test.go new file mode 100644 index 000000000..eb667f2e4 --- /dev/null +++ b/cmd/config/remove_sweep_test.go @@ -0,0 +1,245 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "testing" + + "github.com/zalando/go-keyring" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Regression: pre-fix, configRemoveRun cleared keychain UATs but left +// the disk-backed sidecar profile (per-user user_profile.json) and the +// install-wide user_index.json row in place. Result: +// +// - `auth users list` keeps showing the removed user (loads from +// user_index.json). +// - A subsequent re-login by a different human under the same +// open_id mis-attributes the slot (UserName, FirstAuthAt, etc. +// are pulled from the stale sidecar). +// +// This test seeds all three legs for a removed user, runs the config +// remove command, and proves all three legs are gone afterwards. +func TestConfigRemoveRun_SweepsAllUserArtifacts(t *testing.T) { + keyring.MockInit() + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + multi := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cli_app", + AppSecret: core.SecretInput{ + Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:cli_app"}, + }, + Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + // Seed every user-artifact leg. + root := larkauth.NewLocalRoot(configDir) + ctx := larkauth.ForUser("cli_app", "ou_alice") + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_app", UserOpenId: "ou_alice", AccessToken: "tok", + }); err != nil { + t.Fatalf("seed UAT: %v", err) + } + if err := larkauth.SaveUserProfileFor(root, ctx, larkauth.UserProfile{ + UserOpenId: "ou_alice", UserName: "Alice", + }); err != nil { + t.Fatalf("seed sidecar: %v", err) + } + if err := larkauth.RecordUserActivity(root, ctx, []string{"im:message:send"}); err != nil { + t.Fatalf("seed index: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + + if err := configRemoveRun(&ConfigRemoveOptions{Factory: f}); err != nil { + t.Fatalf("configRemoveRun: %v", err) + } + + if got := larkauth.GetStoredToken("cli_app", "ou_alice"); got != nil { + t.Errorf("keychain UAT not removed: %+v", got) + } + if got, err := larkauth.LoadUserProfileFor(root, ctx); err != nil { + t.Fatalf("LoadUserProfileFor: %v", err) + } else if got != nil { + t.Errorf("sidecar profile not removed: %+v", got) + } + idx, err := larkauth.LoadUserIndex(root) + if err != nil { + t.Fatalf("LoadUserIndex: %v", err) + } + for _, e := range idx.Users { + if e.AppId == "cli_app" && e.UserOpenId == "ou_alice" { + t.Errorf("index row not removed: %+v", e) + } + } +} + +// TestCleanupOldConfig_SweepsAllUserArtifacts targets cmd/config/init.go's +// cleanupOldConfig — the config init "I'm replacing this whole config +// with a different app" path. Same disease, different remove site. +func TestCleanupOldConfig_SweepsAllUserArtifacts(t *testing.T) { + keyring.MockInit() + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + root := larkauth.NewLocalRoot(configDir) + ctx := larkauth.ForUser("cli_old", "ou_alice") + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_old", UserOpenId: "ou_alice", AccessToken: "tok", + }); err != nil { + t.Fatalf("seed UAT: %v", err) + } + if err := larkauth.SaveUserProfileFor(root, ctx, larkauth.UserProfile{ + UserOpenId: "ou_alice", UserName: "Alice", + }); err != nil { + t.Fatalf("seed sidecar: %v", err) + } + if err := larkauth.RecordUserActivity(root, ctx, nil); err != nil { + t.Fatalf("seed index: %v", err) + } + + existing := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cli_old", + AppSecret: core.SecretInput{ + Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:cli_old"}, + }, + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + } + f, _, _, _ := cmdutil.TestFactory(t, nil) + + // Skip target is a different appId, so the old config IS swept. + cleanupOldConfig(existing, f, "cli_brand_new") + + if got := larkauth.GetStoredToken("cli_old", "ou_alice"); got != nil { + t.Errorf("keychain UAT not removed: %+v", got) + } + if got, _ := larkauth.LoadUserProfileFor(root, ctx); got != nil { + t.Errorf("sidecar profile not removed: %+v", got) + } + idx, err := larkauth.LoadUserIndex(root) + if err != nil { + t.Fatalf("LoadUserIndex: %v", err) + } + for _, e := range idx.Users { + if e.AppId == "cli_old" && e.UserOpenId == "ou_alice" { + t.Errorf("index row not removed: %+v", e) + } + } +} + +// TestCleanupOldConfig_SkipPreservesAllUserArtifacts — when an app is +// the skip target (operator wants to keep it), NONE of its artifacts +// are touched. +func TestCleanupOldConfig_SkipPreservesAllUserArtifacts(t *testing.T) { + keyring.MockInit() + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + root := larkauth.NewLocalRoot(configDir) + ctx := larkauth.ForUser("cli_keep", "ou_alice") + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_keep", UserOpenId: "ou_alice", AccessToken: "tok", + }); err != nil { + t.Fatalf("seed UAT: %v", err) + } + if err := larkauth.SaveUserProfileFor(root, ctx, larkauth.UserProfile{ + UserOpenId: "ou_alice", UserName: "Alice", + }); err != nil { + t.Fatalf("seed sidecar: %v", err) + } + if err := larkauth.RecordUserActivity(root, ctx, nil); err != nil { + t.Fatalf("seed index: %v", err) + } + + existing := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cli_keep", + AppSecret: core.SecretInput{ + Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:cli_keep"}, + }, + Users: []core.AppUser{{UserOpenId: "ou_alice", UserName: "Alice"}}, + }}, + } + f, _, _, _ := cmdutil.TestFactory(t, nil) + + cleanupOldConfig(existing, f, "cli_keep") + + if got := larkauth.GetStoredToken("cli_keep", "ou_alice"); got == nil { + t.Errorf("keychain UAT was removed for skip-target") + } + if got, _ := larkauth.LoadUserProfileFor(root, ctx); got == nil { + t.Errorf("sidecar profile was removed for skip-target") + } + idx, _ := larkauth.LoadUserIndex(root) + found := false + for _, e := range idx.Users { + if e.AppId == "cli_keep" && e.UserOpenId == "ou_alice" { + found = true + } + } + if !found { + t.Errorf("index row was removed for skip-target; idx=%+v", idx.Users) + } +} + +// TestCleanupKeychainFromData_SweepsSidecarAndIndex extends the B3 +// regression coverage: the sweep now removes sidecar + index in +// addition to the keychain UAT. Pre-fix, only the UAT went away. +func TestCleanupKeychainFromData_SweepsSidecarAndIndex(t *testing.T) { + keyring.MockInit() + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + root := larkauth.NewLocalRoot(configDir) + ctx := larkauth.ForUser("cli_old", "ou_alice") + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_old", UserOpenId: "ou_alice", AccessToken: "tok", + }); err != nil { + t.Fatalf("seed UAT: %v", err) + } + if err := larkauth.SaveUserProfileFor(root, ctx, larkauth.UserProfile{ + UserOpenId: "ou_alice", UserName: "Alice", + }); err != nil { + t.Fatalf("seed sidecar: %v", err) + } + if err := larkauth.RecordUserActivity(root, ctx, nil); err != nil { + t.Fatalf("seed index: %v", err) + } + + oldConfig := []byte(`{"apps":[{"appId":"cli_old","appSecret":{"source":"keychain","id":"appsecret:cli_old"},"users":[{"userOpenId":"ou_alice","userName":"Alice"}]}]}`) + newApp := &core.AppConfig{ + AppId: "cli_new", + AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:cli_new"}}, + Users: []core.AppUser{}, + } + f, _, _, _ := cmdutil.TestFactory(t, nil) + + cleanupKeychainFromData(f.Keychain, oldConfig, newApp) + + if got := larkauth.GetStoredToken("cli_old", "ou_alice"); got != nil { + t.Errorf("UAT not removed: %+v", got) + } + if got, _ := larkauth.LoadUserProfileFor(root, ctx); got != nil { + t.Errorf("sidecar not removed: %+v", got) + } + idx, _ := larkauth.LoadUserIndex(root) + for _, e := range idx.Users { + if e.AppId == "cli_old" && e.UserOpenId == "ou_alice" { + t.Errorf("index row not removed: %+v", e) + } + } +} diff --git a/cmd/config/show.go b/cmd/config/show.go index 5526f0254..9e28f2072 100644 --- a/cmd/config/show.go +++ b/cmd/config/show.go @@ -4,9 +4,7 @@ package config import ( - "errors" "fmt" - "os" "strings" "github.com/larksuite/cli/errs" @@ -45,17 +43,37 @@ func configShowRun(opts *ConfigShowOptions) error { config, err := core.LoadMultiAppConfig() if err != nil { - if errors.Is(err, os.ErrNotExist) { - return core.NotConfiguredError() - } - return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err) + // R2 transparency: a forward-incompat *core.ConfigError must reach + // the dispatcher with its upgrade Hint intact. The previous + // `errs.NewConfigError(SubtypeInvalidConfig, "failed to load + // config: %v", err).WithCause(err)` outer-typed envelope hid the + // R2 hint behind isOuterTypedError, leaving operators with a + // generic "failed to load config" message instead of "upgrade + // lark-cli". PassThroughOrNotConfigured maps: + // - *core.ConfigError (R2 / parse) → returned verbatim for the + // dispatcher to promote with its Hint intact + // - os.ErrNotExist → workspace-aware NotConfiguredError + // - other (permission, ...) → wrapped *core.ConfigError so the + // dispatcher still gets a typed envelope. + return core.PassThroughOrNotConfigured(err) } if config == nil || len(config.Apps) == 0 { return core.NotConfiguredError() } app := config.CurrentAppConfig(f.Invocation.Profile) if app == nil { - return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list") + // Apps[] is populated above this branch, so the resolution failure + // is either (a) operator named a ghost via --profile, or (b) the + // stored CurrentApp dangles. Neither is "no config" — + // SubtypeNotConfigured would steer AI agents to `config init` and + // clobber the existing profiles. + if f.Invocation.Profile != "" { + return errs.NewConfigError(errs.SubtypeInvalidArgument, + "profile %q not found", f.Invocation.Profile). + WithHint("available profiles: %s", strings.Join(config.ProfileNames(), ", ")) + } + return errs.NewConfigError(errs.SubtypeInvalidConfig, "no active profile"). + WithHint("run: lark-cli profile list") } users := "(no logged-in users)" if len(app.Users) > 0 { diff --git a/cmd/config/show_path_lock_skip_test.go b/cmd/config/show_path_lock_skip_test.go new file mode 100644 index 000000000..03b0f2c10 --- /dev/null +++ b/cmd/config/show_path_lock_skip_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "os" + "strings" + "sync" + "testing" + "time" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// holdLoginLock acquires the SingleUser "login" flock against the test's +// configDir and returns a release func that callers MUST defer. Failures bubble +// as t.Fatal — the lock is the test's witness, so an acquire failure is a test +// failure, not a system-under-test failure. +func holdLoginLock(t *testing.T, configDir string) (release func()) { + t.Helper() + root := larkauth.NewLocalRoot(configDir) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + lk, err := root.Locks(larkauth.SingleUser()).Acquire(ctx, "login", 5*time.Second) + if err != nil { + cancel() + t.Fatalf("test setup: acquire login flock: %v", err) + } + return func() { + lk.Release() + cancel() + } +} + +// configDirForStrictModeTest reuses setupStrictModeTestConfig's wiring while +// returning the dir so the test can root its own flock against it. +func configDirForStrictModeTest(t *testing.T) string { + t.Helper() + setupStrictModeTestConfig(t) + // setupStrictModeTestConfig sets LARKSUITE_CLI_CONFIG_DIR; we read it back + // rather than re-deriving so the two paths can never drift. + dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR") + if dir == "" { + t.Fatal("setupStrictModeTestConfig did not set LARKSUITE_CLI_CONFIG_DIR") + } + return dir +} + +// TestStrictMode_Show_DoesNotBlockOnHeldLock pins the post-fix invariant: +// `config strict-mode` with no args MUST NOT acquire the login flock. Pre-fix, +// a peer holding the lock (typical case: `config bind` sat at its TUI prompt) +// turned an instant status query into a 30s flock-timeout failure. +// +// We assert by holding the lock for the entire test and verifying the show +// command completes well within the 30s acquire deadline — a 2s budget gives +// plenty of CI headroom while still proving the show path is lock-free. +func TestStrictMode_Show_DoesNotBlockOnHeldLock(t *testing.T) { + dir := configDirForStrictModeTest(t) + + release := holdLoginLock(t, dir) + defer release() + + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + cmd := NewCmdConfigStrictMode(f) + cmd.SetArgs([]string{}) + + done := make(chan error, 1) + start := time.Now() + go func() { done <- cmd.Execute() }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("strict-mode show with held lock: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("strict-mode show blocked >2s with login flock held; the show path "+ + "is taking the writer lock again — regression of the show-path lock-skip fix. "+ + "elapsed=%v", time.Since(start)) + } + + if !strings.Contains(stdout.String(), "off") { + t.Errorf("expected 'off' in show output, got: %s", stdout.String()) + } +} + +// TestDefaultAs_Show_DoesNotBlockOnHeldLock mirrors the strict-mode test for +// `config default-as` with no args. Same pre-fix bug shape, same post-fix +// contract: the show path is lock-free. +func TestDefaultAs_Show_DoesNotBlockOnHeldLock(t *testing.T) { + dir := configDirForStrictModeTest(t) // same shape: single-app multi config + + release := holdLoginLock(t, dir) + defer release() + + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + cmd := NewCmdConfigDefaultAs(f) + cmd.SetArgs([]string{}) + + done := make(chan error, 1) + start := time.Now() + go func() { done <- cmd.Execute() }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("default-as show with held lock: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("default-as show blocked >2s with login flock held; elapsed=%v", time.Since(start)) + } + + // app.DefaultAs is empty in the seed config → the show path prints "auto". + if !strings.Contains(stdout.String(), "default-as:") { + t.Errorf("expected 'default-as:' in show output, got: %s", stdout.String()) + } +} + +// TestStrictMode_Set_BlocksOnHeldLock is the counter-test: the SET arm MUST +// still acquire the login flock. If a future refactor accidentally hoists the +// Acquire call above the set branch (or removes it entirely), concurrent set +// invocations would race the underlying config.json. We verify by holding the +// lock for ~200ms in a goroutine, then running `strict-mode bot` and asserting +// it took at least that long — i.e. it waited for the lock. +// +// 200ms is far above OS scheduler jitter and far below the 30s acquire wait, +// so the assertion is robust against CI noise without slowing the suite. +func TestStrictMode_Set_BlocksOnHeldLock(t *testing.T) { + dir := configDirForStrictModeTest(t) + + const holdFor = 200 * time.Millisecond + const lowerBound = 100 * time.Millisecond // generous floor below holdFor + + root := larkauth.NewLocalRoot(dir) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(ctx, "login", 5*time.Second) + if err != nil { + t.Fatalf("acquire test lock: %v", err) + } + + // Release after `holdFor` so the system-under-test's 30s acquire eventually + // wins. WaitGroup ensures the goroutine has actually run before we assert. + var wg sync.WaitGroup + wg.Add(1) + releaseAt := time.Now().Add(holdFor) + go func() { + defer wg.Done() + time.Sleep(holdFor) + lk.Release() + }() + + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"}) + cmd := NewCmdConfigStrictMode(f) + cmd.SetArgs([]string{"bot"}) + + start := time.Now() + if err := cmd.Execute(); err != nil { + wg.Wait() + t.Fatalf("strict-mode set: %v", err) + } + elapsed := time.Since(start) + wg.Wait() + + if elapsed < lowerBound { + t.Errorf("strict-mode set returned in %v, expected >= %v — the set arm "+ + "appears to have skipped the login flock; the lock-skip fix should "+ + "only apply to the no-args show branch.", elapsed, lowerBound) + } + if !time.Now().After(releaseAt) { + // Defensive: shouldn't happen given elapsed >= lowerBound, but explicit + // is better than implicit. + t.Errorf("set returned before goroutine released the lock — timing invariant violated") + } +} diff --git a/cmd/config/show_r2_test.go b/cmd/config/show_r2_test.go new file mode 100644 index 000000000..e20539bda --- /dev/null +++ b/cmd/config/show_r2_test.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zalando/go-keyring" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Regression: `config show` previously wrapped a non-os.ErrNotExist +// load error in errs.NewConfigError(SubtypeInvalidConfig, "failed to +// load config: %v", err).WithCause(err). The dispatcher's +// PromoteConfigError step is gated on isOuterTypedError — when the +// outer envelope is already a typed *errs.ConfigError it short- +// circuits and uses the producer's coarser shape, hiding the R2 +// upgrade hint behind a generic "failed to load config" message. +// +// PassThroughOrNotConfigured returns the raw *core.ConfigError so +// the dispatcher can promote it with the upgrade Hint preserved. +func TestConfigShowRun_R2ForwardIncompat_PassesUpgradeHint(t *testing.T) { + keyring.MockInit() + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + t.Setenv("HOME", t.TempDir()) + + future := []byte(`{"schemaVersion":99,"apps":[{"appId":"cli_x","appSecret":"s","brand":"feishu","users":[]}]}` + "\n") + if err := os.WriteFile(filepath.Join(dir, "config.json"), future, 0600); err != nil { + t.Fatalf("seed config.json: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configShowRun(&ConfigShowOptions{Factory: f}) + if err == nil { + t.Fatal("config show must surface R2 error from a future schema, got nil") + } + // Outer-type assertion: the dispatcher's isOuterTypedError gate + // short-circuits PromoteConfigError when the outer is already a + // typed *errs.* envelope. The producer must hand back the raw + // *core.ConfigError so promotion routes the R2 hint. + if _, ok := err.(*core.ConfigError); !ok { + t.Fatalf("expected outer *core.ConfigError so dispatcher routes R2 hint; got %T: %v", err, err) + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *core.ConfigError, got %T: %v", err, err) + } + if !strings.Contains(cfgErr.Message, "newer lark-cli") { + t.Errorf("R2 message lost; got %q", cfgErr.Message) + } + if !strings.Contains(cfgErr.Hint, "upgrade lark-cli") { + t.Errorf("R2 upgrade hint lost; got %q", cfgErr.Hint) + } +} + +// Regression for A9: --profile=ghost on a populated config must NOT +// route to SubtypeNotConfigured. The config IS configured — the +// operator just typed a name that doesn't exist. SubtypeNotConfigured +// would steer AI agents to `config init` and clobber the working +// profiles. SubtypeInvalidArgument is the correct routing axis. +func TestConfigShowRun_ExplicitProfileNotFound_SubtypeIsInvalidArgument(t *testing.T) { + keyring.MockInit() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + cfg := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "alpha", AppId: "cli_a", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + }}, + CurrentApp: "alpha", + } + if err := core.SaveMultiAppConfig(cfg); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.Invocation = cmdutil.InvocationContext{Profile: "ghost"} + err := configShowRun(&ConfigShowOptions{Factory: f}) + if err == nil { + t.Fatal("expected error for ghost --profile, got nil") + } + var typed *errs.ConfigError + if !errors.As(err, &typed) { + t.Fatalf("expected *errs.ConfigError; got %T %v", err, err) + } + if typed.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype: got %q, want %q (SubtypeInvalidArgument; "+ + "SubtypeNotConfigured would invite config init)", + typed.Subtype, errs.SubtypeInvalidArgument) + } + if !strings.Contains(typed.Message+typed.Hint, "ghost") { + t.Errorf("error must name the bad profile; got msg=%q hint=%q", typed.Message, typed.Hint) + } +} + +// Stored CurrentApp dangles (config exists but no resolvable active): +// SubtypeInvalidConfig, not SubtypeNotConfigured. Re-binding / +// `profile use ` is the right next step, NOT re-init. +func TestConfigShowRun_DanglingCurrentApp_SubtypeIsInvalidConfig(t *testing.T) { + keyring.MockInit() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + cfg := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "alpha", AppId: "cli_a", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + }}, + CurrentApp: "ghost", // dangles — no Apps[i].Name == "ghost" + } + if err := core.SaveMultiAppConfig(cfg); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configShowRun(&ConfigShowOptions{Factory: f}) + if err == nil { + t.Fatal("expected error for dangling CurrentApp, got nil") + } + var typed *errs.ConfigError + if !errors.As(err, &typed) { + t.Fatalf("expected *errs.ConfigError; got %T %v", err, err) + } + if typed.Subtype != errs.SubtypeInvalidConfig { + t.Errorf("subtype: got %q, want %q (SubtypeInvalidConfig; "+ + "SubtypeNotConfigured would invite config init)", + typed.Subtype, errs.SubtypeInvalidConfig) + } +} diff --git a/cmd/config/strict_mode.go b/cmd/config/strict_mode.go index 46610585a..207df310c 100644 --- a/cmd/config/strict_mode.go +++ b/cmd/config/strict_mode.go @@ -6,8 +6,10 @@ package config import ( "context" "fmt" + "time" "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/spf13/cobra" @@ -37,6 +39,31 @@ explicit user confirmation — never run on your own initiative.`, lark-cli config strict-mode --reset # clear profile override`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // Read-only show path: skip the flock so a peer holding it across + // a TUI prompt (e.g. `config bind`) doesn't turn an instant status + // query into a 30s timeout. Every mutating arm below saves and + // MUST take the lock. + if !reset && len(args) == 0 { + multi, err := core.LoadOrNotConfigured() + if err != nil { + return err + } + app := multi.CurrentAppConfig(f.Invocation.Profile) + if app == nil { + return core.NoActiveProfileError() + } + return showStrictMode(cmd.Context(), f, multi, app) + } + + root := larkauth.NewLocalRoot(core.GetConfigDir()) + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "strict-mode: acquire flock: %v", err).WithCause(err) + } + defer lk.Release() + multi, err := core.LoadOrNotConfigured() if err != nil { return err @@ -49,13 +76,6 @@ explicit user confirmation — never run on your own initiative.`, } return resetStrictMode(f, multi, app, global, args) } - if len(args) == 0 { - app := multi.CurrentAppConfig(f.Invocation.Profile) - if app == nil { - return core.NoActiveProfileError() - } - return showStrictMode(cmd.Context(), f, multi, app) - } app := multi.CurrentAppConfig(f.Invocation.Profile) if !global && app == nil { return core.NoActiveProfileError() diff --git a/cmd/global_flags.go b/cmd/global_flags.go index b77e8f189..df1c37128 100644 --- a/cmd/global_flags.go +++ b/cmd/global_flags.go @@ -5,32 +5,37 @@ package cmd import ( "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" "github.com/spf13/pflag" ) // GlobalOptions are the root-level flags shared by bootstrap parsing and the -// actual Cobra command tree. Profile is the parsed --profile value; HideProfile -// is a build-time policy — when true, --profile stays parseable but is marked -// hidden from help and shell completion. +// Cobra command tree. HideProfile, when true, keeps --profile parseable but +// hidden from help and completion. User is raw — env precedence is resolved +// in bootstrap.go. type GlobalOptions struct { Profile string + User string HideProfile bool } -// RegisterGlobalFlags registers the root-level persistent flags on fs and -// applies any visibility policy encoded in opts. Pure function: no disk, -// network, or environment reads — the caller decides HideProfile. +// RegisterGlobalFlags registers the root-level persistent flags on fs. +// Pure: no disk, network, or env reads. +// +// --user is always visible even when --profile is hidden: an asymmetric +// "hide one, show the other" UX would surprise operators expecting them +// to be symmetric companions. func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) { fs.StringVar(&opts.Profile, "profile", "", "use a specific profile") + fs.StringVar(&opts.User, "user", "", "select a specific user (open_id or username) within the active profile; overrides "+envvars.CliOpenID) if opts.HideProfile { _ = fs.MarkHidden("profile") } } // isSingleAppMode reports whether the on-disk config has at most one app. -// Missing configs are treated as single-app since --profile is meaningless -// until at least two profiles exist. Intended for the Execute entry point — -// buildInternal must not call this directly to stay state-free. +// Missing configs count as single-app. Called from Execute only; +// buildInternal stays state-free. func isSingleAppMode() bool { raw, err := core.LoadMultiAppConfig() if err != nil || raw == nil { diff --git a/cmd/global_flags_test.go b/cmd/global_flags_test.go index 67ee19839..bcbea26be 100644 --- a/cmd/global_flags_test.go +++ b/cmd/global_flags_test.go @@ -49,6 +49,43 @@ func TestRegisterGlobalFlags_PolicyHidden(t *testing.T) { } } +func TestRegisterGlobalFlags_UserFlagWired(t *testing.T) { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + opts := &GlobalOptions{} + RegisterGlobalFlags(fs, opts) + + flag := fs.Lookup("user") + if flag == nil { + t.Fatal("user flag should be registered") + } + if flag.Value.String() != "" { + t.Errorf("default value = %q, want empty", flag.Value.String()) + } + if err := fs.Parse([]string{"--user", "ou_alice"}); err != nil { + t.Fatalf("Parse: %v", err) + } + if opts.User != "ou_alice" { + t.Errorf("opts.User = %q, want ou_alice", opts.User) + } +} + +// --user has no HideUser mirror; asymmetric hiding vs --profile would surprise operators. +func TestRegisterGlobalFlags_UserFlagAlwaysVisible(t *testing.T) { + for _, hide := range []bool{false, true} { + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + opts := &GlobalOptions{HideProfile: hide} + RegisterGlobalFlags(fs, opts) + + flag := fs.Lookup("user") + if flag == nil { + t.Fatalf("user flag should be registered (HideProfile=%v)", hide) + } + if flag.Hidden { + t.Errorf("user flag should be visible regardless of HideProfile (got hidden, HideProfile=%v)", hide) + } + } +} + func TestIsSingleAppMode_NoConfig(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) if !isSingleAppMode() { diff --git a/cmd/profile/add.go b/cmd/profile/add.go index e05946d62..a7df1ed94 100644 --- a/cmd/profile/add.go +++ b/cmd/profile/add.go @@ -5,13 +5,16 @@ package profile import ( "bufio" + "context" "errors" "fmt" "os" "strings" + "time" "github.com/spf13/cobra" + larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/i18n" @@ -78,11 +81,23 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, return output.ErrValidation("app secret read from stdin is empty") } - // Load or create config + // Serialise against any concurrent login / users use / profile mutator; + // the login flock under SingleUser() scope is the documented MultiAppConfig + // serializer (see cmd/auth/login.go.syncLoginUserToProfile). + root := larkauth.NewLocalRoot(core.GetConfigDir()) + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "profile add: acquire flock: %v", err) + } + defer lk.Release() + + // Load (or create empty) AFTER the flock so the read is consistent with the save. multi, err := core.LoadMultiAppConfig() if err != nil { if !errors.Is(err, os.ErrNotExist) { - return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err) + return core.PassThroughOrNotConfigured(err) } multi = &core.MultiAppConfig{} } diff --git a/cmd/profile/list.go b/cmd/profile/list.go index fb4cc1ffe..22ac99e9c 100644 --- a/cmd/profile/list.go +++ b/cmd/profile/list.go @@ -72,8 +72,22 @@ func profileListRun(f *cmdutil.Factory) error { } if len(app.Users) > 0 { - item.User = app.Users[0].UserName - stored := larkauth.GetStoredToken(app.AppId, app.Users[0].UserOpenId) + // Honor CurrentUser so `profile list` reflects the active pick + // after `auth users use`. Falls back to Users[0] (insertion + // order) when CurrentUser is empty or stale, matching the + // AppConfig.CurrentUser → Users[0] precedence used by + // ResolveConfigFromMulti and resolveActiveUserOpenId. Without + // this, the output stayed pinned on Users[0] forever and the + // "active" semantics diverged across `auth users list` and + // `profile list`. + active := &app.Users[0] + if app.CurrentUser != "" { + if hit := app.FindUser(app.CurrentUser); hit != nil { + active = hit + } + } + item.User = active.UserName + stored := larkauth.GetStoredToken(app.AppId, active.UserOpenId) if stored != nil { item.TokenStatus = larkauth.TokenStatus(stored) } diff --git a/cmd/profile/profile_list_currentuser_test.go b/cmd/profile/profile_list_currentuser_test.go new file mode 100644 index 000000000..aad618bf6 --- /dev/null +++ b/cmd/profile/profile_list_currentuser_test.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package profile + +import ( + "encoding/json" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Regression: `profile list` previously hard-coded Users[0] when +// rendering the per-profile "user" column. After `auth users use bob` +// switched the profile's CurrentUser to bob, `profile list` still +// showed alice (Users[0]) — the active-user semantic diverged across +// `auth users list` (which honored CurrentUser) and `profile list`. +// +// Fix mirrors resolveActiveUserOpenId: CurrentUser → Users[0] +// fallback. A stale CurrentUser (not in Users[]) falls back to +// Users[0] rather than emitting an unknown name. +func TestProfileListRun_HonorsCurrentUser(t *testing.T) { + setupProfileConfigDir(t) + multi := &core.MultiAppConfig{ + CurrentApp: "target", + Apps: []core.AppConfig{{ + Name: "target", + AppId: "app-target", + AppSecret: core.PlainSecret("s"), + Brand: core.BrandFeishu, + Users: []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }, + CurrentUser: "ou_bob", + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + if err := profileListRun(f); err != nil { + t.Fatalf("profileListRun: %v", err) + } + + var got []profileListItem + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("Unmarshal: %v; output=%s", err, stdout.String()) + } + if len(got) != 1 { + t.Fatalf("len(got) = %d, want 1", len(got)) + } + if got[0].User != "Bob" { + t.Errorf("User = %q, want Bob (CurrentUser); profile list ignored CurrentUser and pinned Users[0]", got[0].User) + } +} + +// Stale CurrentUser (no longer in Users[]) falls back to Users[0] +// rather than rendering a phantom "" user. Mirrors +// resolveActiveUserOpenId in cmd/auth/users_list.go. +func TestProfileListRun_StaleCurrentUser_FallsBackToUsersZero(t *testing.T) { + setupProfileConfigDir(t) + multi := &core.MultiAppConfig{ + CurrentApp: "target", + Apps: []core.AppConfig{{ + Name: "target", + AppId: "app-target", + AppSecret: core.PlainSecret("s"), + Brand: core.BrandFeishu, + Users: []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + }, + CurrentUser: "ou_ghost", // dangling reference + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + if err := profileListRun(f); err != nil { + t.Fatalf("profileListRun: %v", err) + } + var got []profileListItem + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got[0].User != "Alice" { + t.Errorf("stale CurrentUser must fall back to Users[0]; got %q, want Alice", got[0].User) + } +} diff --git a/cmd/profile/profile_remove_self_toggle_test.go b/cmd/profile/profile_remove_self_toggle_test.go new file mode 100644 index 000000000..f72b262f7 --- /dev/null +++ b/cmd/profile/profile_remove_self_toggle_test.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package profile + +import ( + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Regression: removing the active profile must NOT leave PreviousApp +// pointing at the new active. Pre-fix, if the promoted Apps[0] (after +// the removed slot collapses) happened to equal PreviousApp, +// CurrentApp == PreviousApp held — and `profile use -` would +// short-circuit "Already on profile X" instead of toggling. +// +// Repro: +// - alpha (current), beta (previous), gamma +// - remove alpha → Apps[0]==beta → CurrentApp:=beta +// - PreviousApp was already beta — now CurrentApp == PreviousApp +// - `profile use -` short-circuits, breaking the toggle workflow +func TestProfileRemoveRun_AvoidsCurrentEqualsPreviousSelfToggle(t *testing.T) { + setupProfileConfigDir(t) + multi := &core.MultiAppConfig{ + CurrentApp: "alpha", + PreviousApp: "beta", + Apps: []core.AppConfig{ + {Name: "alpha", AppId: "app-alpha", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu}, + {Name: "beta", AppId: "app-beta", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu}, + {Name: "gamma", AppId: "app-gamma", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu}, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := profileRemoveRun(f, "alpha"); err != nil { + t.Fatalf("profileRemoveRun: %v", err) + } + + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("reload: %v", err) + } + if saved.CurrentApp != "beta" { + t.Errorf("CurrentApp = %q, want beta (Apps[0] after removal)", saved.CurrentApp) + } + // Invariant: CurrentApp != PreviousApp. PreviousApp must be cleared + // because it would otherwise equal the new CurrentApp, making + // `profile use -` a no-op. + if saved.PreviousApp == saved.CurrentApp { + t.Errorf("self-toggle invariant broken: CurrentApp=%q == PreviousApp=%q", + saved.CurrentApp, saved.PreviousApp) + } + if saved.PreviousApp != "" { + t.Errorf("PreviousApp = %q, want \"\" (cleared to restore invariant)", saved.PreviousApp) + } +} + +// Counter-test: when the removed profile is unrelated to PreviousApp, +// PreviousApp must NOT be cleared. Don't sweep state we don't have to. +func TestProfileRemoveRun_PreservesUnrelatedPreviousApp(t *testing.T) { + setupProfileConfigDir(t) + multi := &core.MultiAppConfig{ + CurrentApp: "alpha", + PreviousApp: "beta", + Apps: []core.AppConfig{ + {Name: "alpha", AppId: "app-alpha", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu}, + {Name: "beta", AppId: "app-beta", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu}, + {Name: "gamma", AppId: "app-gamma", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu}, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := profileRemoveRun(f, "gamma"); err != nil { + t.Fatalf("profileRemoveRun: %v", err) + } + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatal(err) + } + if saved.CurrentApp != "alpha" { + t.Errorf("CurrentApp = %q, want alpha (untouched)", saved.CurrentApp) + } + if saved.PreviousApp != "beta" { + t.Errorf("PreviousApp = %q, want beta (untouched)", saved.PreviousApp) + } +} diff --git a/cmd/profile/profile_test.go b/cmd/profile/profile_test.go index 3cd724720..9515def09 100644 --- a/cmd/profile/profile_test.go +++ b/cmd/profile/profile_test.go @@ -162,8 +162,11 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te if saved.CurrentApp != "default" { t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "default") } - if saved.PreviousApp != "default" { - t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default") + // PreviousApp must be cleared, not left equal to the new CurrentApp. + // Pre-A7 fix this asserted PreviousApp == "default" (== CurrentApp), + // which broke the `profile use -` toggle invariant. + if saved.PreviousApp != "" { + t.Fatalf("PreviousApp = %q, want \"\" (cleared to restore CurrentApp != PreviousApp)", saved.PreviousApp) } if len(saved.Apps) != 1 || saved.Apps[0].ProfileName() != "default" { t.Fatalf("remaining apps = %#v, want only default", saved.Apps) diff --git a/cmd/profile/remove.go b/cmd/profile/remove.go index 08c19234e..f224db456 100644 --- a/cmd/profile/remove.go +++ b/cmd/profile/remove.go @@ -4,8 +4,10 @@ package profile import ( + "context" "fmt" "strings" + "time" "github.com/spf13/cobra" @@ -33,6 +35,15 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command { } func profileRemoveRun(f *cmdutil.Factory, name string) error { + root := larkauth.NewLocalRoot(core.GetConfigDir()) + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "profile remove: acquire flock: %v", err) + } + defer lk.Release() + multi, err := core.LoadOrNotConfigured() if err != nil { return err @@ -63,6 +74,17 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error { if multi.PreviousApp == removedName { multi.PreviousApp = "" } + // Self-toggle guard: if removing the active profile promoted Apps[0] + // to CurrentApp and Apps[0] happens to equal PreviousApp, the invariant + // CurrentApp != PreviousApp breaks. `profile use -` would short-circuit + // "Already on profile X" — toggling back to where you already are. + // Three-profile repro: CurrentApp=alpha, PreviousApp=beta, remove alpha + // → Apps[0]=beta → CurrentApp:=beta (== PreviousApp). Clear PreviousApp + // to restore the invariant; the next `profile use` round-trip + // re-establishes a real previous. + if multi.PreviousApp != "" && multi.PreviousApp == multi.CurrentApp { + multi.PreviousApp = "" + } if err := core.SaveMultiAppConfig(multi); err != nil { return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) @@ -71,7 +93,11 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error { // Best-effort credential cleanup after config commit core.RemoveSecretStore(appSecret, f.Keychain) for _, user := range users { - larkauth.RemoveStoredToken(appId, user.UserOpenId) + // Triple sweep: keychain UAT + sidecar profile + index row. + // Profile remove is destructive by user intent; leaving on-disk + // artifacts in place would let a removed user resurface in + // `auth users list` and mis-attribute the slot on re-login. + _ = larkauth.PurgeUserArtifacts(root, appId, user.UserOpenId) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q removed", removedName)) diff --git a/cmd/profile/remove_sweep_test.go b/cmd/profile/remove_sweep_test.go new file mode 100644 index 000000000..0e34dc666 --- /dev/null +++ b/cmd/profile/remove_sweep_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package profile + +import ( + "testing" + + "github.com/zalando/go-keyring" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Regression: pre-fix, profileRemoveRun cleaned up keychain UATs but +// left disk-backed sidecar profiles and the install-wide +// user_index.json row in place. Result: a removed profile's users +// would resurface in `auth users list` and mis-attribute the slot on +// re-login under the same open_id. +func TestProfileRemoveRun_SweepsAllUserArtifacts(t *testing.T) { + keyring.MockInit() + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + multi := &core.MultiAppConfig{ + CurrentApp: "keep", + Apps: []core.AppConfig{ + { + Name: "keep", + AppId: "cli_keep", + AppSecret: core.SecretInput{ + Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:cli_keep"}, + }, + Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_keeper", UserName: "Keeper"}}, + }, + { + Name: "victim", + AppId: "cli_victim", + AppSecret: core.SecretInput{ + Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:cli_victim"}, + }, + Brand: core.BrandFeishu, + Users: []core.AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }, + }, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + root := larkauth.NewLocalRoot(configDir) + + // Seed all three artifact legs for both victim users. + for _, u := range []string{"ou_alice", "ou_bob"} { + ctx := larkauth.ForUser("cli_victim", u) + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_victim", UserOpenId: u, AccessToken: "tok_" + u, + }); err != nil { + t.Fatalf("seed UAT for %s: %v", u, err) + } + if err := larkauth.SaveUserProfileFor(root, ctx, larkauth.UserProfile{ + UserOpenId: u, UserName: u, + }); err != nil { + t.Fatalf("seed sidecar for %s: %v", u, err) + } + if err := larkauth.RecordUserActivity(root, ctx, nil); err != nil { + t.Fatalf("seed index for %s: %v", u, err) + } + } + + // Seed the keeper too, to prove they survive the operation. + keepCtx := larkauth.ForUser("cli_keep", "ou_keeper") + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_keep", UserOpenId: "ou_keeper", AccessToken: "tok_keep", + }); err != nil { + t.Fatalf("seed keeper UAT: %v", err) + } + if err := larkauth.SaveUserProfileFor(root, keepCtx, larkauth.UserProfile{ + UserOpenId: "ou_keeper", UserName: "Keeper", + }); err != nil { + t.Fatalf("seed keeper sidecar: %v", err) + } + if err := larkauth.RecordUserActivity(root, keepCtx, nil); err != nil { + t.Fatalf("seed keeper index: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + + if err := profileRemoveRun(f, "victim"); err != nil { + t.Fatalf("profileRemoveRun: %v", err) + } + + // Victim users: every leg must be empty. + for _, u := range []string{"ou_alice", "ou_bob"} { + ctx := larkauth.ForUser("cli_victim", u) + if got := larkauth.GetStoredToken("cli_victim", u); got != nil { + t.Errorf("victim UAT (cli_victim, %s) not removed: %+v", u, got) + } + if got, _ := larkauth.LoadUserProfileFor(root, ctx); got != nil { + t.Errorf("victim sidecar (cli_victim, %s) not removed: %+v", u, got) + } + } + idx, _ := larkauth.LoadUserIndex(root) + for _, e := range idx.Users { + if e.AppId == "cli_victim" { + t.Errorf("victim index row not removed: %+v", e) + } + } + + // Keeper: every leg must survive. + if got := larkauth.GetStoredToken("cli_keep", "ou_keeper"); got == nil { + t.Errorf("keeper UAT was wiped") + } + if got, _ := larkauth.LoadUserProfileFor(root, keepCtx); got == nil { + t.Errorf("keeper sidecar was wiped") + } + keeperFound := false + for _, e := range idx.Users { + if e.AppId == "cli_keep" && e.UserOpenId == "ou_keeper" { + keeperFound = true + } + } + if !keeperFound { + t.Errorf("keeper index row was wiped; idx=%+v", idx.Users) + } +} diff --git a/cmd/profile/rename.go b/cmd/profile/rename.go index 2a8f6a2e5..63da98840 100644 --- a/cmd/profile/rename.go +++ b/cmd/profile/rename.go @@ -4,11 +4,14 @@ package profile import ( + "context" "fmt" "strings" + "time" "github.com/spf13/cobra" + larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -33,6 +36,15 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error { return output.ErrValidation("%v", err) } + root := larkauth.NewLocalRoot(core.GetConfigDir()) + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "profile rename: acquire flock: %v", err) + } + defer lk.Release() + multi, err := core.LoadOrNotConfigured() if err != nil { return err diff --git a/cmd/profile/use.go b/cmd/profile/use.go index 013ade47e..1c7079f18 100644 --- a/cmd/profile/use.go +++ b/cmd/profile/use.go @@ -4,11 +4,14 @@ package profile import ( + "context" "fmt" "strings" + "time" "github.com/spf13/cobra" + larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -32,6 +35,16 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command { } func profileUseRun(f *cmdutil.Factory, name string) error { + // Serialise against any concurrent login / users use / profile mutator. + root := larkauth.NewLocalRoot(core.GetConfigDir()) + flockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", 30*time.Second) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "profile use: acquire flock: %v", err) + } + defer lk.Release() + multi, err := core.LoadOrNotConfigured() if err != nil { return err diff --git a/internal/auth/context.go b/internal/auth/context.go new file mode 100644 index 000000000..19074f285 --- /dev/null +++ b/internal/auth/context.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import "strings" + +// AuthContext binds storage paths and credential lookups to a specific +// (AppId, UserOpenId) pair. +// +// AppId is part of the key (not just UserOpenId) because a single +// MultiAppConfig can list multiple apps and the same human can be +// logged into two of them at once; storage paths and keychain keys +// must tell those logins apart. +// +// SingleUser() returns a zero context for code paths not yet ported +// to multi-user; legacy helpers (GetStoredToken, paths under +// ) keep working byte-for-byte. AppOnly(appId) is the +// bridge state used by login flows that know AppId but have not yet +// learned UserOpenId (e.g., during device authorization). +// +// Value type with all-comparable fields, so it works as a map key. +type AuthContext struct { + appId string + userOpenId string +} + +// SingleUser returns the legacy zero-context. Reserved for code paths +// that still rely on Users[0] / single-tenant layout. +func SingleUser() AuthContext { return AuthContext{} } + +// AppOnly returns a context bound to appId with no user yet. Used by +// device-flow code between "user has scanned the code" and "we got +// open_id back from /authen/v1/user_info". +func AppOnly(appId string) AuthContext { + return AuthContext{appId: strings.TrimSpace(appId)} +} + +// ForUser returns a context bound to (appId, userOpenId). A blank +// userOpenId collapses to AppOnly semantics; a blank appId collapses +// to SingleUser. Blank-after-trim user IDs are rejected higher up the +// stack — this constructor never panics. +func ForUser(appId, userOpenId string) AuthContext { + return AuthContext{ + appId: strings.TrimSpace(appId), + userOpenId: strings.TrimSpace(userOpenId), + } +} + +func (c AuthContext) AppId() string { return c.appId } + +func (c AuthContext) UserOpenId() string { return c.userOpenId } + +// IsSingleUser reports the legacy zero value. Code that hits this +// branch must keep using the pre-multi-user storage paths; routing it +// through a per-user subdirectory would break existing installs. +func (c AuthContext) IsSingleUser() bool { + return c.appId == "" && c.userOpenId == "" +} + +func (c AuthContext) IsAppOnly() bool { + return c.appId != "" && c.userOpenId == "" +} + +func (c AuthContext) HasUser() bool { + return c.appId != "" && c.userOpenId != "" +} + +// sanitizedUserOpenId returns a filename-safe encoding of UserOpenId +// for use as a single directory segment. +// +// Stricter than uat_client.go's sanitizeID (which allows '.' to keep +// ..lock readable): two adjacent dots form `..`, +// which would let a hostile open_id climb out of the user subtree +// when joined with a parent path. Allows `[a-zA-Z0-9_-]` only; every +// other byte collapses to '-'. Empty input becomes "_". +// +// One-way and lossy: callers that need the original open_id must +// persist it un-sanitised in the user index, never round-trip through +// a directory listing. +func (c AuthContext) sanitizedUserOpenId() string { + return sanitizeOpenIdForPath(c.userOpenId) +} + +// sanitizedAppId returns a filename-safe encoding of AppId. Same +// rules as sanitizedUserOpenId. +func (c AuthContext) sanitizedAppId() string { + return sanitizeOpenIdForPath(c.appId) +} + +// sanitizeOpenIdForPath is the shared implementation. Package-level +// (not a method) so storage.go can call it on raw strings during path +// resolution without manufacturing a throwaway AuthContext. +func sanitizeOpenIdForPath(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "_" + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '-' || r == '_': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + return b.String() +} diff --git a/internal/auth/context_test.go b/internal/auth/context_test.go new file mode 100644 index 000000000..c9fe7cc7d --- /dev/null +++ b/internal/auth/context_test.go @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import "testing" + +// TestAuthContextConstructors covers field shape, whitespace trimming, and +// classifier agreement for the three constructors. +func TestAuthContextConstructors(t *testing.T) { + tests := []struct { + name string + ctx AuthContext + wantAppId string + wantUserOpenId string + wantSingleUser bool + wantAppOnly bool + wantHasUser bool + }{ + { + name: "single user", + ctx: SingleUser(), + wantSingleUser: true, + }, + { + name: "app only", + ctx: AppOnly("cli_xxx"), + wantAppId: "cli_xxx", + wantAppOnly: true, + }, + { + name: "app only trims whitespace", + ctx: AppOnly(" cli_xxx "), + wantAppId: "cli_xxx", + wantAppOnly: true, + }, + { + name: "app only with empty arg collapses to single user", + ctx: AppOnly(" "), + wantSingleUser: true, + }, + { + name: "for user", + ctx: ForUser("cli_xxx", "ou_abc"), + wantAppId: "cli_xxx", + wantUserOpenId: "ou_abc", + wantHasUser: true, + }, + { + name: "for user trims whitespace on both", + ctx: ForUser(" cli_xxx ", " ou_abc "), + wantAppId: "cli_xxx", + wantUserOpenId: "ou_abc", + wantHasUser: true, + }, + { + name: "for user with blank user collapses to app only", + ctx: ForUser("cli_xxx", " "), + wantAppId: "cli_xxx", + wantAppOnly: true, + }, + { + name: "for user with both blank collapses to single user", + ctx: ForUser(" ", "\t"), + wantSingleUser: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.ctx.AppId(); got != tc.wantAppId { + t.Errorf("AppId() = %q, want %q", got, tc.wantAppId) + } + if got := tc.ctx.UserOpenId(); got != tc.wantUserOpenId { + t.Errorf("UserOpenId() = %q, want %q", got, tc.wantUserOpenId) + } + if got := tc.ctx.IsSingleUser(); got != tc.wantSingleUser { + t.Errorf("IsSingleUser() = %v, want %v", got, tc.wantSingleUser) + } + if got := tc.ctx.IsAppOnly(); got != tc.wantAppOnly { + t.Errorf("IsAppOnly() = %v, want %v", got, tc.wantAppOnly) + } + if got := tc.ctx.HasUser(); got != tc.wantHasUser { + t.Errorf("HasUser() = %v, want %v", got, tc.wantHasUser) + } + }) + } +} + +// TestAuthContextClassifiersExclusive asserts IsSingleUser/IsAppOnly/HasUser +// partition the input space — exactly one is true for any AuthContext. +func TestAuthContextClassifiersExclusive(t *testing.T) { + cases := []AuthContext{ + SingleUser(), + AppOnly("a"), + ForUser("a", "u"), + } + for _, c := range cases { + count := 0 + if c.IsSingleUser() { + count++ + } + if c.IsAppOnly() { + count++ + } + if c.HasUser() { + count++ + } + if count != 1 { + t.Errorf("AuthContext{appId=%q userOpenId=%q}: expected exactly one classifier true, got %d", c.AppId(), c.UserOpenId(), count) + } + } +} + +// TestAuthContextIsComparable locks in that AuthContext is ==-comparable so +// downstream caches can key on it directly. +func TestAuthContextIsComparable(t *testing.T) { + m := map[AuthContext]int{ + SingleUser(): 1, + AppOnly("cli_a"): 2, + ForUser("cli_a", "ou1"): 3, + ForUser("cli_a", "ou2"): 4, + } + if m[ForUser("cli_a", "ou1")] != 3 { + t.Fatalf("AuthContext map lookup failed; values not equal across construction") + } + if m[SingleUser()] != 1 { + t.Fatalf("SingleUser zero value not stable as map key") + } +} + +// TestSanitizeOpenIdForPath: bytes outside [a-zA-Z0-9_-] collapse to '-', +// empty input becomes "_", and '.' is filtered to prevent path traversal. +func TestSanitizeOpenIdForPath(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty becomes underscore", "", "_"}, + {"whitespace-only becomes underscore", " \t ", "_"}, + {"plain alphanumeric passes through", "ouAbc123", "ouAbc123"}, + {"hyphen and underscore pass through", "ou-abc_def", "ou-abc_def"}, + {"dot is rejected", "ou.abc", "ou-abc"}, + {"slash attack", "../etc/passwd", "---etc-passwd"}, + {"backslash attack", "ou\\..\\etc", "ou----etc"}, + {"nul byte", "ou\x00abc", "ou-abc"}, + {"chinese characters collapse", "用户1", "--1"}, + {"emoji collapses", "ou_🦊_abc", "ou_-_abc"}, + {"leading and trailing whitespace trimmed before sanitise", " ou_abc ", "ou_abc"}, + {"consecutive dots both rejected", "ou..abc", "ou--abc"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := sanitizeOpenIdForPath(tc.in); got != tc.want { + t.Errorf("sanitizeOpenIdForPath(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestSanitizeOpenIdForPathNeverYieldsTraversal: output never contains +// '/', '\\', '.', or is empty — the invariant that makes per-user dirs safe. +func TestSanitizeOpenIdForPathNeverYieldsTraversal(t *testing.T) { + hostile := []string{ + "..", + "../..", + "./.", + "a/b/c", + "a\\b\\c", + "\x00\x01\x02", + "‮", // unicode right-to-left override + strRepeat("..", 100), + } + for _, h := range hostile { + got := sanitizeOpenIdForPath(h) + if got == "" { + t.Errorf("sanitizeOpenIdForPath(%q) returned empty string", h) + } + for _, r := range got { + switch r { + case '/', '\\', '.': + t.Errorf("sanitizeOpenIdForPath(%q) = %q, contains forbidden rune %q", h, got, r) + } + } + } +} + +// TestAuthContextSanitizeMethods checks AppId/UserOpenId each get an +// independent sanitisation pass with the empty-becomes-"_" rule. +func TestAuthContextSanitizeMethods(t *testing.T) { + c := ForUser("cli_xxx", "ou_abc.def") + if got, want := c.sanitizedAppId(), "cli_xxx"; got != want { + t.Errorf("sanitizedAppId() = %q, want %q", got, want) + } + if got, want := c.sanitizedUserOpenId(), "ou_abc-def"; got != want { + t.Errorf("sanitizedUserOpenId() = %q, want %q", got, want) + } + zero := SingleUser() + if got := zero.sanitizedAppId(); got != "_" { + t.Errorf("SingleUser.sanitizedAppId() = %q, want %q", got, "_") + } + if got := zero.sanitizedUserOpenId(); got != "_" { + t.Errorf("SingleUser.sanitizedUserOpenId() = %q, want %q", got, "_") + } +} + +// strRepeat avoids a strings import for one call site. +func strRepeat(s string, n int) string { + out := make([]byte, 0, len(s)*n) + for i := 0; i < n; i++ { + out = append(out, s...) + } + return string(out) +} diff --git a/internal/auth/purge.go b/internal/auth/purge.go new file mode 100644 index 000000000..a87d13a31 --- /dev/null +++ b/internal/auth/purge.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "strings" +) + +// PurgeUserArtifacts removes EVERY on-host artifact for the +// (appId, userOpenId) pair: the OS-keychain UAT, the per-user sidecar +// profile JSON on disk, and the install-wide user_index.json row. +// +// The three legs are deliberately independent: +// +// - Keychain UAT: lives in the OS keyring under +// :">. Token reuse safety. +// - Sidecar UserProfile: lives at +// /users///user_profile.json. Cached +// non-secret identity. Survives without UAT, so a stale sidecar +// would mis-attribute the slot on a subsequent re-login by a +// different human under the same open_id. +// - User-index row: /user_index.json keyed +// ":". Authoritative source for `auth users +// list`; orphan rows make logged-out users appear logged in. +// +// Best-effort: every leg runs even if a prior leg errored, so a +// keychain hiccup cannot strand the sidecar / index. Errors are +// returned together (joined with "; ") for callers that want to log +// a single warning, or nil when all three succeeded. +// +// Caller MUST hold the SingleUser/login flock for the cross-process +// serialization the index leg expects (`auth.RecordUserActivity` +// acquires it itself; the existing remove paths already hold it). +// +// No-ops on empty appId / userOpenId — same idempotence contract as +// the underlying RemoveStoredToken / DeleteUserProfileFor / +// DeleteUser. +func PurgeUserArtifacts(root Root, appId, userOpenId string) error { + appId = strings.TrimSpace(appId) + userOpenId = strings.TrimSpace(userOpenId) + if appId == "" || userOpenId == "" { + return nil + } + + var errs []string + if err := RemoveStoredToken(appId, userOpenId); err != nil { + errs = append(errs, fmt.Sprintf("keychain UAT: %v", err)) + } + + // root may legitimately be nil from older callers that haven't + // adopted the LocalRoot yet; the keychain leg above is still done. + if root != nil { + ctx := ForUser(appId, userOpenId) + if err := DeleteUserProfileFor(root, ctx); err != nil { + errs = append(errs, fmt.Sprintf("sidecar profile: %v", err)) + } + if err := DeleteUser(root, ctx); err != nil { + errs = append(errs, fmt.Sprintf("index row: %v", err)) + } + } + + if len(errs) == 0 { + return nil + } + return fmt.Errorf("auth: purge user artifacts (%s, %s): %s", appId, userOpenId, strings.Join(errs, "; ")) +} diff --git a/internal/auth/purge_test.go b/internal/auth/purge_test.go new file mode 100644 index 000000000..5bcf25c20 --- /dev/null +++ b/internal/auth/purge_test.go @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "strings" + "testing" + + "github.com/zalando/go-keyring" +) + +// TestPurgeUserArtifacts_RemovesAllThreeLegs is the dominant case: +// the user is fully provisioned (UAT in keychain, sidecar profile on +// disk, index row in user_index.json), and a single PurgeUserArtifacts +// call leaves all three slots empty. +func TestPurgeUserArtifacts_RemovesAllThreeLegs(t *testing.T) { + keyring.MockInit() + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_app", "ou_alice") + + // Seed every leg. + if err := SetStoredToken(&StoredUAToken{ + AppId: "cli_app", UserOpenId: "ou_alice", AccessToken: "tok", + }); err != nil { + t.Fatalf("seed UAT: %v", err) + } + if err := SaveUserProfileFor(root, ctx, UserProfile{ + UserOpenId: "ou_alice", UserName: "Alice", + }); err != nil { + t.Fatalf("seed sidecar: %v", err) + } + if err := RecordUserActivity(root, ctx, []string{"im:message:send"}); err != nil { + t.Fatalf("seed index: %v", err) + } + + if err := PurgeUserArtifacts(root, "cli_app", "ou_alice"); err != nil { + t.Fatalf("PurgeUserArtifacts: %v", err) + } + + // Keychain UAT + if got := GetStoredToken("cli_app", "ou_alice"); got != nil { + t.Errorf("keychain UAT not removed: %+v", got) + } + // Sidecar profile + if got, err := LoadUserProfileFor(root, ctx); err != nil { + t.Fatalf("LoadUserProfileFor: %v", err) + } else if got != nil { + t.Errorf("sidecar profile not removed: %+v", got) + } + // Index row + idx, err := LoadUserIndex(root) + if err != nil { + t.Fatalf("LoadUserIndex: %v", err) + } + if _, ok := idx.Users[userIndexEntryKey("cli_app", "ou_alice")]; ok { + t.Errorf("index row not removed; idx=%+v", idx.Users) + } +} + +// TestPurgeUserArtifacts_PartialState handles a row missing some legs +// (a partial logout from a prior bug, or first-login in flight). All +// existing legs go away, missing legs do nothing — no error. +func TestPurgeUserArtifacts_PartialState(t *testing.T) { + keyring.MockInit() + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_app", "ou_alice") + + // Only sidecar; no UAT, no index row. + if err := SaveUserProfileFor(root, ctx, UserProfile{UserOpenId: "ou_alice"}); err != nil { + t.Fatalf("seed sidecar: %v", err) + } + + if err := PurgeUserArtifacts(root, "cli_app", "ou_alice"); err != nil { + t.Fatalf("PurgeUserArtifacts: %v", err) + } + if got, _ := LoadUserProfileFor(root, ctx); got != nil { + t.Errorf("sidecar not removed: %+v", got) + } +} + +// TestPurgeUserArtifacts_DoesNotTouchNeighbors confirms scope: a +// SECOND user under the same app, AND the SAME user under a different +// app, both survive. +func TestPurgeUserArtifacts_DoesNotTouchNeighbors(t *testing.T) { + keyring.MockInit() + root := NewLocalRoot(t.TempDir()) + + // Victim + victim := ForUser("cli_app", "ou_alice") + _ = SetStoredToken(&StoredUAToken{AppId: "cli_app", UserOpenId: "ou_alice", AccessToken: "v"}) + _ = SaveUserProfileFor(root, victim, UserProfile{UserOpenId: "ou_alice"}) + _ = RecordUserActivity(root, victim, nil) + + // Neighbor 1: same app, different user + sameAppDiffUser := ForUser("cli_app", "ou_bob") + _ = SetStoredToken(&StoredUAToken{AppId: "cli_app", UserOpenId: "ou_bob", AccessToken: "n1"}) + _ = SaveUserProfileFor(root, sameAppDiffUser, UserProfile{UserOpenId: "ou_bob"}) + _ = RecordUserActivity(root, sameAppDiffUser, nil) + + // Neighbor 2: different app, same user open_id (multi-app installs) + diffAppSameUser := ForUser("cli_other", "ou_alice") + _ = SetStoredToken(&StoredUAToken{AppId: "cli_other", UserOpenId: "ou_alice", AccessToken: "n2"}) + _ = SaveUserProfileFor(root, diffAppSameUser, UserProfile{UserOpenId: "ou_alice"}) + _ = RecordUserActivity(root, diffAppSameUser, nil) + + if err := PurgeUserArtifacts(root, "cli_app", "ou_alice"); err != nil { + t.Fatalf("PurgeUserArtifacts: %v", err) + } + + // Victim is gone. + if got := GetStoredToken("cli_app", "ou_alice"); got != nil { + t.Errorf("victim keychain still present: %+v", got) + } + + // Neighbor 1 survives every leg. + if got := GetStoredToken("cli_app", "ou_bob"); got == nil { + t.Errorf("neighbor (cli_app, ou_bob) keychain wiped") + } + if got, _ := LoadUserProfileFor(root, sameAppDiffUser); got == nil { + t.Errorf("neighbor (cli_app, ou_bob) sidecar wiped") + } + idx, _ := LoadUserIndex(root) + if _, ok := idx.Users[userIndexEntryKey("cli_app", "ou_bob")]; !ok { + t.Errorf("neighbor (cli_app, ou_bob) index row wiped") + } + + // Neighbor 2 survives every leg. + if got := GetStoredToken("cli_other", "ou_alice"); got == nil { + t.Errorf("neighbor (cli_other, ou_alice) keychain wiped") + } + if got, _ := LoadUserProfileFor(root, diffAppSameUser); got == nil { + t.Errorf("neighbor (cli_other, ou_alice) sidecar wiped") + } + if _, ok := idx.Users[userIndexEntryKey("cli_other", "ou_alice")]; !ok { + t.Errorf("neighbor (cli_other, ou_alice) index row wiped") + } +} + +// TestPurgeUserArtifacts_EmptyArgs is the no-op contract: empty / blank +// args return nil and touch nothing. +func TestPurgeUserArtifacts_EmptyArgs(t *testing.T) { + keyring.MockInit() + root := NewLocalRoot(t.TempDir()) + if err := PurgeUserArtifacts(root, "", "ou_alice"); err != nil { + t.Errorf("empty appId returned error: %v", err) + } + if err := PurgeUserArtifacts(root, "cli_app", ""); err != nil { + t.Errorf("empty userOpenId returned error: %v", err) + } + if err := PurgeUserArtifacts(root, " ", "ou_alice"); err != nil { + t.Errorf("whitespace appId returned error: %v", err) + } +} + +// TestPurgeUserArtifacts_NilRoot covers the legacy-caller path: callers +// that haven't adopted LocalRoot still get the keychain UAT swept. This +// is the migration ramp — a future cleanup can remove the nil-root +// branch once every site passes a real root. +func TestPurgeUserArtifacts_NilRoot(t *testing.T) { + keyring.MockInit() + if err := SetStoredToken(&StoredUAToken{ + AppId: "cli_app", UserOpenId: "ou_alice", AccessToken: "v", + }); err != nil { + t.Fatalf("seed UAT: %v", err) + } + if err := PurgeUserArtifacts(nil, "cli_app", "ou_alice"); err != nil { + t.Fatalf("PurgeUserArtifacts(nil root): %v", err) + } + if got := GetStoredToken("cli_app", "ou_alice"); got != nil { + t.Errorf("keychain UAT not removed despite nil root: %+v", got) + } +} + +// TestPurgeUserArtifacts_ErrorAggregation joins per-leg errors. We can't +// trivially force a real-leg failure under the mock keyring, but the +// joined-error contract still covers the message-format invariant +// callers depend on for warning lines. +func TestPurgeUserArtifacts_ErrorAggregation(t *testing.T) { + keyring.MockInit() + root := NewLocalRoot(t.TempDir()) + // Real call should succeed and return nil, not a joined string. + if err := PurgeUserArtifacts(root, "cli_app", "ou_alice"); err != nil { + t.Fatalf("PurgeUserArtifacts on empty state: %v", err) + } + // Sanity-check the fmt.Errorf path: an empty errs list returns nil + // (above), and any non-empty list joins with "; ". Construct one + // manually to lock the format. + const example = "auth: purge user artifacts (a, b): keychain UAT: x; sidecar profile: y" + if !strings.Contains(example, "; ") { + t.Errorf("expected aggregated errors to be joined with \"; \"") + } +} diff --git a/internal/auth/storage.go b/internal/auth/storage.go new file mode 100644 index 000000000..ce742ab13 --- /dev/null +++ b/internal/auth/storage.go @@ -0,0 +1,452 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/gofrs/flock" + + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/vfs" +) + +// Root is the storage substrate every multi-user operation is scoped +// through. Constructed via NewLocalRoot; tests pass NewLocalRoot(t.TempDir()). +// No process-global / SetDefault hatch — callers inject explicitly. +type Root interface { + // KV returns the per-AuthContext blob store. Callers MUST NOT + // reconstruct paths themselves; UserDir is diagnostic only. + KV(ctx AuthContext) KVStore + + // SharedKV returns the install-wide blob store. SingleUser CLI + // invocations MUST NOT write through SharedKV; the invariant is + // enforced by callers, not by Root. + SharedKV() KVStore + + // Tokens returns the encrypted-at-rest token store for ctx. + // Account key format (":") matches + // token_store.go:accountKey so multi-user TokenStores share + // keychain slots with the legacy path — this is what makes the + // migration non-destructive. + Tokens(ctx AuthContext) TokenStore + + // Locks returns a per-AuthContext LockProvider. Lock names are + // scoped to ctx so two users never contend on each other's flows. + Locks(ctx AuthContext) LockProvider + + // UserDir is DIAGNOSTIC ONLY: callers MUST NOT use this string to + // open files / construct keys / route I/O. Recorded as StorageDir + // in the user index for operator visibility. + UserDir(ctx AuthContext) string + + // SharedDir is the diagnostic counterpart of UserDir. + SharedDir() string +} + +// KVStore is a key→bytes map scoped to one AuthContext (or to the +// shared namespace via Root.SharedKV). Encoding is the caller's +// responsibility. +// +// Load returns (nil, false, nil) on miss. Save MUST be observably +// atomic (file backend uses tmp + rename, mode 0600 / dir 0700). +// Delete is idempotent. +type KVStore interface { + Load(key string) (data []byte, ok bool, err error) + Save(key string, data []byte) error + Delete(key string) error +} + +// TokenStore holds the access-token and refresh-token envelopes per +// AuthContext. Both are JSON envelopes (see token_store.go); the +// store moves bytes only. The fixed 4-method shape leaves room for a +// future per-user DEK rotation to land inside Save/Load without +// touching this interface. +type TokenStore interface { + LoadAccessToken() (envelope []byte, ok bool, err error) + SaveAccessToken(envelope []byte) error + LoadRefreshToken() (envelope []byte, ok bool, err error) + SaveRefreshToken(envelope []byte) error + + // DeleteAll removes both slots. Idempotent. + DeleteAll() error +} + +// LockProvider hands out named cross-process critical sections scoped +// to one AuthContext. Two AuthContexts can hold the same lock name +// without contention because the provider is per-AuthContext. +type LockProvider interface { + // Acquire blocks for at most wait. ctx cancellation aborts the + // wait; on ctx cancellation returns ctx.Err(). + Acquire(ctx context.Context, name string, wait time.Duration) (Lock, error) +} + +// Lock is a held lock. Release MUST be safe to call multiple times. +type Lock interface { + Release() +} + +// LocalRoot is the file + keychain Root for ordinary lark-cli installs. +// +// On-disk layout (relative to LocalRoot.configDir) +// ------------------------------------------------ +// +// / [legacy / SingleUser] +// config.json ← MultiAppConfig +// locks/refresh__.lock ← legacy uat_client.go +// users.json ← user index (SharedKV) +// users/ ← multi-user subtree +// / +// / +// user_profile.json +// ... ← per-user KV slots +// locks/ ← per-user critical sections +// refresh-token.lock +// device-flow.lock +// +// Tokens for a fully-bound (appId, userOpenId) live in the OS +// keychain under service "lark-cli", account ":" — +// the SAME slot the legacy token_store.go reads/writes. +// +// LocalRoot itself never reads from disk — every path is computed at +// call time from configDir, so a test that bumps configDir between +// calls observes the new value. +type LocalRoot struct { + configDir string +} + +// NewLocalRoot returns a Root rooted at configDir. The directory is +// created lazily by the first Save call that needs it. An empty +// configDir surfaces as a real I/O error at the call site. +func NewLocalRoot(configDir string) *LocalRoot { + return &LocalRoot{configDir: configDir} +} + +// userDir is the per-AuthContext directory. +// +// SingleUser → (legacy) +// AppOnly → (legacy until login binds a user) +// ForUser → /users// +// +// AppOnly stays at the legacy root because the device-flow pending +// state already lives there and must not move; once login completes +// the caller switches to a ForUser context. +func (r *LocalRoot) userDir(ctx AuthContext) string { + if !ctx.HasUser() { + return r.configDir + } + return filepath.Join( + r.configDir, + "users", + ctx.sanitizedAppId(), + ctx.sanitizedUserOpenId(), + ) +} + +func (r *LocalRoot) UserDir(ctx AuthContext) string { return r.userDir(ctx) } + +// SharedDir returns configDir; SharedKV writes a single users.json at +// the root, no separate subdirectory needed. +func (r *LocalRoot) SharedDir() string { return r.configDir } + +func (r *LocalRoot) KV(ctx AuthContext) KVStore { + return &fileKVStore{dir: r.userDir(ctx)} +} + +func (r *LocalRoot) SharedKV() KVStore { + return &fileKVStore{dir: r.configDir} +} + +// Tokens routes to the OS keychain via internal/keychain. The account +// key format (":") matches token_store.go:accountKey +// byte-for-byte. SingleUser/AppOnly contexts return a TokenStore that +// errors with ErrUnboundContext on every operation. +func (r *LocalRoot) Tokens(ctx AuthContext) TokenStore { + return &keychainTokenStore{ctx: ctx} +} + +// Locks lands per-user file locks in /locks/; SingleUser/ +// AppOnly fall back to /locks/ to match the legacy +// uat_client.go layout. +func (r *LocalRoot) Locks(ctx AuthContext) LockProvider { + return &fileLockProvider{dir: filepath.Join(r.userDir(ctx), "locks")} +} + +// fileKVStore maps each key to "/.json"; writes go through +// tmp + rename so a crash mid-write cannot produce a torn read. +type fileKVStore struct { + dir string +} + +// kvKeyPattern restricts keys to [a-z0-9_] — safe filename stems and +// matches the names every caller passes ("user_profile", "user_index", +// "scope_cache", ...). +var kvKeyPattern = func(key string) error { + if key == "" { + return errors.New("kv: empty key") + } + for _, r := range key { + switch { + case r >= 'a' && r <= 'z', + r >= '0' && r <= '9', + r == '_': + default: + return fmt.Errorf("kv: key %q contains disallowed character %q", key, r) + } + } + return nil +} + +func (s *fileKVStore) path(key string) (string, error) { + if err := kvKeyPattern(key); err != nil { + return "", err + } + return filepath.Join(s.dir, key+".json"), nil +} + +// Load returns (nil, false, nil) on miss to spare every caller the +// same os.ErrNotExist dance. +func (s *fileKVStore) Load(key string) ([]byte, bool, error) { + p, err := s.path(key) + if err != nil { + return nil, false, err + } + data, err := vfs.ReadFile(p) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + return nil, false, err + } + return data, true, nil +} + +// Save is observably atomic via tmp + rename. Directory is created +// lazily with mode 0700. +func (s *fileKVStore) Save(key string, data []byte) error { + p, err := s.path(key) + if err != nil { + return err + } + if err := vfs.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + tmp := p + ".tmp" + if err := vfs.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return vfs.Rename(tmp, p) +} + +// Delete is idempotent. +func (s *fileKVStore) Delete(key string) error { + p, err := s.path(key) + if err != nil { + return err + } + if err := vfs.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +// keychainTokenStore writes to the same keychain account format as +// the legacy token_store.go; we deliberately do NOT add a "v2" prefix +// — an existing logged-in user must keep working without re-login. +// Envelope bytes are opaque here; a future per-user DEK lands inside +// maybeWrap/maybeUnwrap without changing this surface. +type keychainTokenStore struct { + ctx AuthContext +} + +// ErrUnboundContext is returned by TokenStore methods when the +// AuthContext lacks a fully-bound (AppId, UserOpenId) pair. +var ErrUnboundContext = errors.New("auth: token store requires a fully-bound AuthContext (appId + userOpenId)") + +// accountKeyFor mirrors token_store.go:accountKey byte-for-byte. Kept +// inline rather than calling accountKey directly so that if the +// legacy format ever changes a single grep finds every site. +func (s *keychainTokenStore) accountKeyFor() (string, error) { + if !s.ctx.HasUser() { + return "", ErrUnboundContext + } + return fmt.Sprintf("%s:%s", s.ctx.AppId(), s.ctx.UserOpenId()), nil +} + +// LoadAccessToken/LoadRefreshToken: legacy format stores both UAT and +// RT in one StoredUAToken JSON blob, so today both slots resolve to +// the same raw bytes. The fixed 4-method interface lets us split them +// later without changing any caller. +func (s *keychainTokenStore) LoadAccessToken() ([]byte, bool, error) { + return s.loadSlot(slotAccessToken) +} + +func (s *keychainTokenStore) SaveAccessToken(envelope []byte) error { + return s.saveSlot(slotAccessToken, envelope) +} + +func (s *keychainTokenStore) LoadRefreshToken() ([]byte, bool, error) { + return s.loadSlot(slotRefreshToken) +} + +func (s *keychainTokenStore) SaveRefreshToken(envelope []byte) error { + return s.saveSlot(slotRefreshToken, envelope) +} + +// DeleteAll removes both slots. Idempotent. +func (s *keychainTokenStore) DeleteAll() error { + key, err := s.accountKeyFor() + if err != nil { + return err + } + // Legacy single-account format: one keychain entry holds both + // UAT and RT, so one Remove drops both slots atomically. + if err := keychain.Remove(keychain.LarkCliService, key); err != nil { + return err + } + return nil +} + +// slotKind selects which inner field of the on-keychain envelope is +// addressed. Today both slots resolve to the same raw bytes — see +// LoadAccessToken. +type slotKind int + +const ( + slotAccessToken slotKind = iota + slotRefreshToken +) + +func (s *keychainTokenStore) loadSlot(_ slotKind) ([]byte, bool, error) { + key, err := s.accountKeyFor() + if err != nil { + return nil, false, err + } + val, err := keychain.Get(keychain.LarkCliService, key) + if err != nil { + if errors.Is(err, keychain.ErrNotFound) { + return nil, false, nil + } + return nil, false, err + } + if val == "" { + return nil, false, nil + } + return []byte(val), true, nil +} + +func (s *keychainTokenStore) saveSlot(_ slotKind, envelope []byte) error { + key, err := s.accountKeyFor() + if err != nil { + return err + } + // Cheap envelope sanity check: keychain backends accept any + // string, but envelopes are always JSON, so a caller passing raw + // garbage gets a typed error rather than a silent write. + if !looksLikeJSON(envelope) { + return fmt.Errorf("auth: token envelope is not JSON") + } + return keychain.Set(keychain.LarkCliService, key, string(envelope)) +} + +// looksLikeJSON skips a full json.Valid call: this is a programmer-error +// guard, not validation. +func looksLikeJSON(b []byte) bool { + for _, c := range b { + switch c { + case ' ', '\t', '\r', '\n': + continue + case '{': + return true + default: + return false + } + } + return false +} + +// fileLockProvider is the gofrs/flock-backed LockProvider. The lock +// directory is created lazily on first Acquire. +type fileLockProvider struct { + dir string +} + +// lockNamePattern restricts lock names to filename-safe stems. Same +// rules as KV keys plus '-' (lock names often read more naturally with +// hyphens, e.g. "refresh-token"). +var lockNamePattern = func(name string) error { + if name == "" { + return errors.New("lock: empty name") + } + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', + r >= '0' && r <= '9', + r == '-' || r == '_': + default: + return fmt.Errorf("lock: name %q contains disallowed character %q", name, r) + } + } + return nil +} + +func (p *fileLockProvider) Acquire(ctx context.Context, name string, wait time.Duration) (Lock, error) { + if err := lockNamePattern(name); err != nil { + return nil, err + } + if err := vfs.MkdirAll(p.dir, 0o700); err != nil { + return nil, err + } + path := filepath.Join(p.dir, name+".lock") + fl := flock.New(path) + + // gofrs/flock honours context cancellation in TryLockContext; + // we layer the wait timeout on top so callers get the documented + // "blocks for at most wait" semantics regardless of whether ctx + // has its own deadline. + if wait <= 0 { + wait = 30 * time.Second + } + deadline, cancel := context.WithTimeout(ctx, wait) + defer cancel() + + locked, err := fl.TryLockContext(deadline, 100*time.Millisecond) + if err != nil { + return nil, fmt.Errorf("auth: acquire lock %q: %w", name, err) + } + if !locked { + return nil, fmt.Errorf("auth: acquire lock %q: timeout after %s", name, wait) + } + return &fileLock{fl: fl}, nil +} + +// fileLock wraps *flock.Flock so callers see only the interface. +// Release is idempotent. +type fileLock struct { + mu sync.Mutex + fl *flock.Flock +} + +func (l *fileLock) Release() { + l.mu.Lock() + defer l.mu.Unlock() + if l.fl == nil { + return + } + _ = l.fl.Unlock() + l.fl = nil +} + +// MarshalJSONIndent marshals v with two-space indent so on-disk files +// diff cleanly under `git diff`. +func MarshalJSONIndent(v any) ([]byte, error) { + return json.MarshalIndent(v, "", " ") +} diff --git a/internal/auth/storage_test.go b/internal/auth/storage_test.go new file mode 100644 index 000000000..b8f0940cc --- /dev/null +++ b/internal/auth/storage_test.go @@ -0,0 +1,312 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// TestLocalRootUserDir locks the on-disk layout: SingleUser/AppOnly +// fall through to ; ForUser lands in +// /users//. +func TestLocalRootUserDir(t *testing.T) { + root := NewLocalRoot("/tmp/cfg") + tests := []struct { + name string + ctx AuthContext + want string + }{ + { + name: "SingleUser maps to legacy configDir", + ctx: SingleUser(), + want: "/tmp/cfg", + }, + { + name: "AppOnly also maps to legacy configDir (pre-bind)", + ctx: AppOnly("cli_xxx"), + want: "/tmp/cfg", + }, + { + name: "ForUser maps to per-user subtree", + ctx: ForUser("cli_xxx", "ou_abc"), + want: filepath.Join("/tmp/cfg", "users", "cli_xxx", "ou_abc"), + }, + { + name: "ForUser sanitises both segments", + ctx: ForUser("cli.xxx", "ou/abc"), + want: filepath.Join("/tmp/cfg", "users", "cli-xxx", "ou-abc"), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := root.UserDir(tc.ctx); got != tc.want { + t.Errorf("UserDir(%+v) = %q, want %q", tc.ctx, got, tc.want) + } + }) + } + + if got := root.SharedDir(); got != "/tmp/cfg" { + t.Errorf("SharedDir() = %q, want /tmp/cfg", got) + } +} + +// TestFileKVStoreRoundTrip exercises the basic Save → Load → Delete +// cycle every higher-level KV consumer builds on. +func TestFileKVStoreRoundTrip(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_xxx", "ou_abc") + kv := root.KV(ctx) + + // Miss returns (nil, false, nil). + data, ok, err := kv.Load("user_profile") + if err != nil { + t.Fatalf("Load on empty: unexpected err %v", err) + } + if ok || data != nil { + t.Fatalf("Load on empty: got (ok=%v data=%v), want (false, nil)", ok, data) + } + + want := []byte(`{"hello":"world"}`) + if err := kv.Save("user_profile", want); err != nil { + t.Fatalf("Save: %v", err) + } + got, ok, err := kv.Load("user_profile") + if err != nil || !ok { + t.Fatalf("Load after Save: ok=%v err=%v", ok, err) + } + if string(got) != string(want) { + t.Errorf("Load after Save: got %q, want %q", got, want) + } + + // Delete is idempotent. + if err := kv.Delete("user_profile"); err != nil { + t.Fatalf("Delete: %v", err) + } + if err := kv.Delete("user_profile"); err != nil { + t.Errorf("second Delete (idempotent): %v", err) + } + + if data, ok, _ := kv.Load("user_profile"); ok || data != nil { + t.Errorf("Load after Delete: got (ok=%v data=%v), want miss", ok, data) + } +} + +// TestFileKVStoreAtomicReplace asserts no .tmp survivor lingers after +// Save, since tmp+rename is the only mechanism producing atomicity. +func TestFileKVStoreAtomicReplace(t *testing.T) { + dir := t.TempDir() + kv := (&LocalRoot{configDir: dir}).KV(ForUser("a", "u")) + + if err := kv.Save("user_profile", []byte("{}")); err != nil { + t.Fatalf("Save: %v", err) + } + + leakDir := filepath.Join(dir, "users", "a", "u") + entries, err := os.ReadDir(leakDir) + if err != nil { + t.Fatalf("ReadDir(%s): %v", leakDir, err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Errorf("orphan tmp file after Save: %s", e.Name()) + } + } +} + +// TestFileKVStoreRejectsBadKeys guards against path-escape: keys with +// separators, dots, uppercase, or emptiness must be rejected. +func TestFileKVStoreRejectsBadKeys(t *testing.T) { + kv := (&LocalRoot{configDir: t.TempDir()}).KV(ForUser("a", "u")) + bad := []string{ + "", + "User", + "user.json", + "a/b", + "a\\b", + "a-b", // hyphen NOT allowed for KV keys (only [a-z0-9_]) + "\x00x", + } + for _, k := range bad { + if err := kv.Save(k, []byte("{}")); err == nil { + t.Errorf("Save(%q) should have rejected, got nil err", k) + } + if _, _, err := kv.Load(k); err == nil { + t.Errorf("Load(%q) should have rejected, got nil err", k) + } + } +} + +// TestKeychainTokenStoreRequiresBoundContext locks the contract that +// SingleUser / AppOnly cannot mint a TokenStore key — the keychain +// account format ":" needs both fields. +func TestKeychainTokenStoreRequiresBoundContext(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + for _, ctx := range []AuthContext{SingleUser(), AppOnly("cli_xxx")} { + ts := root.Tokens(ctx) + if _, _, err := ts.LoadAccessToken(); !errors.Is(err, ErrUnboundContext) { + t.Errorf("LoadAccessToken on %+v: err = %v, want ErrUnboundContext", ctx, err) + } + if err := ts.SaveAccessToken([]byte(`{"x":1}`)); !errors.Is(err, ErrUnboundContext) { + t.Errorf("SaveAccessToken on %+v: err = %v, want ErrUnboundContext", ctx, err) + } + if _, _, err := ts.LoadRefreshToken(); !errors.Is(err, ErrUnboundContext) { + t.Errorf("LoadRefreshToken on %+v: err = %v, want ErrUnboundContext", ctx, err) + } + if err := ts.SaveRefreshToken([]byte(`{"x":1}`)); !errors.Is(err, ErrUnboundContext) { + t.Errorf("SaveRefreshToken on %+v: err = %v, want ErrUnboundContext", ctx, err) + } + if err := ts.DeleteAll(); !errors.Is(err, ErrUnboundContext) { + t.Errorf("DeleteAll on %+v: err = %v, want ErrUnboundContext", ctx, err) + } + } +} + +// TestKeychainTokenStoreAccountKeyMatchesLegacy pins the account-key +// format so multi-user TokenStore reads/writes the same slot as +// legacy token_store.go:accountKey. A divergent change to either +// copy of the formula must surface as a test failure. +func TestKeychainTokenStoreAccountKeyMatchesLegacy(t *testing.T) { + store := &keychainTokenStore{ctx: ForUser("cli_xxx", "ou_abc")} + got, err := store.accountKeyFor() + if err != nil { + t.Fatalf("accountKeyFor: %v", err) + } + want := "cli_xxx:ou_abc" + if got != want { + t.Errorf("accountKeyFor() = %q, want %q (must match token_store.go:accountKey)", got, want) + } +} + +// TestSaveAccessTokenRejectsNonJSON verifies envelope sanity: callers +// reaching saveSlot with non-object JSON get a typed error rather +// than silently writing garbage to the keychain. +func TestSaveAccessTokenRejectsNonJSON(t *testing.T) { + store := &keychainTokenStore{ctx: ForUser("a", "u")} + cases := []string{ + "", + " ", + "hello", + "[1,2,3]", // arrays are valid JSON, but envelopes are objects + } + for _, c := range cases { + if err := store.SaveAccessToken([]byte(c)); err == nil { + t.Errorf("SaveAccessToken(%q) should have rejected non-object envelope", c) + } + } + if !looksLikeJSON([]byte("\n\t {\"x\":1}")) { + t.Error("looksLikeJSON should accept leading whitespace before object") + } +} + +// TestFileLockProviderMutualExclusion locks the core LockProvider +// invariant: two Acquire calls on the same name + dir cannot both +// hold at once. +func TestFileLockProviderMutualExclusion(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("a", "u") + prov := root.Locks(ctx) + + first, err := prov.Acquire(context.Background(), "refresh-token", time.Second) + if err != nil { + t.Fatalf("first Acquire: %v", err) + } + defer first.Release() + + deadline, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if _, err := prov.Acquire(deadline, "refresh-token", 150*time.Millisecond); err == nil { + t.Error("second Acquire should have failed while first lock is held") + } + + first.Release() + second, err := prov.Acquire(context.Background(), "refresh-token", 500*time.Millisecond) + if err != nil { + t.Fatalf("Acquire after Release: %v", err) + } + second.Release() + second.Release() // idempotent — must not panic / error +} + +// TestFileLockProviderPerContext verifies two AuthContexts never +// contend — user A's refresh must not block user B's. +func TestFileLockProviderPerContext(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + a := root.Locks(ForUser("app1", "userA")) + b := root.Locks(ForUser("app1", "userB")) + + la, err := a.Acquire(context.Background(), "refresh-token", time.Second) + if err != nil { + t.Fatalf("a.Acquire: %v", err) + } + defer la.Release() + + lb, err := b.Acquire(context.Background(), "refresh-token", 200*time.Millisecond) + if err != nil { + t.Fatalf("b.Acquire while a holds: %v", err) + } + lb.Release() +} + +// TestFileLockProviderRejectsBadNames keeps lock filenames safe. +func TestFileLockProviderRejectsBadNames(t *testing.T) { + prov := NewLocalRoot(t.TempDir()).Locks(ForUser("a", "u")) + for _, bad := range []string{"", "Refresh", "refresh.token", "../escape", "a/b"} { + _, err := prov.Acquire(context.Background(), bad, 0) + if err == nil { + t.Errorf("Acquire(%q) should have been rejected", bad) + } + } +} + +// TestSharedKVRoundTrip pins SharedKV writes to /.json, +// next to config.json — that's where user_index lives. +func TestSharedKVRoundTrip(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + skv := root.SharedKV() + + if err := skv.Save("user_index", []byte(`{}`)); err != nil { + t.Fatalf("SharedKV.Save: %v", err) + } + want := filepath.Join(dir, "user_index.json") + if _, err := os.Stat(want); err != nil { + t.Errorf("expected SharedKV file at %s, stat err: %v", want, err) + } +} + +// TestMarshalJSONIndent locks the on-disk format: pretty-printed +// two-space JSON, since operators occasionally inspect these files. +func TestMarshalJSONIndent(t *testing.T) { + out, err := MarshalJSONIndent(map[string]any{"a": 1}) + if err != nil { + t.Fatalf("MarshalJSONIndent: %v", err) + } + want := "{\n \"a\": 1\n}" + if string(out) != want { + t.Errorf("MarshalJSONIndent format drift: got %q, want %q", out, want) + } +} + +// TestLockReleaseIdempotent ensures Release survives concurrent calls +// — callers rely on `defer Release` racing with manual Release. +func TestLockReleaseIdempotent(t *testing.T) { + prov := NewLocalRoot(t.TempDir()).Locks(ForUser("a", "u")) + l, err := prov.Acquire(context.Background(), "x", time.Second) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { defer wg.Done(); l.Release() }() + } + wg.Wait() +} diff --git a/internal/auth/user_index.go b/internal/auth/user_index.go new file mode 100644 index 000000000..df937f3aa --- /dev/null +++ b/internal/auth/user_index.go @@ -0,0 +1,357 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strings" + "sync" + "time" +) + +// UserIndex is the install-wide registry of every (AppId, UserOpenId) +// the CLI has minted credentials for. Persisted at +// /user_index.json via Root.SharedKV(). +// +// Authoritative source for `lark auth users list` — config.json drifts +// (user-edited) and the keychain doesn't expose listing. +// +// Keyed by (AppId, UserOpenId), joined with the same colon separator +// as keychainTokenStore.accountKeyFor (storage.go), so one human +// logged into two apps gets two distinct rows. +// +// SingleUser and AppOnly contexts MUST NOT touch this file; the index +// only materialises after a fully-bound context records activity. +type UserIndex struct { + Users map[string]UserIndexEntry `json:"users"` +} + +// UserIndexEntry is one row of the index. +// +// JSON tags are camelCase to match UserProfile / AppUser. Time fields +// use RFC3339 (matching UserProfile), not the Unix-millis encoding +// token envelopes use. +// +// - StorageDir is diagnostic only — recomputable from (AppId, +// UserOpenId) via Root.UserDir. +// - LastScopes is normalised (sorted, deduped, comma-joined) at +// write time so identical scope sets produce byte-identical JSON. +// - FirstSeen is write-once; preserved across re-upserts. +type UserIndexEntry struct { + AppId string `json:"appId"` + UserOpenId string `json:"userOpenId"` + UserName string `json:"userName,omitempty"` + StorageDir string `json:"storageDir,omitempty"` + LastScopes string `json:"lastScopes,omitempty"` + LastUsed time.Time `json:"lastUsed"` + FirstSeen time.Time `json:"firstSeen,omitempty"` +} + +// userIndexKey resolves to "/user_index.json" via fileKVStore. +// kvKeyPattern restricts key chars to [a-z0-9_]. +const userIndexKey = "user_index" + +// userIndexLockName mirrors userIndexKey so a single grep finds every site. +const userIndexLockName = "user_index" + +// userIndexAcquireWait matches the deadline used by uat_client.go's +// refresh path so a hung CLI presents a familiar timeout. +const userIndexAcquireWait = 30 * time.Second + +var errIndexNilRoot = errors.New("auth: user index: root is nil") + +// userIndexEntryKey produces the on-disk map key. MUST stay +// byte-identical to keychainTokenStore.accountKeyFor in token_store.go; +// any format change is a coordinated edit across both sites. +// +// Whitespace is trimmed defensively — a stray space at the call site +// is a caller bug, not a divergent key. +func userIndexEntryKey(appId, userOpenId string) string { + return fmt.Sprintf("%s:%s", strings.TrimSpace(appId), strings.TrimSpace(userOpenId)) +} + +// keyFor returns the index key for ctx, or "" if ctx is not fully +// bound. Callers MUST gate on the return value to honor the +// "no SingleUser/AppOnly rows" invariant. +// +// The whitespace check guards against AuthContext zero-values +// constructed bypassing ForUser/AppOnly — HasUser() only checks +// `!= ""`, so a whitespace-only field would otherwise produce a +// poisoned key like ":ou_a". +func keyFor(ctx AuthContext) string { + if !ctx.HasUser() { + return "" + } + if strings.TrimSpace(ctx.AppId()) == "" || strings.TrimSpace(ctx.UserOpenId()) == "" { + return "" + } + return userIndexEntryKey(ctx.AppId(), ctx.UserOpenId()) +} + +// userIndexMu serialises read-modify-write within a single process. +// +// fileKVStore.Save's atomic-replace prevents torn reads but NOT lost +// updates: two processes can each Load → mutate → Save and one row's +// changes are silently dropped. The cross-process flock acquired via +// Root.Locks(SingleUser()) closes that hole. +// +// gofrs/flock is process-aware, so this in-process mutex is technically +// redundant when the flock is held — but it makes the in-process +// critical section observable in stack traces, and matches the +// precedent set by storage.go's fileLock. +var userIndexMu sync.Mutex + +// LoadUserIndex returns the on-disk index, or an empty index if the +// file does not yet exist. A missing file is NOT an error — first-run +// installs must boot silently or every `lark auth users list` fails. +// +// A file that exists but doesn't parse is recovered as empty with a +// stderr warning: the index is observability-grade (rebuilt by the +// next RecordUserActivity) and blocking every authenticated CLI +// invocation behind a non-load-bearing log file is the wrong tradeoff. +// +// Takes userIndexMu briefly but does NOT acquire the flock — readers +// don't need cross-process serialisation; atomic-replace already +// guarantees readers see a whole document. Returned Users map is +// non-nil even on miss / corrupt recovery. +func LoadUserIndex(root Root) (UserIndex, error) { + if root == nil { + return UserIndex{}, errIndexNilRoot + } + userIndexMu.Lock() + defer userIndexMu.Unlock() + return loadUserIndexLocked(root) +} + +// loadUserIndexLocked reads the index assuming userIndexMu is held. +func loadUserIndexLocked(root Root) (UserIndex, error) { + data, ok, err := root.SharedKV().Load(userIndexKey) + if err != nil { + return UserIndex{}, fmt.Errorf("auth: load user index: %w", err) + } + if !ok { + return UserIndex{Users: map[string]UserIndexEntry{}}, nil + } + var idx UserIndex + if err := json.Unmarshal(data, &idx); err != nil { + // Corrupt: surface on stderr, return empty so the next Record reseeds. + fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] auth: user index corrupt, rebuilding empty: %v\n", err) + return UserIndex{Users: map[string]UserIndexEntry{}}, nil + } + if idx.Users == nil { + idx.Users = map[string]UserIndexEntry{} + } + return idx, nil +} + +// UserIndexEntries returns every row sorted for stable CLI output. +// +// Sort: LastUsed descending, tiebreak (AppId, UserOpenId) ascending. +// Composite tiebreak (not just UserOpenId, as park does) is forced by +// the composite key — two rows can share LastUsed and UserOpenId +// across different apps and listing must remain deterministic for +// golden tests. +// +// Time comparison uses Equal/After (not !Before) to avoid +// strict-weak-ordering bugs on equal times. +// +// Returns ([], nil) on first-run, NOT (nil, nil), so callers can range +// without a nil-check. +func UserIndexEntries(root Root) ([]UserIndexEntry, error) { + idx, err := LoadUserIndex(root) + if err != nil { + return nil, err + } + out := make([]UserIndexEntry, 0, len(idx.Users)) + for _, e := range idx.Users { + out = append(out, e) + } + sort.Slice(out, func(i, j int) bool { + if out[i].LastUsed.Equal(out[j].LastUsed) { + if out[i].AppId != out[j].AppId { + return out[i].AppId < out[j].AppId + } + return out[i].UserOpenId < out[j].UserOpenId + } + return out[i].LastUsed.After(out[j].LastUsed) + }) + return out, nil +} + +// RecordUserActivity upserts the index row for ctx. +// +// Returns nil (no-op) for SingleUser, AppOnly, or whitespace-only +// AppId/UserOpenId — the index is multi-user-only and a malformed +// AuthContext must not poison it with a blank composite key. +// +// Merge rules: +// - StorageDir: always overwritten with root.UserDir(ctx). +// - UserName: loaded from UserProfile if available; preserved +// from prior row otherwise. Never blanked. +// - LastScopes: empty / whitespace-only input PRESERVES prior — a +// stale scope-cache write must not blank a richer earlier record. +// Non-empty input is normalised (sort+dedup+join) so identical +// scope sets produce byte-identical JSON. +// - LastUsed: always bumped to time.Now(). +// - FirstSeen: write-once; stamped on first insert. +// +// Acquires userIndexMu and a 30s flock via Root.Locks. Both are needed: +// atomic-replace prevents torn reads but not lost updates, and +// concurrent multi-app login is exactly the contention case this file +// exists to handle. +// +// Flock acquired AFTER the in-process mutex would let one stuck +// goroutine starve readers — so the flock is taken FIRST, outside the +// mutex, with the bounded wait. +func RecordUserActivity(root Root, ctx AuthContext, scopes []string) error { + if root == nil { + return errIndexNilRoot + } + key := keyFor(ctx) + if key == "" { + return nil + } + + // Flock outside the in-process mutex so a 30s wait doesn't queue + // other in-process readers behind it. + flockCtx, cancel := context.WithTimeout(context.Background(), userIndexAcquireWait) + defer cancel() + lk, err := root.Locks(SingleUser()).Acquire(flockCtx, userIndexLockName, userIndexAcquireWait) + if err != nil { + return fmt.Errorf("auth: user index: acquire flock: %w", err) + } + defer lk.Release() + + userIndexMu.Lock() + defer userIndexMu.Unlock() + + idx, err := loadUserIndexLocked(root) + if err != nil { + return err + } + + now := time.Now() + prev := idx.Users[key] + + // Prefer freshly-loaded profile; fall back to prior row. + userName := prev.UserName + if profile, err := LoadUserProfileFor(root, ctx); err == nil && profile != nil && profile.UserName != "" { + userName = profile.UserName + } + + // Empty/whitespace input preserves prior; non-empty is normalised. + lastScopes := prev.LastScopes + if normalised := NormaliseScopes(scopes); normalised != "" { + lastScopes = normalised + } + + firstSeen := prev.FirstSeen + if firstSeen.IsZero() { + firstSeen = now + } + + idx.Users[key] = UserIndexEntry{ + AppId: ctx.AppId(), + UserOpenId: ctx.UserOpenId(), + UserName: userName, + StorageDir: root.UserDir(ctx), + LastScopes: lastScopes, + LastUsed: now, + FirstSeen: firstSeen, + } + return writeUserIndexLocked(root, idx) +} + +// DeleteUser removes the index row for ctx. Idempotent — deleting a +// missing row returns nil, matching `lark auth users logout`'s +// best-effort tidy contract. +// +// Lookup uses keyFor(ctx) — i.e. the (AppId, UserOpenId) pair, NOT +// UserOpenId alone. Load-bearing: park's DeleteAgentIndexUser keys by +// bare open_id and would delete the wrong row in a multi-app install +// if two apps minted the same open_id string for different humans. +// +// Does NOT touch keychain or per-user storage; those are the caller's +// responsibility (logout orchestrates Token DeleteAll, profile Delete, +// AND DeleteUser separately) so a partial failure surfaces its real +// cause instead of wedging behind a generic 'cleanup failed'. +func DeleteUser(root Root, ctx AuthContext) error { + if root == nil { + return errIndexNilRoot + } + key := keyFor(ctx) + if key == "" { + return nil + } + + flockCtx, cancel := context.WithTimeout(context.Background(), userIndexAcquireWait) + defer cancel() + lk, err := root.Locks(SingleUser()).Acquire(flockCtx, userIndexLockName, userIndexAcquireWait) + if err != nil { + return fmt.Errorf("auth: user index: acquire flock: %w", err) + } + defer lk.Release() + + userIndexMu.Lock() + defer userIndexMu.Unlock() + + idx, err := loadUserIndexLocked(root) + if err != nil { + return err + } + if _, ok := idx.Users[key]; !ok { + return nil + } + delete(idx.Users, key) + return writeUserIndexLocked(root, idx) +} + +// writeUserIndexLocked persists idx, assuming userIndexMu and the +// cross-process flock are both held by the caller. +func writeUserIndexLocked(root Root, idx UserIndex) error { + data, err := MarshalJSONIndent(idx) + if err != nil { + return fmt.Errorf("auth: marshal user index: %w", err) + } + if err := root.SharedKV().Save(userIndexKey, data); err != nil { + return fmt.Errorf("auth: save user index: %w", err) + } + return nil +} + +// NormaliseScopes trims, drops empties, sorts, and dedupes scopes, +// then joins with ",". Returns "" if the result would be empty — +// RecordUserActivity uses that to mean "preserve prior". +// +// Exported so login.go and other callers share one canonical +// representation; AppUser.LastScopes equality and downstream cache +// validity compare byte-identical strings. +func NormaliseScopes(scopes []string) string { + if len(scopes) == 0 { + return "" + } + seen := make(map[string]struct{}, len(scopes)) + uniq := make([]string, 0, len(scopes)) + for _, s := range scopes { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + uniq = append(uniq, s) + } + if len(uniq) == 0 { + return "" + } + sort.Strings(uniq) + return strings.Join(uniq, ",") +} diff --git a/internal/auth/user_index_test.go b/internal/auth/user_index_test.go new file mode 100644 index 000000000..45ca16db4 --- /dev/null +++ b/internal/auth/user_index_test.go @@ -0,0 +1,543 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// Missing user_index.json must load cleanly so first-run `auth users list` works. +func TestLoadUserIndexAbsentFileYieldsEmpty(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + idx, err := LoadUserIndex(root) + if err != nil { + t.Fatalf("Load on missing file: %v", err) + } + if idx.Users == nil { + t.Fatal("Load on missing file: Users is nil; callers cannot range over it") + } + if len(idx.Users) != 0 { + t.Errorf("Load on missing file: got %d entries, want 0", len(idx.Users)) + } +} + +// Back-compat: legacy single-user installs must never materialise user_index.json. +func TestRecordUserActivityNoOpForSingleUser(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + if err := RecordUserActivity(root, SingleUser(), []string{"docs:read"}); err != nil { + t.Fatalf("Record SingleUser: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "user_index.json")); err == nil { + t.Error("SingleUser activity created user_index.json; back-compat invariant broken") + } +} + +// AppOnly has no UserOpenId, so the HasUser() gate must skip it like SingleUser. +func TestRecordUserActivityNoOpForAppOnly(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + if err := RecordUserActivity(root, AppOnly("cli_x"), []string{"docs:read"}); err != nil { + t.Fatalf("Record AppOnly: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "user_index.json")); err == nil { + t.Error("AppOnly activity created user_index.json; should be no-op") + } +} + +// Full row shape round-trips through user_index.json on disk. +func TestRecordUserActivityRoundTrip(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + ctx := ForUser("cli_x", "ou_alice") + + if err := RecordUserActivity(root, ctx, []string{"docs:read", "im:message"}); err != nil { + t.Fatalf("Record: %v", err) + } + + want := filepath.Join(dir, "user_index.json") + data, err := os.ReadFile(want) + if err != nil { + t.Fatalf("ReadFile %s: %v", want, err) + } + + var idx UserIndex + if err := json.Unmarshal(data, &idx); err != nil { + t.Fatalf("parse: %v", err) + } + + got, ok := idx.Users["cli_x:ou_alice"] + if !ok { + t.Fatalf("expected key %q in Users map; got %v", "cli_x:ou_alice", idx.Users) + } + if got.AppId != "cli_x" || got.UserOpenId != "ou_alice" { + t.Errorf("identity fields wrong: got AppId=%q UserOpenId=%q", got.AppId, got.UserOpenId) + } + // LastScopes is sorted+joined with ",". + if got.LastScopes != "docs:read,im:message" { + t.Errorf("LastScopes = %q, want sorted-joined form", got.LastScopes) + } + if got.LastUsed.IsZero() { + t.Error("LastUsed is zero; should have been stamped") + } + if got.FirstSeen.IsZero() { + t.Error("FirstSeen is zero on first Record") + } + if !got.FirstSeen.Equal(got.LastUsed) { + t.Errorf("FirstSeen = %v, want equal to LastUsed = %v on first Record", got.FirstSeen, got.LastUsed) + } + if got.StorageDir != root.UserDir(ctx) { + t.Errorf("StorageDir = %q, want %q", got.StorageDir, root.UserDir(ctx)) + } +} + +// Cross-layer invariant: index key must equal token_store.go:accountKey for the +// same (appId, openId). If either formula drifts, this fails and forces a dual update. +func TestUserIndexEntryKeyMatchesKeychainAccountKey(t *testing.T) { + got := userIndexEntryKey("cli_x", "ou_alice") + want := accountKey("cli_x", "ou_alice") + if got != want { + t.Errorf("userIndexEntryKey = %q, want match for accountKey = %q", got, want) + } +} + +// FirstSeen is write-once: a second Record must preserve the original timestamp. +func TestRecordUserActivityFirstSeenPreservedAcrossUpserts(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_x", "ou_alice") + + if err := RecordUserActivity(root, ctx, []string{"docs:read"}); err != nil { + t.Fatalf("first Record: %v", err) + } + idx1, _ := LoadUserIndex(root) + first := idx1.Users["cli_x:ou_alice"].FirstSeen + if first.IsZero() { + t.Fatal("FirstSeen not stamped on first Record") + } + + time.Sleep(10 * time.Millisecond) + if err := RecordUserActivity(root, ctx, []string{"docs:read", "im:message"}); err != nil { + t.Fatalf("second Record: %v", err) + } + idx2, _ := LoadUserIndex(root) + got := idx2.Users["cli_x:ou_alice"] + if !got.FirstSeen.Equal(first) { + t.Errorf("FirstSeen drifted: got %v, want preserved %v", got.FirstSeen, first) + } + if !got.LastUsed.After(first) { + t.Errorf("LastUsed should advance on second Record: got %v, want > %v", got.LastUsed, first) + } +} + +// Empty/whitespace scopes must not blank a richer prior LastScopes — guards +// against stale-cache writes degrading the listing over time. +func TestRecordUserActivityEmptyScopesPreservesPrior(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_x", "ou_alice") + + if err := RecordUserActivity(root, ctx, []string{"docs:read", "im:message"}); err != nil { + t.Fatalf("first Record: %v", err) + } + + if err := RecordUserActivity(root, ctx, nil); err != nil { + t.Fatalf("second Record (nil scopes): %v", err) + } + idx, _ := LoadUserIndex(root) + got := idx.Users["cli_x:ou_alice"] + if got.LastScopes != "docs:read,im:message" { + t.Errorf("LastScopes was blanked: got %q, want preserved %q", got.LastScopes, "docs:read,im:message") + } + + if err := RecordUserActivity(root, ctx, []string{" ", "\t"}); err != nil { + t.Fatalf("third Record (whitespace scopes): %v", err) + } + idx, _ = LoadUserIndex(root) + got = idx.Users["cli_x:ou_alice"] + if got.LastScopes != "docs:read,im:message" { + t.Errorf("LastScopes was blanked by whitespace: got %q", got.LastScopes) + } +} + +// Normalisation contract: trim, drop empties, sort, dedupe, join with ",". +func TestNormaliseScopes(t *testing.T) { + tests := []struct { + in []string + want string + }{ + {nil, ""}, + {[]string{}, ""}, + {[]string{""}, ""}, + {[]string{" "}, ""}, + {[]string{"a"}, "a"}, + {[]string{"b", "a"}, "a,b"}, // sorted + {[]string{"a", "a"}, "a"}, // deduped + {[]string{" a ", "b ", " a"}, "a,b"}, // trim+dedupe + {[]string{"docs:read", "im:message"}, "docs:read,im:message"}, + {[]string{"im:message", "docs:read"}, "docs:read,im:message"}, // order-insensitive + } + for _, tc := range tests { + got := NormaliseScopes(tc.in) + if got != tc.want { + t.Errorf("NormaliseScopes(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// Same scope set in different order must produce byte-identical LastScopes. +// We compare LastScopes only because LastUsed/FirstSeen advance across calls. +func TestRecordUserActivityScopeOrderProducesByteIdenticalLastScopes(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_x", "ou_alice") + + if err := RecordUserActivity(root, ctx, []string{"docs:read", "im:message", "calendar:event"}); err != nil { + t.Fatalf("Record (order A): %v", err) + } + idx1, _ := LoadUserIndex(root) + a := idx1.Users["cli_x:ou_alice"].LastScopes + + if err := RecordUserActivity(root, ctx, []string{"calendar:event", "im:message", "docs:read"}); err != nil { + t.Fatalf("Record (order B): %v", err) + } + idx2, _ := LoadUserIndex(root) + b := idx2.Users["cli_x:ou_alice"].LastScopes + + if a != b { + t.Errorf("scope-order-different inputs yielded different LastScopes:\norder A: %q\norder B: %q", a, b) + } + want := "calendar:event,docs:read,im:message" + if a != want { + t.Errorf("LastScopes = %q, want %q (sorted+joined)", a, want) + } +} + +// Recording bob must not overwrite alice — composite-key isolation. +func TestRecordUserActivityTwoUsersIndependent(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + + if err := RecordUserActivity(root, ForUser("cli_x", "ou_alice"), []string{"a"}); err != nil { + t.Fatalf("alice: %v", err) + } + if err := RecordUserActivity(root, ForUser("cli_x", "ou_bob"), []string{"b"}); err != nil { + t.Fatalf("bob: %v", err) + } + + idx, _ := LoadUserIndex(root) + if got := idx.Users["cli_x:ou_alice"].LastScopes; got != "a" { + t.Errorf("alice LastScopes = %q, want a", got) + } + if got := idx.Users["cli_x:ou_bob"].LastScopes; got != "b" { + t.Errorf("bob LastScopes = %q, want b", got) + } +} + +// Same open_id under two appIds must yield two distinct rows — park's flat-key +// collapse bug, fixed at the AuthContext boundary. +func TestRecordUserActivityTwoAppsSameOpenIdAreDistinct(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + if err := RecordUserActivity(root, ForUser("cli_appA", "ou_alice"), []string{"a"}); err != nil { + t.Fatalf("appA: %v", err) + } + if err := RecordUserActivity(root, ForUser("cli_appB", "ou_alice"), []string{"b"}); err != nil { + t.Fatalf("appB: %v", err) + } + + idx, _ := LoadUserIndex(root) + if len(idx.Users) != 2 { + t.Fatalf("two apps with same open_id collapsed to %d rows: %v", len(idx.Users), idx.Users) + } + if got := idx.Users["cli_appA:ou_alice"].LastScopes; got != "a" { + t.Errorf("appA LastScopes = %q", got) + } + if got := idx.Users["cli_appB:ou_alice"].LastScopes; got != "b" { + t.Errorf("appB LastScopes = %q", got) + } +} + +// Record copies UserName from saved UserProfile so `auth users list` doesn't fan out. +func TestRecordUserActivityCopiesUserNameFromProfile(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_x", "ou_alice") + + if err := SaveUserProfileFor(root, ctx, UserProfile{ + UserOpenId: "ou_alice", + UserName: "Alice", + }); err != nil { + t.Fatalf("SaveUserProfileFor: %v", err) + } + if err := RecordUserActivity(root, ctx, []string{"docs:read"}); err != nil { + t.Fatalf("Record: %v", err) + } + + idx, _ := LoadUserIndex(root) + if got := idx.Users["cli_x:ou_alice"].UserName; got != "Alice" { + t.Errorf("UserName = %q, want Alice", got) + } +} + +// A later Record with profile missing must not blank a previously-populated UserName. +func TestRecordUserActivityPreservesUserNameWhenProfileAbsent(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_x", "ou_alice") + + if err := SaveUserProfileFor(root, ctx, UserProfile{UserOpenId: "ou_alice", UserName: "Alice"}); err != nil { + t.Fatalf("Save profile: %v", err) + } + if err := RecordUserActivity(root, ctx, []string{"a"}); err != nil { + t.Fatalf("first Record: %v", err) + } + if err := DeleteUserProfileFor(root, ctx); err != nil { + t.Fatalf("Delete profile: %v", err) + } + if err := RecordUserActivity(root, ctx, []string{"a"}); err != nil { + t.Fatalf("second Record (no profile): %v", err) + } + + idx, _ := LoadUserIndex(root) + if got := idx.Users["cli_x:ou_alice"].UserName; got != "Alice" { + t.Errorf("UserName lost when profile vanished: got %q, want preserved Alice", got) + } +} + +// Listing is sorted LastUsed desc; ties break on AppId asc, UserOpenId asc +// (Go map iteration is random, so explicit sort is required). +func TestUserIndexEntriesSortedByLastUsedDescTiebreakAppIdOpenId(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + + if err := RecordUserActivity(root, ForUser("cli_x", "ou_alice"), []string{"a"}); err != nil { + t.Fatalf("alice: %v", err) + } + time.Sleep(2 * time.Millisecond) + if err := RecordUserActivity(root, ForUser("cli_x", "ou_carol"), []string{"c"}); err != nil { + t.Fatalf("carol: %v", err) + } + time.Sleep(2 * time.Millisecond) + if err := RecordUserActivity(root, ForUser("cli_x", "ou_bob"), []string{"b"}); err != nil { + t.Fatalf("bob: %v", err) + } + + got, err := UserIndexEntries(root) + if err != nil { + t.Fatalf("Entries: %v", err) + } + if len(got) != 3 { + t.Fatalf("got %d entries, want 3", len(got)) + } + wantOrder := []string{"ou_bob", "ou_carol", "ou_alice"} + for i, w := range wantOrder { + if got[i].UserOpenId != w { + t.Errorf("position %d: got %q, want %q", i, got[i].UserOpenId, w) + } + } +} + +// Hand-craft equal LastUsed timestamps to force the tiebreak path. +func TestUserIndexEntriesTiebreakOnEqualLastUsed(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + + now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC) + idx := UserIndex{Users: map[string]UserIndexEntry{ + "cli_b:ou_alice": {AppId: "cli_b", UserOpenId: "ou_alice", LastUsed: now}, + "cli_a:ou_bob": {AppId: "cli_a", UserOpenId: "ou_bob", LastUsed: now}, + "cli_a:ou_alice": {AppId: "cli_a", UserOpenId: "ou_alice", LastUsed: now}, + }} + data, _ := MarshalJSONIndent(idx) + if err := root.SharedKV().Save(userIndexKey, data); err != nil { + t.Fatalf("seed: %v", err) + } + + got, err := UserIndexEntries(root) + if err != nil { + t.Fatalf("Entries: %v", err) + } + // Equal LastUsed → AppId asc, then UserOpenId asc. + wantKeys := []string{"cli_a:ou_alice", "cli_a:ou_bob", "cli_b:ou_alice"} + for i, want := range wantKeys { + gotKey := userIndexEntryKey(got[i].AppId, got[i].UserOpenId) + if gotKey != want { + t.Errorf("position %d: got %q, want %q", i, gotKey, want) + } + } +} + +// Delete on a missing row is nil — `auth users logout` runs Token+Profile+Index +// deletes and must be safe to repeat. +func TestDeleteUserIdempotent(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_x", "ou_alice") + + if err := DeleteUser(root, ctx); err != nil { + t.Errorf("Delete on absent index: %v", err) + } + + if err := RecordUserActivity(root, ctx, []string{"a"}); err != nil { + t.Fatalf("Record: %v", err) + } + if err := DeleteUser(root, ctx); err != nil { + t.Fatalf("first Delete: %v", err) + } + if err := DeleteUser(root, ctx); err != nil { + t.Errorf("second Delete (idempotent): %v", err) + } + idx, _ := LoadUserIndex(root) + if _, present := idx.Users["cli_x:ou_alice"]; present { + t.Error("alice still present after Delete") + } +} + +// Multi-app delete safety: logging out (cli_x, alice) must not touch (cli_y, alice). +func TestDeleteUserDoesNotAffectOtherApp(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + if err := RecordUserActivity(root, ForUser("cli_x", "ou_alice"), []string{"a"}); err != nil { + t.Fatalf("appX alice: %v", err) + } + if err := RecordUserActivity(root, ForUser("cli_y", "ou_alice"), []string{"b"}); err != nil { + t.Fatalf("appY alice: %v", err) + } + if err := DeleteUser(root, ForUser("cli_x", "ou_alice")); err != nil { + t.Fatalf("Delete: %v", err) + } + idx, _ := LoadUserIndex(root) + if _, present := idx.Users["cli_x:ou_alice"]; present { + t.Error("alice@appX still present after Delete") + } + if _, present := idx.Users["cli_y:ou_alice"]; !present { + t.Error("alice@appY was incorrectly deleted; multi-app safety broken") + } +} + +// Delete on a non-bound context must be a no-op — never materialise the file. +func TestDeleteUserNoOpForSingleUserAndAppOnly(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + for _, ctx := range []AuthContext{SingleUser(), AppOnly("cli_x")} { + if err := DeleteUser(root, ctx); err != nil { + t.Errorf("Delete(%+v): %v", ctx, err) + } + } + if _, err := os.Stat(filepath.Join(dir, "user_index.json")); err == nil { + t.Error("Delete on non-bound ctx materialised user_index.json") + } +} + +// Corrupt index must collapse to empty (not error) so the next Record reseeds. +func TestLoadUserIndexCorruptFileRecoversEmpty(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + if err := os.WriteFile(filepath.Join(dir, "user_index.json"), []byte("{not json"), 0o600); err != nil { + t.Fatalf("seed corrupt: %v", err) + } + + idx, err := LoadUserIndex(root) + if err != nil { + t.Fatalf("Load on corrupt: %v", err) + } + if len(idx.Users) != 0 { + t.Errorf("corrupt file did not collapse to empty: got %d entries", len(idx.Users)) + } + + if err := RecordUserActivity(root, ForUser("cli_x", "ou_alice"), []string{"a"}); err != nil { + t.Fatalf("Record after corrupt: %v", err) + } + idx, _ = LoadUserIndex(root) + if _, ok := idx.Users["cli_x:ou_alice"]; !ok { + t.Fatal("post-corruption Record did not land") + } +} + +// All entry points return errIndexNilRoot rather than panicking on nil. +func TestUserIndexNilRootRejected(t *testing.T) { + if _, err := LoadUserIndex(nil); !errors.Is(err, errIndexNilRoot) { + t.Errorf("Load(nil): err = %v, want errIndexNilRoot", err) + } + if err := RecordUserActivity(nil, ForUser("a", "u"), []string{"a"}); !errors.Is(err, errIndexNilRoot) { + t.Errorf("Record(nil): err = %v, want errIndexNilRoot", err) + } + if err := DeleteUser(nil, ForUser("a", "u")); !errors.Is(err, errIndexNilRoot) { + t.Errorf("Delete(nil): err = %v, want errIndexNilRoot", err) + } + if _, err := UserIndexEntries(nil); !errors.Is(err, errIndexNilRoot) { + t.Errorf("Entries(nil): err = %v, want errIndexNilRoot", err) + } +} + +// Defensive: a context with whitespace-only fields must yield "" so Record/Delete +// no-op rather than write a poisoned key. Constructors trim, so we build directly. +func TestKeyForBlankFieldsReturnsEmpty(t *testing.T) { + cases := []AuthContext{ + {appId: " ", userOpenId: "ou_a"}, + {appId: "cli_x", userOpenId: " "}, + {appId: " ", userOpenId: " "}, + } + for _, c := range cases { + if got := keyFor(c); got != "" { + t.Errorf("keyFor(%+v) = %q, want empty", c, got) + } + } +} + +// Concurrent Records on the same row must serialise — no torn rows or lost updates. +func TestRecordUserActivityConcurrentInProcess(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_x", "ou_alice") + + var wg sync.WaitGroup + const N = 20 + for i := 0; i < N; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if err := RecordUserActivity(root, ctx, []string{"docs:read"}); err != nil { + t.Errorf("concurrent Record: %v", err) + } + }() + } + wg.Wait() + + idx, err := LoadUserIndex(root) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(idx.Users) != 1 { + t.Errorf("got %d rows, want 1 (all goroutines targeted one user)", len(idx.Users)) + } + got, ok := idx.Users["cli_x:ou_alice"] + if !ok || got.LastScopes != "docs:read" { + t.Errorf("after concurrent Record: got %+v ok=%v", got, ok) + } +} + +// AppOnly Record must not create the users/ subtree — that path layout is +// reserved for ForUser contexts. +func TestRecordUserActivityCreatesNoUsersDirForLegacyAppOnly(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + if err := RecordUserActivity(root, AppOnly("cli_x"), []string{"a"}); err != nil { + t.Fatalf("AppOnly Record: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "users")); err == nil { + t.Error("AppOnly Record created users/ directory; should be no-op") + } +} + +// On-disk file is two-space indented JSON (operators occasionally cat it). +// MarshalJSONIndent's contract is locked in storage_test.go; this checks the consumer. +func TestUserIndexJSONIsTwoSpaceIndented(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + if err := RecordUserActivity(root, ForUser("cli_x", "ou_alice"), []string{"a"}); err != nil { + t.Fatalf("Record: %v", err) + } + data, _ := os.ReadFile(filepath.Join(dir, "user_index.json")) + if !strings.Contains(string(data), "\n \"users\":") { + t.Errorf("user_index.json is not two-space indented:\n%s", data) + } +} diff --git a/internal/auth/user_profile.go b/internal/auth/user_profile.go new file mode 100644 index 000000000..a242cc709 --- /dev/null +++ b/internal/auth/user_profile.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "time" +) + +// UserProfile is the cached, non-secret user identity for one AuthContext. +// OAuth tokens for the same user live in the OS keychain (see +// keychainTokenStore); this struct holds only the public metadata used to +// avoid a /authen/v1/user_info round trip. +// +// JSON field names are camelCase to match AppUser (core/config.go) and +// StoredUAToken (token_store.go) so shared fields round-trip byte-identically +// across all three on-disk shapes. +// +// UnionId/UserName/FirstAuthAt are omitempty so older profiles load losslessly. +// FirstAuthAt is write-once: see SaveUserProfileFor for the merge rule. +type UserProfile struct { + UserOpenId string `json:"userOpenId"` + UnionId string `json:"unionId,omitempty"` + UserName string `json:"userName,omitempty"` + CachedAt time.Time `json:"cachedAt"` + FirstAuthAt time.Time `json:"firstAuthAt,omitempty"` +} + +// userProfileKey is the KVStore slot for Load/Save/Delete. Constant so a +// future SQL backend's row name matches the file backend's filename stem +// ("user_profile.json") byte-for-byte. +const userProfileKey = "user_profile" + +// errProfileEmptyOpenId is returned when saving a profile with no +// UserOpenId. Sentinel so callers can errors.Is it. +var errProfileEmptyOpenId = errors.New("auth: UserProfile.UserOpenId is empty") + +// LoadUserProfileFor reads the cached profile for ctx via root. +// +// Returns (nil, nil) on miss, mirroring KVStore.Load semantics so first-run +// callers don't need an os.ErrNotExist check. A profile loaded without a +// UserOpenId is also treated as missing — it is structurally unroutable, and +// the next login overwrites it cleanly. +// +// SingleUser/AppOnly contexts read from the legacy ; see +// LocalRoot.userDir. +func LoadUserProfileFor(root Root, ctx AuthContext) (*UserProfile, error) { + if root == nil { + return nil, errors.New("auth: LoadUserProfileFor: root is nil") + } + data, ok, err := root.KV(ctx).Load(userProfileKey) + if err != nil { + return nil, fmt.Errorf("auth: load user profile: %w", err) + } + if !ok { + return nil, nil + } + var p UserProfile + if err := json.Unmarshal(data, &p); err != nil { + return nil, fmt.Errorf("auth: parse user profile: %w", err) + } + if p.UserOpenId == "" { + return nil, nil + } + return &p, nil +} + +// SaveUserProfileFor writes p for ctx via root. +// +// p.UserOpenId must be non-empty, and when ctx.HasUser() must equal +// ctx.UserOpenId() — a mismatch would land one user's profile under another +// user's directory. +// +// CachedAt defaults to time.Now() when zero. FirstAuthAt is preserved across +// rewrites: SaveUserProfileFor runs on every login refresh, but FirstAuthAt +// is write-once. The extra Load per Save is cheap on the file backend and +// forward-safe for a SQL backend (UPDATE ... COALESCE). +func SaveUserProfileFor(root Root, ctx AuthContext, p UserProfile) error { + if root == nil { + return errors.New("auth: SaveUserProfileFor: root is nil") + } + if p.UserOpenId == "" { + return errProfileEmptyOpenId + } + if ctx.HasUser() && ctx.UserOpenId() != p.UserOpenId { + return fmt.Errorf("auth: SaveUserProfileFor: ctx.UserOpenId=%q does not match profile.UserOpenId=%q", ctx.UserOpenId(), p.UserOpenId) + } + + if p.CachedAt.IsZero() { + p.CachedAt = time.Now() + } + + // Preserve FirstAuthAt across rewrites — see method docstring. + if p.FirstAuthAt.IsZero() { + if existing, err := LoadUserProfileFor(root, ctx); err == nil && existing != nil && !existing.FirstAuthAt.IsZero() { + p.FirstAuthAt = existing.FirstAuthAt + } else { + p.FirstAuthAt = p.CachedAt + } + } + + data, err := MarshalJSONIndent(p) + if err != nil { + return fmt.Errorf("auth: marshal user profile: %w", err) + } + if err := root.KV(ctx).Save(userProfileKey, data); err != nil { + return fmt.Errorf("auth: save user profile: %w", err) + } + return nil +} + +// DeleteUserProfileFor removes the cached profile for ctx via root. +// Idempotent: deleting a missing profile is not an error. +func DeleteUserProfileFor(root Root, ctx AuthContext) error { + if root == nil { + return errors.New("auth: DeleteUserProfileFor: root is nil") + } + if err := root.KV(ctx).Delete(userProfileKey); err != nil { + return fmt.Errorf("auth: delete user profile: %w", err) + } + return nil +} diff --git a/internal/auth/user_profile_test.go b/internal/auth/user_profile_test.go new file mode 100644 index 000000000..f775574f3 --- /dev/null +++ b/internal/auth/user_profile_test.go @@ -0,0 +1,306 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// A miss is (nil, nil); callers don't special-case "no profile yet". +func TestLoadUserProfileForMissReturnsNoError(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + got, err := LoadUserProfileFor(root, ForUser("cli_x", "ou_a")) + if err != nil { + t.Fatalf("Load on empty: unexpected err %v", err) + } + if got != nil { + t.Fatalf("Load on empty: got %+v, want nil", got) + } +} + +// CachedAt and FirstAuthAt are defaulted on first Save. +func TestSaveLoadUserProfileRoundTrip(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_x", "ou_a") + + p := UserProfile{ + UserOpenId: "ou_a", + UnionId: "on_a", + UserName: "Alice", + } + if err := SaveUserProfileFor(root, ctx, p); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := LoadUserProfileFor(root, ctx) + if err != nil { + t.Fatalf("Load after Save: %v", err) + } + if got == nil { + t.Fatalf("Load after Save: got nil") + } + if got.UserOpenId != "ou_a" || got.UnionId != "on_a" || got.UserName != "Alice" { + t.Errorf("Load returned %+v, want UserOpenId=ou_a UnionId=on_a UserName=Alice", got) + } + if got.CachedAt.IsZero() { + t.Error("CachedAt was zero after Save; should have been defaulted") + } + if got.FirstAuthAt.IsZero() { + t.Error("FirstAuthAt was zero after first Save; should equal CachedAt") + } +} + +// A second Save with FirstAuthAt zero must recover it from disk, not clobber. +func TestSaveUserProfilePreservesFirstAuthAt(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_x", "ou_a") + + first := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + if err := SaveUserProfileFor(root, ctx, UserProfile{ + UserOpenId: "ou_a", + UserName: "Alice", + CachedAt: first, + FirstAuthAt: first, + }); err != nil { + t.Fatalf("first Save: %v", err) + } + + // FirstAuthAt deliberately zero — should be recovered from disk. + second := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC) + if err := SaveUserProfileFor(root, ctx, UserProfile{ + UserOpenId: "ou_a", + UserName: "Alice (renamed)", + CachedAt: second, + }); err != nil { + t.Fatalf("second Save: %v", err) + } + + got, err := LoadUserProfileFor(root, ctx) + if err != nil || got == nil { + t.Fatalf("Load after second Save: got=%v err=%v", got, err) + } + if !got.FirstAuthAt.Equal(first) { + t.Errorf("FirstAuthAt was overwritten: got %v, want %v", got.FirstAuthAt, first) + } + if !got.CachedAt.Equal(second) { + t.Errorf("CachedAt did not advance: got %v, want %v", got.CachedAt, second) + } + if got.UserName != "Alice (renamed)" { + t.Errorf("UserName did not update: got %q", got.UserName) + } +} + +// First-save branch: zero FirstAuthAt is stamped to CachedAt. +func TestSaveUserProfileFirstAuthAtDefaultsToCachedAt(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_x", "ou_a") + + if err := SaveUserProfileFor(root, ctx, UserProfile{ + UserOpenId: "ou_a", + UserName: "Alice", + }); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := LoadUserProfileFor(root, ctx) + if err != nil || got == nil { + t.Fatalf("Load: %v", err) + } + if got.FirstAuthAt.IsZero() { + t.Fatal("FirstAuthAt was zero after first Save") + } + if !got.FirstAuthAt.Equal(got.CachedAt) { + t.Errorf("FirstAuthAt = %v, want equal to CachedAt = %v", got.FirstAuthAt, got.CachedAt) + } +} + +// Empty UserOpenId returns errProfileEmptyOpenId so callers can errors.Is it. +func TestSaveUserProfileRejectsEmptyOpenId(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + err := SaveUserProfileFor(root, ForUser("cli_x", "ou_a"), UserProfile{ + UserName: "Alice (no open id)", + }) + if !errors.Is(err, errProfileEmptyOpenId) { + t.Errorf("Save with empty UserOpenId: err = %v, want errProfileEmptyOpenId", err) + } +} + +// Ctx/profile UserOpenId mismatch errors and names both ids in the message. +func TestSaveUserProfileRejectsCtxMismatch(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + err := SaveUserProfileFor(root, ForUser("cli_x", "ou_alice"), UserProfile{ + UserOpenId: "ou_bob", + UserName: "Bob masquerading", + }) + if err == nil { + t.Fatal("Save with ctx/profile mismatch: expected error, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "ou_alice") || !strings.Contains(msg, "ou_bob") { + t.Errorf("error %q should mention both UserOpenIds", msg) + } +} + +// AppOnly ctx is allowed; profile.UserOpenId carries the identity. +func TestSaveUserProfileAllowsAppOnlyCtx(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + if err := SaveUserProfileFor(root, AppOnly("cli_x"), UserProfile{ + UserOpenId: "ou_a", + UserName: "Alice", + }); err != nil { + t.Errorf("Save with AppOnly ctx: %v", err) + } +} + +// Locks on-disk filename — user-index logic and operator diagnostics depend on it. +func TestUserProfileFileLandsOnDisk(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + ctx := ForUser("cli_x", "ou_a") + if err := SaveUserProfileFor(root, ctx, UserProfile{UserOpenId: "ou_a"}); err != nil { + t.Fatalf("Save: %v", err) + } + want := filepath.Join(dir, "users", "cli_x", "ou_a", "user_profile.json") + if _, err := os.Stat(want); err != nil { + t.Errorf("expected user_profile.json at %s, stat err: %v", want, err) + } +} + +// File without userOpenId is treated as absent so a Save can repair it. +func TestLoadUserProfileMissingOpenIdTreatedAsAbsent(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + ctx := ForUser("cli_x", "ou_a") + target := filepath.Join(dir, "users", "cli_x", "ou_a", "user_profile.json") + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(target, []byte(`{"userName":"unknown"}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + got, err := LoadUserProfileFor(root, ctx) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got != nil { + t.Errorf("Load on userOpenId-less file: got %+v, want nil", got) + } +} + +// Corrupt JSON surfaces as error, not as a silent miss. +func TestLoadUserProfileCorruptJSONReturnsError(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + ctx := ForUser("cli_x", "ou_a") + target := filepath.Join(dir, "users", "cli_x", "ou_a", "user_profile.json") + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(target, []byte(`{not json`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + _, err := LoadUserProfileFor(root, ctx) + if err == nil { + t.Error("Load on corrupt JSON: expected error, got nil") + } +} + +// Delete on a missing profile is not an error; `auth users logout` deletes blindly. +func TestDeleteUserProfileForIdempotent(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + ctx := ForUser("cli_x", "ou_a") + if err := DeleteUserProfileFor(root, ctx); err != nil { + t.Errorf("Delete on absent profile: %v", err) + } + + if err := SaveUserProfileFor(root, ctx, UserProfile{UserOpenId: "ou_a"}); err != nil { + t.Fatalf("Save: %v", err) + } + if err := DeleteUserProfileFor(root, ctx); err != nil { + t.Errorf("first Delete: %v", err) + } + if err := DeleteUserProfileFor(root, ctx); err != nil { + t.Errorf("second Delete (idempotent): %v", err) + } + got, err := LoadUserProfileFor(root, ctx) + if err != nil || got != nil { + t.Errorf("Load after Delete: got=%v err=%v, want (nil, nil)", got, err) + } +} + +// Nil root surfaces as typed error, not nil-pointer panic. +func TestUserProfileNilRootRejected(t *testing.T) { + if _, err := LoadUserProfileFor(nil, ForUser("a", "u")); err == nil { + t.Error("Load with nil root: expected error") + } + if err := SaveUserProfileFor(nil, ForUser("a", "u"), UserProfile{UserOpenId: "u"}); err == nil { + t.Error("Save with nil root: expected error") + } + if err := DeleteUserProfileFor(nil, ForUser("a", "u")); err == nil { + t.Error("Delete with nil root: expected error") + } +} + +// SingleUser stays at root; must not be routed under users/. +func TestUserProfileSingleUserCtxLandsAtLegacyDir(t *testing.T) { + dir := t.TempDir() + root := NewLocalRoot(dir) + + if err := SaveUserProfileFor(root, SingleUser(), UserProfile{ + UserOpenId: "ou_legacy", + UserName: "legacy", + }); err != nil { + t.Fatalf("Save: %v", err) + } + + want := filepath.Join(dir, "user_profile.json") + if _, err := os.Stat(want); err != nil { + t.Errorf("expected legacy path %s, stat err: %v", want, err) + } + bad := filepath.Join(dir, "users") + if _, err := os.Stat(bad); err == nil { + t.Errorf("legacy SingleUser should not have created users/ dir, but %s exists", bad) + } +} + +// Two users share neither file nor data; deleting alice must not touch bob. +func TestSaveUserProfileTwoUsersIsolated(t *testing.T) { + root := NewLocalRoot(t.TempDir()) + alice := ForUser("cli_x", "ou_alice") + bob := ForUser("cli_x", "ou_bob") + + if err := SaveUserProfileFor(root, alice, UserProfile{UserOpenId: "ou_alice", UserName: "Alice"}); err != nil { + t.Fatalf("alice Save: %v", err) + } + if err := SaveUserProfileFor(root, bob, UserProfile{UserOpenId: "ou_bob", UserName: "Bob"}); err != nil { + t.Fatalf("bob Save: %v", err) + } + + a, err := LoadUserProfileFor(root, alice) + if err != nil || a == nil || a.UserName != "Alice" { + t.Errorf("alice Load: got %+v err %v", a, err) + } + b, err := LoadUserProfileFor(root, bob) + if err != nil || b == nil || b.UserName != "Bob" { + t.Errorf("bob Load: got %+v err %v", b, err) + } + + // Delete alice — bob's profile must survive. + if err := DeleteUserProfileFor(root, alice); err != nil { + t.Fatalf("alice Delete: %v", err) + } + if a, err := LoadUserProfileFor(root, alice); err != nil || a != nil { + t.Errorf("alice after Delete: got %+v err %v", a, err) + } + if b, err := LoadUserProfileFor(root, bob); err != nil || b == nil { + t.Errorf("bob after alice's Delete: got %+v err %v, want still present", b, err) + } +} diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 827d7e58d..7666dd526 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -21,32 +21,35 @@ import ( "github.com/larksuite/cli/internal/keychain" ) -// Factory holds shared dependencies injected into every command. -// All function fields are lazily initialized and cached after first call. -// In tests, replace any field to stub out external dependencies. type InvocationContext struct { Profile string + // Resolved --user / LARKSUITE_CLI_OPEN_ID override (empty when neither was + // provided); falls through to AppConfig.CurrentUser then Users[0] in + // core.ResolveConfigFromMulti. + UserOpenId string + // "flag", "env", or ""; downstream error hints branch on this so an env + // override miss gets different remediation copy than a --user miss. + UserSource string } type Factory struct { - Config func() (*core.CliConfig, error) // lazily loads app config from Credential - HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers) - LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls - IOStreams *IOStreams // stdin/stdout/stderr streams + Config func() (*core.CliConfig, error) + HttpClient func() (*http.Client, error) + LarkClient func() (*lark.Client, error) + IOStreams *IOStreams - Invocation InvocationContext // Immutable call context; do not mutate after Factory construction. - Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests) - IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected - ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call - CurrentCommand *cobra.Command // last matched command being executed; set during PersistentPreRun + Invocation InvocationContext // immutable; do not mutate after construction + Keychain keychain.KeychainAccess + IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected + ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call + CurrentCommand *cobra.Command Credential *credential.CredentialProvider - FileIOProvider fileio.Provider // file transfer provider (default: local filesystem) + FileIOProvider fileio.Provider } // ResolveFileIO resolves a FileIO instance using the current execution context. -// The provider controls whether the returned instance is fresh or cached. func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO { if f == nil || f.FileIOProvider == nil { return nil @@ -54,9 +57,8 @@ func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO { return f.FileIOProvider.ResolveFileIO(ctx) } -// ResolveAs returns the effective identity type. -// If the user explicitly passed --as, use that value; otherwise use the configured default. -// When the value is "auto" (or unset), auto-detect based on credential hints. +// ResolveAs returns the effective identity. Explicit --as wins; otherwise the +// configured default-as, then auto-detect from credential hints. func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core.Identity) core.Identity { f.IdentityAutoDetected = false @@ -84,7 +86,6 @@ func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core } } - // Auto-detect based on credential hint f.IdentityAutoDetected = true result := autoDetectIdentityFromHint(hint) f.ResolvedIdentity = result @@ -117,8 +118,7 @@ func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityH } // CheckIdentity verifies the resolved identity is in the supported list. -// On success, sets f.ResolvedIdentity. On failure, returns an error -// tailored to whether the identity was explicit (--as) or auto-detected. +// Error copy differs based on whether the identity was explicit (--as) or auto-detected. func (f *Factory) CheckIdentity(as core.Identity, supported []string) error { for _, t := range supported { if string(as) == t { @@ -142,8 +142,8 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error { WithParam("--as") } -// ResolveStrictMode returns the effective strict mode by reading -// Account.SupportedIdentities from the credential provider chain. +// ResolveStrictMode reads Account.SupportedIdentities from the credential +// provider chain. func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode { if f.Credential == nil { return core.StrictModeOff @@ -174,8 +174,9 @@ func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error { return nil } -// NewAPIClient creates an APIClient using the Factory's base Config (app credentials only). -// For user-mode calls where the correct user profile matters, use NewAPIClientWithConfig instead. +// NewAPIClient creates an APIClient using the Factory's base Config (app +// credentials only). For user-mode calls where the correct user profile +// matters, use NewAPIClientWithConfig instead. func (f *Factory) NewAPIClient() (*client.APIClient, error) { cfg, err := f.Config() if err != nil { @@ -185,7 +186,6 @@ func (f *Factory) NewAPIClient() (*client.APIClient, error) { } // NewAPIClientWithConfig creates an APIClient with an explicit config. -// Use this when the caller has already resolved the correct config. func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient, error) { sdk, err := f.LarkClient() if err != nil { @@ -209,12 +209,9 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient } // RequireBuiltinCredentialProvider returns a typed validation error when an -// extension provider is actively managing credentials. Intended for use as -// PersistentPreRunE on the auth and config parent commands. -// -// Returns nil when: -// - f.Credential is nil (test environments without credential setup) -// - No extension provider is active (built-in keychain/config path is used) +// extension provider is actively managing credentials. Intended as +// PersistentPreRunE on the auth and config parent commands. Returns nil when +// f.Credential is nil or no extension provider is active. func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command string) error { if f.Credential == nil { return nil diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index 514aaf93f..dc0a08981 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -28,12 +28,7 @@ import ( ) // NewDefault creates a production Factory with cached closures. -// Initialization follows a credential-first order: -// -// Phase 1: HttpClient (no credential dependency) -// Phase 2: Credential (sole data source for account info) -// Phase 3: Config derived from Credential -// Phase 4: LarkClient derived from Credential +// Credential is the sole data source; Config and LarkClient derive from it. func NewDefault(streams *IOStreams, inv InvocationContext) *Factory { streams = normalizeStreams(streams) f := &Factory{ @@ -42,27 +37,23 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory { IOStreams: streams, } - // Workspace detection: determines which config subtree to use. - // Must run before any config or credential load, since those paths are - // workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged. + // Must run before any config or credential load — those paths are workspace-scoped. ws := core.DetectWorkspaceFromEnv(os.Getenv) core.SetCurrentWorkspace(ws) - // Inject workspace-aware dir into keychain's log system. - // This breaks the core↔keychain import cycle by using a function variable. + // Function variable breaks the core↔keychain import cycle. keychain.RuntimeDirFunc = core.GetRuntimeDir - // Phase 0: FileIO provider (no dependency) f.FileIOProvider = fileio.GetProvider() - // Phase 1: HttpClient (no credential dependency) f.HttpClient = cachedHttpClientFunc(f) - // Phase 2: Credential (sole data source) - // Keychain is read via closure so callers can replace f.Keychain after construction. + // Keychain read via closure so callers can replace f.Keychain after construction. f.Credential = buildCredentialProvider(credentialDeps{ Keychain: func() keychain.KeychainAccess { return f.Keychain }, Profile: inv.Profile, + UserOpenId: inv.UserOpenId, + UserSource: inv.UserSource, HttpClient: f.HttpClient, ErrOut: f.IOStreams.ErrOut, }) @@ -78,16 +69,13 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory { return cfg, nil }) - // Phase 4: LarkClient from Credential (placeholder AppSecret) f.LarkClient = cachedLarkClientFunc(f) return f } -// safeRedirectPolicy prevents credential headers from being forwarded -// when a response redirects to a different host (e.g. Lark API 302 → CDN). -// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host -// redirects; other headers like X-Cli-* pass through. +// safeRedirectPolicy strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT +// on cross-host redirects (e.g. Lark API 302 → CDN). Other headers pass through. func safeRedirectPolicy(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("too many redirects") @@ -107,7 +95,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) { var rt http.RoundTripper = transport.Shared() rt = &RetryTransport{Base: rt} rt = &SecurityHeaderTransport{Base: rt} - rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor + rt = &auth.SecurityPolicyTransport{Base: rt} rt = wrapWithExtension(rt) client := &http.Client{ Transport: rt, @@ -152,19 +140,19 @@ func buildSDKTransport() http.RoundTripper { type credentialDeps struct { Keychain func() keychain.KeychainAccess Profile string + UserOpenId string + UserSource string HttpClient func() (*http.Client, error) ErrOut io.Writer } func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider { providers := extcred.Providers() - defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile) + defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile, deps.UserOpenId, deps.UserSource) defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut) - // NOTE: Do not pass deps.ErrOut as warnOut. Credential resolution - // happens before the command runs, so any plain-text warning written - // to stderr would break the JSON envelope contract that AI agents - // depend on. enrichUserInfo failures are already non-fatal (the - // provider clears unverified identity fields), so silencing the - // warning is safe. + // Do not pass deps.ErrOut as warnOut: credential resolution runs before the + // command, so plain-text warnings on stderr would break the JSON envelope + // contract that AI agents depend on. enrichUserInfo failures are already + // non-fatal (provider clears unverified identity fields). return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient) } diff --git a/internal/cmdutil/factory_default_step7_test.go b/internal/cmdutil/factory_default_step7_test.go new file mode 100644 index 000000000..15ece8cc6 --- /dev/null +++ b/internal/cmdutil/factory_default_step7_test.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" +) + +// End-to-end checks that InvocationContext.UserOpenId/UserSource flow through +// NewDefault → credentialDeps → DefaultAccountProvider. + +func TestNewDefault_InvocationUserOpenIdSelectsRequestedUser(t *testing.T) { + t.Setenv(envvars.CliAppID, "") + t.Setenv(envvars.CliAppSecret, "") + t.Setenv(envvars.CliUserAccessToken, "") + t.Setenv(envvars.CliTenantAccessToken, "") + t.Setenv(envvars.CliOpenID, "") // env must not participate at this layer + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + multi := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + {UserOpenId: "ou_b", UserName: "Bob"}, + }, + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + f := NewDefault(nil, InvocationContext{UserOpenId: "ou_b", UserSource: "flag"}) + cfg, err := f.Config() + if err != nil { + t.Fatalf("Config: %v", err) + } + if cfg.UserOpenId != "ou_b" { + t.Errorf("UserOpenId = %q, want ou_b (override should beat Users[0])", cfg.UserOpenId) + } + if cfg.UserName != "Bob" { + t.Errorf("UserName = %q, want Bob", cfg.UserName) + } +} + +// Env-sourced override miss must surface the LARKSUITE_CLI_OPEN_ID hint. +func TestNewDefault_UserOverrideMissProducesEnvHintWhenSourceEnv(t *testing.T) { + t.Setenv(envvars.CliAppID, "") + t.Setenv(envvars.CliAppSecret, "") + t.Setenv(envvars.CliUserAccessToken, "") + t.Setenv(envvars.CliTenantAccessToken, "") + t.Setenv(envvars.CliOpenID, "") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + multi := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_a", UserName: "Alice"}}, + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + f := NewDefault(nil, InvocationContext{UserOpenId: "ou_ghost", UserSource: "env"}) + _, err := f.Config() + if err == nil { + t.Fatal("expected user-miss error, got nil") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *core.ConfigError, got %T", err) + } + if !strings.Contains(cfgErr.Hint, "LARKSUITE_CLI_OPEN_ID") { + t.Errorf("env-sourced miss hint should mention env var, got: %q", cfgErr.Hint) + } +} + +// Zero-value InvocationContext must keep legacy single-user installs working. +func TestNewDefault_EmptyUserOpenIdPreservesLegacyBehaviour(t *testing.T) { + t.Setenv(envvars.CliAppID, "") + t.Setenv(envvars.CliAppSecret, "") + t.Setenv(envvars.CliUserAccessToken, "") + t.Setenv(envvars.CliTenantAccessToken, "") + t.Setenv(envvars.CliOpenID, "") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + multi := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_a", UserName: "Alice"}}, + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + f := NewDefault(nil, InvocationContext{}) + cfg, err := f.Config() + if err != nil { + t.Fatalf("Config: %v", err) + } + if cfg.UserOpenId != "ou_a" { + t.Errorf("UserOpenId = %q, want ou_a (legacy single-user fallthrough)", cfg.UserOpenId) + } +} diff --git a/internal/core/config.go b/internal/core/config.go index 040b59b38..d39a1220b 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -9,6 +9,7 @@ import ( "fmt" "path/filepath" "strings" + "time" "unicode/utf8" "github.com/larksuite/cli/internal/i18n" @@ -18,7 +19,7 @@ import ( "github.com/larksuite/cli/internal/vfs" ) -// Identity represents the caller identity for API requests. +// Identity is the caller identity for API requests. type Identity string const ( @@ -27,29 +28,47 @@ const ( AsAuto Identity = "auto" ) -// IsBot returns true if the identity is bot. func (id Identity) IsBot() bool { return id == AsBot } // AppUser is a logged-in user record stored in config. +// +// All multi-user fields are `omitempty` so legacy config.json files +// load losslessly. Time fields are *time.Time because encoding/json +// does not honor omitempty on time.Time structs — pointer typing +// keeps zero-value timestamps from polluting saved files. +// +// CachedAt, LastUsed and LastScopes are a denormalised cache of +// user_index.json (the source of truth) — duplicated here so +// `lark config show` need not load the index. If they disagree, +// trust the index; the migrator reconciles on upgrade. type AppUser struct { - UserOpenId string `json:"userOpenId"` - UserName string `json:"userName"` + UserOpenId string `json:"userOpenId"` + UserName string `json:"userName"` + UnionId string `json:"unionId,omitempty"` + CachedAt *time.Time `json:"cachedAt,omitempty"` + FirstAuthAt *time.Time `json:"firstAuthAt,omitempty"` + LastUsed *time.Time `json:"lastUsed,omitempty"` + LastScopes string `json:"lastScopes,omitempty"` } // AppConfig is a per-app configuration entry (stored format — secrets may be unresolved). +// +// CurrentUser names the active user within Users[] for this app; empty +// means fall back to Users[0] so legacy config.json files resolve +// identically. Resolution order is --user > CurrentUser > Users[0]. type AppConfig struct { - Name string `json:"name,omitempty"` - AppId string `json:"appId"` - AppSecret SecretInput `json:"appSecret"` - Brand LarkBrand `json:"brand"` - Lang i18n.Lang `json:"lang,omitempty"` - DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto - StrictMode *StrictMode `json:"strictMode,omitempty"` - Users []AppUser `json:"users"` + Name string `json:"name,omitempty"` + AppId string `json:"appId"` + AppSecret SecretInput `json:"appSecret"` + Brand LarkBrand `json:"brand"` + Lang i18n.Lang `json:"lang,omitempty"` + DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto + StrictMode *StrictMode `json:"strictMode,omitempty"` + CurrentUser string `json:"currentUser,omitempty"` + Users []AppUser `json:"users"` } -// ProfileName returns the display name for this app config. -// If Name is set, returns Name; otherwise falls back to AppId. +// ProfileName returns the display name for this app config (Name, or AppId fallback). func (a *AppConfig) ProfileName() string { if a.Name != "" { return a.Name @@ -57,16 +76,93 @@ func (a *AppConfig) ProfileName() string { return a.AppId } +// FindUser looks up a user in this profile by UserOpenId, then UserName. +// Returns nil if not found. +// +// OpenId-first (vs FindApp's Name-first) is deliberate: open_ids are +// issuance-unambiguous, while display names are server-provided and +// can collide within a tenant — matching by name first would risk +// resolving `auth users use ou_xxx` to a same-named user. +// +// Empty input returns nil so AppUsers with empty UserName cannot be +// matched by passing "". +func (a *AppConfig) FindUser(idOrName string) *AppUser { + if idOrName == "" { + return nil + } + for i := range a.Users { + if a.Users[i].UserOpenId == idOrName { + return &a.Users[i] + } + } + for i := range a.Users { + if a.Users[i].UserName != "" && a.Users[i].UserName == idOrName { + return &a.Users[i] + } + } + return nil +} + +// FindUserIndex returns the index of the matching user, or -1 if not +// found. Same OpenId-first two-pass policy as FindUser. +func (a *AppConfig) FindUserIndex(idOrName string) int { + if idOrName == "" { + return -1 + } + for i := range a.Users { + if a.Users[i].UserOpenId == idOrName { + return i + } + } + for i := range a.Users { + if a.Users[i].UserName != "" && a.Users[i].UserName == idOrName { + return i + } + } + return -1 +} + +// UserNames returns "name (open_id)" for each user in this profile, or +// just the open_id if name is empty. Order matches Users[] insertion order. +func (a *AppConfig) UserNames() []string { + out := make([]string, len(a.Users)) + for i := range a.Users { + if a.Users[i].UserName != "" { + out[i] = a.Users[i].UserName + " (" + a.Users[i].UserOpenId + ")" + } else { + out[i] = a.Users[i].UserOpenId + } + } + return out +} + +// CurrentSchemaVersion is the schema-version stamp written into every +// MultiAppConfig saved by this binary. Bump only for non-additive +// changes that need a migrator dispatch. +// +// - 0: legacy (pre-multi-user; default zero-value). +// - 1: multi-user fields (AppUser.UnionId/CachedAt/FirstAuthAt/ +// LastUsed/LastScopes, AppConfig.CurrentUser, user_index.json + +// user_profile.json on disk). +// +// Additive `omitempty` changes do NOT bump this; a bump should be +// reserved for renames/retypes that older binaries cannot read losslessly. +const CurrentSchemaVersion = 1 + // MultiAppConfig is the multi-app config file format. +// +// SchemaVersion is omitempty so legacy files load to zero; the migrator +// triggers on `cfg.SchemaVersion < CurrentSchemaVersion`. type MultiAppConfig struct { - StrictMode StrictMode `json:"strictMode,omitempty"` - CurrentApp string `json:"currentApp,omitempty"` - PreviousApp string `json:"previousApp,omitempty"` - Apps []AppConfig `json:"apps"` + SchemaVersion int `json:"schemaVersion,omitempty"` + StrictMode StrictMode `json:"strictMode,omitempty"` + CurrentApp string `json:"currentApp,omitempty"` + PreviousApp string `json:"previousApp,omitempty"` + Apps []AppConfig `json:"apps"` } -// CurrentAppConfig returns the currently active app config. -// Resolution priority: profileOverride > CurrentApp field > Apps[0]. +// CurrentAppConfig returns the active app config. +// Resolution priority: profileOverride > CurrentApp > Apps[0]. func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig { if profileOverride != "" { if app := m.FindApp(profileOverride); app != nil { @@ -86,17 +182,15 @@ func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig { return nil } -// FindApp looks up an app by name, then by appId. Returns nil if not found. -// Name match takes priority: if profile A has Name "X" and profile B has AppId "X", -// FindApp("X") returns profile A. +// FindApp looks up an app by Name first, then AppId. Name match wins +// when both collide, matching the user-chosen-uniqueness contract on +// profile names. Returns nil if not found. func (m *MultiAppConfig) FindApp(name string) *AppConfig { - // First pass: match by Name for i := range m.Apps { if m.Apps[i].Name != "" && m.Apps[i].Name == name { return &m.Apps[i] } } - // Second pass: match by AppId for i := range m.Apps { if m.Apps[i].AppId == name { return &m.Apps[i] @@ -105,7 +199,7 @@ func (m *MultiAppConfig) FindApp(name string) *AppConfig { return nil } -// FindAppIndex looks up an app index by name, then by appId. Returns -1 if not found. +// FindAppIndex is the index-returning sibling of FindApp. Returns -1 if not found. func (m *MultiAppConfig) FindAppIndex(name string) int { for i := range m.Apps { if m.Apps[i].Name != "" && m.Apps[i].Name == name { @@ -130,8 +224,8 @@ func (m *MultiAppConfig) ProfileNames() []string { } // ValidateProfileName checks that a profile name is valid. -// Rejects empty names, whitespace, control characters, and shell-problematic characters, -// but allows Unicode letters (e.g. Chinese, Japanese) for localized profile names. +// Allows Unicode letters (Chinese, Japanese, etc.) but rejects empty, +// over-long, control, and shell-problematic characters. func ValidateProfileName(name string) error { if name == "" { return fmt.Errorf("profile name cannot be empty") @@ -140,7 +234,7 @@ func ValidateProfileName(name string) error { return fmt.Errorf("profile name %q is too long (max 64 characters)", name) } for _, r := range name { - if r <= 0x1F || r == 0x7F { // control characters + if r <= 0x1F || r == 0x7F { return fmt.Errorf("invalid profile name %q: contains control characters", name) } switch r { @@ -175,9 +269,8 @@ func (c *CliConfig) CanBot() bool { } // GetConfigDir returns the config directory path for the current workspace. -// When workspace is local (default), this returns the same path as before -// (LARKSUITE_CLI_CONFIG_DIR or ~/.lark-cli) — fully backward-compatible. -// When workspace is openclaw/hermes, returns base/openclaw or base/hermes. +// Local workspace returns LARKSUITE_CLI_CONFIG_DIR or ~/.lark-cli (fully +// backward-compatible); openclaw/hermes return base/openclaw or base/hermes. func GetConfigDir() string { return GetRuntimeDir() } @@ -188,6 +281,10 @@ func GetConfigPath() string { } // LoadMultiAppConfig loads multi-app config from disk. +// +// Refuses to load a SchemaVersion higher than CurrentSchemaVersion: a +// future-binary file may carry fields we'd silently drop on re-save. +// Same-version or legacy 0 always loads (additive omitempty evolution). func LoadMultiAppConfig() (*MultiAppConfig, error) { data, err := vfs.ReadFile(GetConfigPath()) if err != nil { @@ -198,6 +295,16 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) { if err := json.Unmarshal(data, &multi); err != nil { return nil, fmt.Errorf("invalid config format: %w", err) } + if multi.SchemaVersion > CurrentSchemaVersion { + return nil, &ConfigError{ + Code: 3, + Type: "config", + Message: fmt.Sprintf( + "config.json was written by a newer lark-cli (schemaVersion %d > supported %d)", + multi.SchemaVersion, CurrentSchemaVersion), + Hint: "upgrade lark-cli, or use a different --profile to avoid overwriting fields the newer binary populated", + } + } if len(multi.Apps) == 0 { return nil, fmt.Errorf("invalid config format: no apps") } @@ -205,11 +312,18 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) { } // SaveMultiAppConfig saves config to disk. +// +// Stamps SchemaVersion = CurrentSchemaVersion if lower or zero. Never +// downgrades a higher SchemaVersion — only the migrator may touch the +// version field downward. func SaveMultiAppConfig(config *MultiAppConfig) error { dir := GetConfigDir() if err := vfs.MkdirAll(dir, 0700); err != nil { return err } + if config.SchemaVersion < CurrentSchemaVersion { + config.SchemaVersion = CurrentSchemaVersion + } data, err := json.MarshalIndent(config, "", " ") if err != nil { return err @@ -218,23 +332,46 @@ func SaveMultiAppConfig(config *MultiAppConfig) error { } // RequireConfig loads the single-app config using the default profile resolution. +// Backward-compatible thin forwarder. func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) { - return RequireConfigForProfile(kc, "") + return RequireConfigForProfileAndUser(kc, "", "") } // RequireConfigForProfile loads the single-app config for a specific profile. // Resolution priority: profileOverride > config.CurrentApp > Apps[0]. +// Backward-compatible thin forwarder around the *AndUser entry point. func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) { + return RequireConfigForProfileAndUser(kc, profileOverride, "") +} + +// RequireConfigForProfileAndUser is the multi-user-aware variant of +// RequireConfigForProfile. userOverride is the resolved user identity +// (--user / LARKSUITE_CLI_OPEN_ID); empty means "no override". +func RequireConfigForProfileAndUser(kc keychain.KeychainAccess, profileOverride, userOverride string) (*CliConfig, error) { raw, err := LoadMultiAppConfig() - if err != nil || raw == nil || len(raw.Apps) == 0 { + if err != nil { + return nil, PassThroughOrNotConfigured(err) + } + if raw == nil || len(raw.Apps) == 0 { return nil, NotConfiguredError() } - return ResolveConfigFromMulti(raw, kc, profileOverride) + return ResolveConfigFromMulti(raw, kc, profileOverride, userOverride) } -// ResolveConfigFromMulti resolves a single-app config from an already-loaded MultiAppConfig. -// This avoids re-reading the config file when the caller has already loaded it. -func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) { +// ResolveConfigFromMulti resolves a single-app config from an already-loaded +// MultiAppConfig. User selection follows a three-rung fallback: +// +// rung 1: userOverride (--user / LARKSUITE_CLI_OPEN_ID) +// rung 2: AppConfig.CurrentUser (set by `auth users use`) +// rung 3: Users[0] (legacy single-user compatibility) +// +// Both explicit-selector rungs ERROR on miss rather than silently +// falling through — preventing wrong-user data leaks (e.g. `--user +// ou_alice` against a profile holding only ou_bob must not dispatch +// as ou_bob). Only the empty/empty case picks Users[0]. +// +// userOverride matches via FindUser (UserOpenId-first, then UserName). +func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride, userOverride string) (*CliConfig, error) { app := raw.CurrentAppConfig(profileOverride) if app == nil { return nil, &ConfigError{ @@ -242,6 +379,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro Type: "config", Message: fmt.Sprintf("profile %q not found", profileOverride), Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())), + Rung: RungProfile, } } @@ -268,21 +406,106 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro DefaultAs: app.DefaultAs, Lang: app.Lang, } - if len(app.Users) > 0 { - cfg.UserOpenId = app.Users[0].UserOpenId - cfg.UserName = app.Users[0].UserName + + // Three-rung user fallback. Both explicit-selector rungs error + // rather than fall through; only empty/empty picks Users[0]. + profile := app.ProfileName() + var picked *AppUser + switch { + case userOverride != "": + picked = app.FindUser(userOverride) + if picked == nil { + return nil, userResolutionError(profile, userOverride, app.Users, false /* drift */) + } + case app.CurrentUser != "": + picked = app.FindUser(app.CurrentUser) + if picked == nil { + return nil, userResolutionError(profile, app.CurrentUser, app.Users, true /* drift */) + } + case len(app.Users) > 0: + picked = &app.Users[0] + } + if picked != nil { + cfg.UserOpenId = picked.UserOpenId + cfg.UserName = picked.UserName } return cfg, nil } +// ResolveProfileConfigForLogin resolves the profile-rung config WITHOUT +// enforcing the strict user-rung selector. It is the entry point for +// `auth login` (and any future "add a user to a profile" command), +// where the operator-supplied --user / env may legitimately name a +// brand-new open_id that is not in app.Users yet. +// +// Concretely: ResolveConfigFromMulti errors when --user names an +// unknown user (correct for "use this user to make an API call"); +// here that strictness is wrong (the very point is to ADD that user). +// We return the AppId/AppSecret/Brand/Lang/DefaultAs fields needed to +// drive the device flow; UserOpenId/UserName are left to the caller's +// post-authorization holder verification (cmd/auth/login_holder.go). +// +// Profile-rung errors still surface verbatim (typed *ConfigError with +// RungProfile) so the dispatcher routes profile typos as InvalidArgument +// rather than "not configured". +func ResolveProfileConfigForLogin(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) { + app := raw.CurrentAppConfig(profileOverride) + if app == nil { + return nil, &ConfigError{ + Code: 3, + Type: "config", + Message: fmt.Sprintf("profile %q not found", profileOverride), + Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())), + Rung: RungProfile, + } + } + + if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil { + return nil, &ConfigError{Code: 3, Type: "config", + Message: "appId and appSecret keychain key are out of sync", + Hint: err.Error()} + } + + secret, err := ResolveSecretInput(app.AppSecret, kc) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return nil, exitErr + } + return nil, &ConfigError{Code: 3, Type: "config", Message: err.Error()} + } + return &CliConfig{ + ProfileName: app.ProfileName(), + AppID: app.AppId, + AppSecret: secret, + Brand: app.Brand, + DefaultAs: app.DefaultAs, + Lang: app.Lang, + // UserOpenId / UserName intentionally left empty; login resolves + // them post-authorization via the upstream open_id, then + // verifyHolder reconciles against --user / env / CurrentUser. + }, nil +} + // RequireAuth loads config and ensures a user is logged in. +// Backward-compatible thin forwarder; the user-selection chain now +// honours AppConfig.CurrentUser when set by `auth users use`. func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) { - return RequireAuthForProfile(kc, "") + return RequireAuthForProfileAndUser(kc, "", "") } // RequireAuthForProfile loads config for a profile and ensures a user is logged in. +// Backward-compatible thin forwarder around the *AndUser entry point. func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) { - cfg, err := RequireConfigForProfile(kc, profileOverride) + return RequireAuthForProfileAndUser(kc, profileOverride, "") +} + +// RequireAuthForProfileAndUser loads config for a profile + user and +// ensures a user is logged in. The not-logged-in error envelope +// (Code=3, Type="auth") is unchanged from RequireAuthForProfile so +// operator runbook greps keep working. +func RequireAuthForProfileAndUser(kc keychain.KeychainAccess, profileOverride, userOverride string) (*CliConfig, error) { + cfg, err := RequireConfigForProfileAndUser(kc, profileOverride, userOverride) if err != nil { return nil, err } @@ -292,6 +515,31 @@ func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) ( return cfg, nil } +// userResolutionError builds the override-miss / current-user-stale +// ConfigError. Single helper so both rungs share hint formatting. +// +// drift=false: operator passed --user / env that does not match +// anyone in Users[]. drift=true: AppConfig.CurrentUser names a user +// no longer in Users[] (config.json hand-edited or logout removed +// the user) — the hint also offers a one-shot --user override. +func userResolutionError(profile, requested string, users []AppUser, drift bool) error { + available := formatUserDisplay(users) + if drift { + return &ConfigError{ + Code: 3, Type: "config", + Message: fmt.Sprintf("current user %q in profile %q is no longer present in users list", requested, profile), + Hint: fmt.Sprintf("this usually means config.json was hand-edited or a logout removed the user; pass --user to override (available: %s), or run `lark-cli auth login` to re-establish the user", available), + Rung: RungUser, + } + } + return &ConfigError{ + Code: 3, Type: "config", + Message: fmt.Sprintf("user %q not found in profile %q", requested, profile), + Hint: fmt.Sprintf("available users in this profile: %s; run `lark-cli auth login` to add a new user, or `lark-cli auth users list` to see all known users", available), + Rung: RungUser, + } +} + // formatProfileNames joins profile names for display. func formatProfileNames(names []string) string { if len(names) == 0 { @@ -299,3 +547,25 @@ func formatProfileNames(names []string) string { } return strings.Join(names, ", ") } + +// formatUserDisplay renders one line per AppUser as "name (open_id-prefix)". +// open_ids longer than 12 characters are truncated with "…" so hints +// stay terminal-readable. Returns "(none)" for an empty slice. +func formatUserDisplay(users []AppUser) string { + if len(users) == 0 { + return "(none)" + } + parts := make([]string, 0, len(users)) + for _, u := range users { + prefix := u.UserOpenId + if len(prefix) > 12 { + prefix = prefix[:12] + "…" + } + if u.UserName != "" { + parts = append(parts, fmt.Sprintf("%s (%s)", u.UserName, prefix)) + } else { + parts = append(parts, prefix) + } + } + return strings.Join(parts, ", ") +} diff --git a/internal/core/config_multiuser_test.go b/internal/core/config_multiuser_test.go new file mode 100644 index 000000000..73f4de32f --- /dev/null +++ b/internal/core/config_multiuser_test.go @@ -0,0 +1,375 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Read-side back-compat: legacy AppUser JSON must unmarshal with all new fields zero. +func TestAppUser_LegacyConfigStillUnmarshals(t *testing.T) { + legacy := []byte(`{"userOpenId":"ou_alice","userName":"Alice"}`) + + var got AppUser + if err := json.Unmarshal(legacy, &got); err != nil { + t.Fatalf("unmarshal legacy AppUser: %v", err) + } + if got.UserOpenId != "ou_alice" || got.UserName != "Alice" { + t.Errorf("legacy fields lost: got %+v", got) + } + if got.UnionId != "" { + t.Errorf("UnionId = %q, want empty (legacy has none)", got.UnionId) + } + if got.CachedAt != nil { + t.Errorf("CachedAt = %v, want nil", got.CachedAt) + } + if got.FirstAuthAt != nil { + t.Errorf("FirstAuthAt = %v, want nil", got.FirstAuthAt) + } + if got.LastUsed != nil { + t.Errorf("LastUsed = %v, want nil", got.LastUsed) + } + if got.LastScopes != "" { + t.Errorf("LastScopes = %q, want empty", got.LastScopes) + } +} + +// Write-side back-compat: zero-valued new fields must not appear in JSON (omitempty contract). +func TestAppUser_NewFieldsOmittedWhenZero(t *testing.T) { + u := AppUser{UserOpenId: "ou_a", UserName: "Alice"} + data, err := json.Marshal(u) + if err != nil { + t.Fatalf("marshal: %v", err) + } + got := string(data) + for _, banned := range []string{"unionId", "cachedAt", "firstAuthAt", "lastUsed", "lastScopes"} { + if strings.Contains(got, banned) { + t.Errorf("zero-valued AppUser JSON contains %q: %s", banned, got) + } + } +} + +func TestAppUser_NewFieldsRoundTrip(t *testing.T) { + cached := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC) + first := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + last := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC) + + in := AppUser{ + UserOpenId: "ou_a", + UserName: "Alice", + UnionId: "on_a", + CachedAt: &cached, + FirstAuthAt: &first, + LastUsed: &last, + LastScopes: "im:message,im:resource", + } + data, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got AppUser + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.UnionId != in.UnionId { + t.Errorf("UnionId: got %q want %q", got.UnionId, in.UnionId) + } + if got.CachedAt == nil || !got.CachedAt.Equal(*in.CachedAt) { + t.Errorf("CachedAt: got %v want %v", got.CachedAt, in.CachedAt) + } + if got.FirstAuthAt == nil || !got.FirstAuthAt.Equal(*in.FirstAuthAt) { + t.Errorf("FirstAuthAt: got %v want %v", got.FirstAuthAt, in.FirstAuthAt) + } + if got.LastUsed == nil || !got.LastUsed.Equal(*in.LastUsed) { + t.Errorf("LastUsed: got %v want %v", got.LastUsed, in.LastUsed) + } + if got.LastScopes != in.LastScopes { + t.Errorf("LastScopes: got %q want %q", got.LastScopes, in.LastScopes) + } +} + +// Empty CurrentUser must be omitted from JSON to match Lang/StrictMode behaviour. +func TestAppConfig_CurrentUserOmitEmpty(t *testing.T) { + app := AppConfig{ + AppId: "cli_x", AppSecret: PlainSecret("s"), + Brand: BrandFeishu, Users: []AppUser{}, + } + data, err := json.Marshal(app) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + if _, exists := raw["currentUser"]; exists { + t.Error("expected currentUser to be omitted when empty") + } +} + +// CurrentUser must round-trip — three-level resolution falls back to Users[0] without it. +func TestAppConfig_CurrentUserRoundTrip(t *testing.T) { + app := AppConfig{ + AppId: "cli_x", AppSecret: PlainSecret("s"), + Brand: BrandFeishu, + CurrentUser: "ou_alice", + Users: []AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + {UserOpenId: "ou_bob", UserName: "Bob"}, + }, + } + data, err := json.Marshal(app) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got AppConfig + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.CurrentUser != "ou_alice" { + t.Errorf("CurrentUser = %q, want ou_alice", got.CurrentUser) + } +} + +// Legacy AppConfig (no currentUser) loads with CurrentUser zero — Users[0] becomes the default. +func TestAppConfig_LegacyConfigStillUnmarshals(t *testing.T) { + legacy := []byte(`{ + "appId": "cli_x", + "appSecret": "s", + "brand": "feishu", + "users": [ + {"userOpenId":"ou_a","userName":"Alice"} + ] + }`) + var got AppConfig + if err := json.Unmarshal(legacy, &got); err != nil { + t.Fatalf("unmarshal legacy AppConfig: %v", err) + } + if got.CurrentUser != "" { + t.Errorf("CurrentUser = %q, want empty (legacy has none)", got.CurrentUser) + } + if len(got.Users) != 1 || got.Users[0].UserOpenId != "ou_a" { + t.Errorf("Users[] not preserved: %+v", got.Users) + } +} + +// SchemaVersion=0 must be omitted from JSON; 0 is the legacy-file marker. +// SaveMultiAppConfig is the authoritative writer that stamps the live version. +func TestMultiAppConfig_SchemaVersionOmitEmpty(t *testing.T) { + cfg := MultiAppConfig{Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + }}} + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + if _, exists := raw["schemaVersion"]; exists { + t.Error("expected schemaVersion to be omitted when zero") + } +} + +// Legacy file (no schemaVersion) must unmarshal as version 0 so the migrator fires once on upgrade. +func TestMultiAppConfig_LegacyFileLoadsAsVersionZero(t *testing.T) { + legacy := []byte(`{ + "currentApp": "alpha", + "apps": [ + {"appId":"cli_x","appSecret":"s","brand":"feishu","users":[]} + ] + }`) + var got MultiAppConfig + if err := json.Unmarshal(legacy, &got); err != nil { + t.Fatalf("unmarshal legacy MultiAppConfig: %v", err) + } + if got.SchemaVersion != 0 { + t.Errorf("SchemaVersion = %d on legacy file, want 0", got.SchemaVersion) + } + if got.CurrentApp != "alpha" { + t.Errorf("CurrentApp = %q, want alpha", got.CurrentApp) + } + if len(got.Apps) != 1 { + t.Errorf("Apps count = %d, want 1", len(got.Apps)) + } +} + +func TestMultiAppConfig_SchemaVersionRoundTrip(t *testing.T) { + cfg := MultiAppConfig{ + SchemaVersion: 1, + CurrentApp: "alpha", + Apps: []AppConfig{{AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu}}, + } + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got MultiAppConfig + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.SchemaVersion != 1 { + t.Errorf("SchemaVersion = %d, want 1", got.SchemaVersion) + } +} + +// SaveMultiAppConfig must stamp SchemaVersion forward to CurrentSchemaVersion. +func TestSaveMultiAppConfig_StampsSchemaVersion(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + cfg := &MultiAppConfig{ + // SchemaVersion zero — simulates a freshly-loaded legacy file. + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + Users: []AppUser{{UserOpenId: "ou_a", UserName: "Alice"}}, + }}, + } + if err := SaveMultiAppConfig(cfg); err != nil { + t.Fatalf("Save: %v", err) + } + if cfg.SchemaVersion != CurrentSchemaVersion { + t.Errorf("after Save: SchemaVersion = %d, want %d", cfg.SchemaVersion, CurrentSchemaVersion) + } + + got, err := LoadMultiAppConfig() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.SchemaVersion != CurrentSchemaVersion { + t.Errorf("on-disk SchemaVersion = %d, want %d", got.SchemaVersion, CurrentSchemaVersion) + } +} + +// Forward-compat: Save must not downgrade a future SchemaVersion (only the migrator changes it). +func TestSaveMultiAppConfig_DoesNotDowngradeSchemaVersion(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + cfg := &MultiAppConfig{ + SchemaVersion: 99, // pretend a future build wrote this + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + }}, + } + if err := SaveMultiAppConfig(cfg); err != nil { + t.Fatalf("Save: %v", err) + } + if cfg.SchemaVersion != 99 { + t.Errorf("Save downgraded SchemaVersion: got %d, want preserved 99", cfg.SchemaVersion) + } +} + +// Unknown JSON fields must be tolerated so rolling forward then back doesn't brick the config. +func TestMultiAppConfig_UnknownFieldsTolerated(t *testing.T) { + withFuture := []byte(`{ + "schemaVersion": 1, + "currentApp": "alpha", + "futurePolicy": {"x": 1}, + "apps": [ + {"appId":"cli_x","appSecret":"s","brand":"feishu","users":[]} + ] + }`) + var got MultiAppConfig + if err := json.Unmarshal(withFuture, &got); err != nil { + t.Fatalf("unmarshal with unknown future field: %v", err) + } + if got.SchemaVersion != 1 || got.CurrentApp != "alpha" { + t.Errorf("unexpected parse result: %+v", got) + } +} + +// Forward-incompat schema must reject with a *ConfigError (code 3, type "config") +// so the root command's exit-error adapter renders the structured envelope and upgrade hint. +func TestLoadMultiAppConfig_RejectsForwardIncompatSchema(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + // Bypass SaveMultiAppConfig so we can stamp a version this binary wouldn't produce. + future := []byte(`{ + "schemaVersion": 99, + "apps": [ + {"appId":"cli_x","appSecret":"s","brand":"feishu","users":[{"userOpenId":"ou_a","userName":"Alice"}]} + ] + }`) + if err := os.WriteFile(filepath.Join(dir, "config.json"), future, 0600); err != nil { + t.Fatalf("write future config: %v", err) + } + + got, err := LoadMultiAppConfig() + if err == nil { + t.Fatalf("LoadMultiAppConfig: want error for SchemaVersion=99, got nil; result=%+v", got) + } + if got != nil { + t.Errorf("LoadMultiAppConfig: want nil result alongside error, got %+v", got) + } + + var ce *ConfigError + if !errors.As(err, &ce) { + t.Fatalf("LoadMultiAppConfig: want *ConfigError, got %T: %v", err, err) + } + if ce.Code != 3 || ce.Type != "config" { + t.Errorf("ConfigError shape mismatch: code=%d type=%q (want 3 / \"config\")", ce.Code, ce.Type) + } + // Message must name both versions so the user knows whether to upgrade or use --profile. + if !strings.Contains(ce.Message, "99") || !strings.Contains(ce.Message, fmt.Sprint(CurrentSchemaVersion)) { + t.Errorf("Message does not mention both versions: %q", ce.Message) + } + if ce.Hint == "" { + t.Errorf("ConfigError.Hint must be populated so AI agents and humans see the next-step guidance") + } +} + +// Regression guard paired with the rejection test: a file at exactly CurrentSchemaVersion loads. +func TestLoadMultiAppConfig_AcceptsCurrentSchema(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + cfg := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + Users: []AppUser{{UserOpenId: "ou_a", UserName: "Alice"}}, + }}, + } + if err := SaveMultiAppConfig(cfg); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig at CurrentSchemaVersion: unexpected error: %v", err) + } + if got.SchemaVersion != CurrentSchemaVersion { + t.Errorf("loaded SchemaVersion = %d, want %d", got.SchemaVersion, CurrentSchemaVersion) + } +} + +// Legacy configs (no schemaVersion) must load unchanged; the migrator stamps them forward. +func TestLoadMultiAppConfig_AcceptsLegacyZeroSchema(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + legacy := []byte(`{ + "apps": [ + {"appId":"cli_x","appSecret":"s","brand":"feishu","users":[{"userOpenId":"ou_a","userName":"Alice"}]} + ] + }`) + if err := os.WriteFile(filepath.Join(dir, "config.json"), legacy, 0600); err != nil { + t.Fatalf("write legacy config: %v", err) + } + + got, err := LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig on legacy SchemaVersion=0: unexpected error: %v", err) + } + if got.SchemaVersion != 0 { + t.Errorf("legacy file should load with SchemaVersion=0, got %d", got.SchemaVersion) + } +} diff --git a/internal/core/config_step6_test.go b/internal/core/config_step6_test.go new file mode 100644 index 000000000..de3525368 --- /dev/null +++ b/internal/core/config_step6_test.go @@ -0,0 +1,463 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "errors" + "strings" + "testing" +) + +// Resolver fallback order: +// 1. userOverride non-empty: FindUser(userOverride); miss = error +// 2. AppConfig.CurrentUser non-empty: FindUser(CurrentUser); miss = drift error +// 3. else len(Users) > 0: pick &Users[0] (legacy single-user path) +// 4. else: cfg.UserOpenId stays empty (RequireAuth* surfaces "not logged in") + +// Legacy contract: one user, no CurrentUser, no override must resolve from Users[0]. +func TestResolveConfigFromMulti_LegacyUsersZeroPathUnchanged(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + }, + }}, + } + cfg, err := ResolveConfigFromMulti(raw, nil, "", "") + if err != nil { + t.Fatalf("legacy path errored: %v", err) + } + if cfg.UserOpenId != "ou_a" || cfg.UserName != "Alice" { + t.Errorf("Users[0] not resolved: cfg=%+v", cfg) + } +} + +// Empty Users[] does not error here; RequireAuth* wraps that as "not logged in". +func TestResolveConfigFromMulti_EmptyUsersStaysEmpty(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + Users: []AppUser{}, + }}, + } + cfg, err := ResolveConfigFromMulti(raw, nil, "", "") + if err != nil { + t.Fatalf("empty Users[]: resolver errored unexpectedly: %v", err) + } + if cfg.UserOpenId != "" || cfg.UserName != "" { + t.Errorf("empty Users[] should leave user fields empty, got: %+v", cfg) + } +} + +// ─── Rung 2: AppConfig.CurrentUser ────────────────────────────────────── + +func TestResolveConfigFromMulti_HonoursAppConfigCurrentUser(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + CurrentUser: "ou_b", + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + {UserOpenId: "ou_b", UserName: "Bob"}, + }, + }}, + } + cfg, err := ResolveConfigFromMulti(raw, nil, "", "") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if cfg.UserOpenId != "ou_b" || cfg.UserName != "Bob" { + t.Errorf("CurrentUser not honoured: got %+v", cfg) + } +} + +// Stale CurrentUser must NOT silently fall back to Users[0] — that would +// dispatch as the wrong human. Drift error must include both --user and +// `auth users use` remediation paths. +func TestResolveConfigFromMulti_StaleCurrentUser_DoesNotFallbackToUsers0(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + CurrentUser: "ou_ghost", + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + }, + }}, + } + _, err := ResolveConfigFromMulti(raw, nil, "", "") + if err == nil { + t.Fatal("expected error for stale CurrentUser, got nil") + } + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected ConfigError, got %T: %v", err, err) + } + if cfgErr.Code != 3 || cfgErr.Type != "config" { + t.Errorf("err shape: code=%d type=%q, want 3/config", cfgErr.Code, cfgErr.Type) + } + if !strings.Contains(cfgErr.Message, "current user") || !strings.Contains(cfgErr.Message, "ou_ghost") { + t.Errorf("message missing key terms: %q", cfgErr.Message) + } + // Hint must mention both the one-shot --user override and the + // permanent `auth users use` / `auth login` recovery paths. + if !strings.Contains(cfgErr.Hint, "--user") { + t.Errorf("hint missing --user remediation: %q", cfgErr.Hint) + } + if !strings.Contains(cfgErr.Hint, "auth login") { + t.Errorf("hint missing auth login remediation: %q", cfgErr.Hint) + } + if !strings.Contains(cfgErr.Hint, "Alice") { + t.Errorf("hint should list available users: %q", cfgErr.Hint) + } +} + +// ─── Rung 1: explicit userOverride ────────────────────────────────────── + +func TestResolveConfigFromMulti_UserOverrideTakesPrecedenceOverCurrentUser(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + CurrentUser: "ou_a", + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + {UserOpenId: "ou_b", UserName: "Bob"}, + }, + }}, + } + cfg, err := ResolveConfigFromMulti(raw, nil, "", "ou_b") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if cfg.UserOpenId != "ou_b" || cfg.UserName != "Bob" { + t.Errorf("override not respected: got %+v", cfg) + } +} + +// OpenId match wins over name match: a UserName equal to another user's +// OpenId must NOT shadow the real OpenId owner. +func TestResolveConfigFromMulti_UserOverrideMatchesByOpenId(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + Users: []AppUser{ + // User a's UserName equals user b's OpenId — pathological but legal. + {UserOpenId: "ou_a", UserName: "ou_b"}, + {UserOpenId: "ou_b", UserName: "Bob"}, + }, + }}, + } + cfg, err := ResolveConfigFromMulti(raw, nil, "", "ou_b") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if cfg.UserOpenId != "ou_b" || cfg.UserName != "Bob" { + t.Errorf("name-impostor matched instead of OpenId: got %+v", cfg) + } +} + +// Override falls back to UserName when OpenId match fails — so operators +// can pass --user "Alice" without copying ou_xxx. +func TestResolveConfigFromMulti_UserOverrideMatchesByName(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + {UserOpenId: "ou_b", UserName: "Bob"}, + }, + }}, + } + cfg, err := ResolveConfigFromMulti(raw, nil, "", "Alice") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if cfg.UserOpenId != "ou_a" { + t.Errorf("name match failed: got %+v", cfg) + } +} + +// Override miss → ConfigError{Code:3,Type:"config"} (matches existing +// renderer); hint must list available users + suggest auth login. +func TestResolveConfigFromMulti_UserOverrideUnknown_TypedErrorWithHint(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + }, + }}, + } + _, err := ResolveConfigFromMulti(raw, nil, "", "ou_z") + if err == nil { + t.Fatal("expected error, got nil") + } + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected ConfigError, got %T: %v", err, err) + } + if cfgErr.Code != 3 || cfgErr.Type != "config" { + t.Errorf("err shape: code=%d type=%q, want 3/config", cfgErr.Code, cfgErr.Type) + } + if !strings.Contains(cfgErr.Message, "ou_z") || !strings.Contains(cfgErr.Message, "not found") { + t.Errorf("message missing key terms: %q", cfgErr.Message) + } + if !strings.Contains(cfgErr.Hint, "Alice") { + t.Errorf("hint should list available users: %q", cfgErr.Hint) + } + if !strings.Contains(cfgErr.Hint, "auth login") { + t.Errorf("hint should suggest auth login: %q", cfgErr.Hint) + } + // Drift hint copy MUST NOT appear here — explicit override miss has + // different remediation than the CurrentUser-stale case. + if strings.Contains(cfgErr.Hint, "config.json was hand-edited") { + t.Error("hint should not include drift text for explicit override miss") + } +} + +// Empty users + --user must still render hint cleanly with "(none)". +func TestResolveConfigFromMulti_UserOverrideUnknown_EmptyUsers_HintShowsNone(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + Users: []AppUser{}, + }}, + } + _, err := ResolveConfigFromMulti(raw, nil, "", "ou_z") + if err == nil { + t.Fatal("expected error, got nil") + } + var cfgErr *ConfigError + errors.As(err, &cfgErr) + if !strings.Contains(cfgErr.Hint, "(none)") { + t.Errorf("hint should show (none) for empty users, got: %q", cfgErr.Hint) + } +} + +// userOverride="" must fall through to rungs 2/3, not error — every +// existing call site passes "" today. +func TestResolveConfigFromMulti_EmptyUserOverride_TreatedAsUnset(t *testing.T) { + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + }, + }}, + } + cfg, err := ResolveConfigFromMulti(raw, nil, "", "") + if err != nil { + t.Fatalf("empty override should not error: %v", err) + } + if cfg.UserOpenId != "ou_a" { + t.Errorf("empty override should fall through to Users[0], got: %+v", cfg) + } +} + +// Resolver is env-agnostic: LARKSUITE_CLI_OPEN_ID does NOT inject when +// userOverride="". Bootstrap plumbs env→string before calling resolver, +// mirroring --profile (see TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback). +func TestResolveConfigFromMulti_DoesNotReadEnvForUserOverride(t *testing.T) { + t.Setenv("LARKSUITE_CLI_OPEN_ID", "ou_b") + raw := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + {UserOpenId: "ou_b", UserName: "Bob"}, + }, + }}, + } + cfg, err := ResolveConfigFromMulti(raw, nil, "", "") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if cfg.UserOpenId != "ou_a" { + t.Errorf("env should be ignored; expected Users[0]=ou_a, got %+v", cfg) + } +} + +// ─── RequireAuth* wrappers ────────────────────────────────────────────── + +// Drift error from ResolveConfigFromMulti must propagate untransformed +// through the Auth wrapper so operators see the same recovery hint. +func TestRequireAuthForProfileAndUser_StaleCurrentUser_SurfacesConfigError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + cfg := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + CurrentUser: "ou_ghost", + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + }, + }}, + } + if err := SaveMultiAppConfig(cfg); err != nil { + t.Fatalf("Save: %v", err) + } + + _, err := RequireAuthForProfileAndUser(nil, "", "") + if err == nil { + t.Fatal("expected drift ConfigError, got nil") + } + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected ConfigError, got %T: %v", err, err) + } + if cfgErr.Type != "config" { + t.Errorf("type=%q, want config (drift propagated, not converted to auth)", cfgErr.Type) + } + if !strings.Contains(cfgErr.Message, "ou_ghost") { + t.Errorf("drift message lost: %q", cfgErr.Message) + } +} + +// Legacy zero-arg helper must return same CliConfig as *AndUser sibling +// with userOverride="" — locks the thin-forwarder contract. +func TestRequireConfigForProfile_LegacyShape_StillWorks(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + cfg := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_x", AppSecret: PlainSecret("s"), Brand: BrandFeishu, + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + }, + }}, + } + if err := SaveMultiAppConfig(cfg); err != nil { + t.Fatalf("Save: %v", err) + } + + legacy, err := RequireConfigForProfile(nil, "") + if err != nil { + t.Fatalf("legacy helper: %v", err) + } + via, err := RequireConfigForProfileAndUser(nil, "", "") + if err != nil { + t.Fatalf("AndUser helper: %v", err) + } + if legacy.UserOpenId != via.UserOpenId || legacy.AppID != via.AppID { + t.Errorf("forwarder drift: legacy=%+v vs sibling=%+v", legacy, via) + } +} + +// ─── FindUser / FindUserIndex / UserNames helpers ─────────────────────── + +// OpenId match wins over name match on collision. +func TestFindUser_OpenIdTakesPrecedenceOverNameOnConflict(t *testing.T) { + app := &AppConfig{ + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "ou_b"}, // name impostor + {UserOpenId: "ou_b", UserName: "Bob"}, + }, + } + got := app.FindUser("ou_b") + if got == nil { + t.Fatal("FindUser returned nil for valid OpenId") + } + if got.UserOpenId != "ou_b" || got.UserName != "Bob" { + t.Errorf("name impostor matched: got %+v", got) + } +} + +// Empty input must NOT match an AppUser with empty UserName (legitimate +// for service accounts). +func TestFindUser_EmptyInputReturnsNil(t *testing.T) { + app := &AppConfig{ + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: ""}, + }, + } + if got := app.FindUser(""); got != nil { + t.Errorf("empty input matched %+v", got) + } +} + +func TestFindUser_NameMatchFallback(t *testing.T) { + app := &AppConfig{ + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + {UserOpenId: "ou_b", UserName: "Bob"}, + }, + } + got := app.FindUser("Bob") + if got == nil || got.UserOpenId != "ou_b" { + t.Errorf("name fallback failed: got %+v", got) + } +} + +func TestFindUser_NotFoundReturnsNil(t *testing.T) { + app := &AppConfig{ + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + }, + } + if got := app.FindUser("ou_z"); got != nil { + t.Errorf("expected nil for missing user, got %+v", got) + } +} + +// Index-returning sibling of FindUser; -1 means not-found, mirroring FindAppIndex. +func TestFindUserIndex(t *testing.T) { + app := &AppConfig{ + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + {UserOpenId: "ou_b", UserName: "Bob"}, + }, + } + if i := app.FindUserIndex("ou_b"); i != 1 { + t.Errorf("FindUserIndex(ou_b) = %d, want 1", i) + } + if i := app.FindUserIndex("Alice"); i != 0 { + t.Errorf("FindUserIndex(Alice) = %d, want 0", i) + } + if i := app.FindUserIndex("ou_z"); i != -1 { + t.Errorf("FindUserIndex(missing) = %d, want -1", i) + } + if i := app.FindUserIndex(""); i != -1 { + t.Errorf("FindUserIndex(empty) = %d, want -1", i) + } +} + +// Rendering used by error hints and `auth users list`; stable across +// releases for operators scripting around the output. +func TestUserNames_FormatStable(t *testing.T) { + app := &AppConfig{ + Users: []AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + {UserOpenId: "ou_serviceaccount", UserName: ""}, + }, + } + names := app.UserNames() + if len(names) != 2 { + t.Fatalf("UserNames() len = %d, want 2", len(names)) + } + if names[0] != "Alice (ou_a)" { + t.Errorf("names[0] = %q, want %q", names[0], "Alice (ou_a)") + } + if names[1] != "ou_serviceaccount" { + t.Errorf("names[1] = %q, want %q (no name shows OpenId only)", names[1], "ou_serviceaccount") + } +} + +// Long OpenIds truncate to 12 chars + "…" so hints stay terminal-readable. +func TestFormatUserDisplay_TruncatesLongOpenIds(t *testing.T) { + users := []AppUser{ + {UserOpenId: "ou_aaaaaaaaaaaaaaaaaaaaaa", UserName: "Alice"}, + } + got := formatUserDisplay(users) + const wantSubstr = "Alice (ou_aaaaaaaaa…)" + if !strings.Contains(got, wantSubstr) { + t.Errorf("expected truncation %q, got: %q", wantSubstr, got) + } +} + +// "(none)" branch used in error hints. +func TestFormatUserDisplay_EmptyReturnsNone(t *testing.T) { + if got := formatUserDisplay([]AppUser{}); got != "(none)" { + t.Errorf("empty users render = %q, want (none)", got) + } +} diff --git a/internal/core/config_test.go b/internal/core/config_test.go index 3d727c504..194730516 100644 --- a/internal/core/config_test.go +++ b/internal/core/config_test.go @@ -11,7 +11,6 @@ import ( "github.com/larksuite/cli/internal/keychain" ) -// stubKeychain is a minimal KeychainAccess that always returns ErrNotFound. type stubKeychain struct{} func (stubKeychain) Get(service, account string) (string, error) { @@ -48,7 +47,6 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - // Lang should be omitted when empty var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { t.Fatalf("unmarshal raw: %v", err) @@ -99,7 +97,7 @@ func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) { }, } - _, err := ResolveConfigFromMulti(raw, nil, "") + _, err := ResolveConfigFromMulti(raw, nil, "", "") if err == nil { t.Fatal("expected error for mismatched appId and appSecret keychain key") } @@ -123,7 +121,7 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) { }, } - cfg, err := ResolveConfigFromMulti(raw, nil, "") + cfg, err := ResolveConfigFromMulti(raw, nil, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -133,9 +131,8 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) { } func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) { - // Keychain ref matches appId, so validation passes. - // The subsequent ResolveSecretInput will fail (no real keychain), - // but that proves the mismatch check itself passed. + // Mismatch check passes; subsequent keychain lookup fails, proving the + // failure came from resolution, not from the appId/keychain-key check. raw := &MultiAppConfig{ Apps: []AppConfig{ { @@ -149,13 +146,10 @@ func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T }, } - _, err := ResolveConfigFromMulti(raw, stubKeychain{}, "") + _, err := ResolveConfigFromMulti(raw, stubKeychain{}, "", "") if err == nil { - // stubKeychain returns ErrNotFound, so we expect a keychain error, - // but NOT a mismatch error — that's the point of this test. t.Fatal("expected error (keychain entry not found), got nil") } - // The error should come from keychain resolution, NOT from our mismatch check. var cfgErr *ConfigError if errors.As(err, &cfgErr) { if cfgErr.Message == "appId and appSecret keychain key are out of sync" { @@ -179,7 +173,7 @@ func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) { }, } - cfg, err := ResolveConfigFromMulti(raw, nil, "") + cfg, err := ResolveConfigFromMulti(raw, nil, "", "") if err != nil { t.Fatalf("ResolveConfigFromMulti() error = %v", err) } diff --git a/internal/core/errors.go b/internal/core/errors.go index b5ad13e89..479b20127 100644 --- a/internal/core/errors.go +++ b/internal/core/errors.go @@ -5,6 +5,28 @@ package core import "fmt" +// ConfigErrorRung tags the resolution rung that produced a ConfigError. +// Decorators (e.g. credential.decorateUserResolutionError) use this +// instead of substring-matching the Message — substring matches drift +// when copy is reworded and false-match neighboring messages that +// happen to contain the same word. +type ConfigErrorRung string + +const ( + // RungUnspecified is the zero value — used by historical call sites + // that build a ConfigError without thinking about the rung. + RungUnspecified ConfigErrorRung = "" + // RungProfile means the resolution failed before a single AppConfig + // could be picked: profile not found, dangling CurrentApp, schema + // errors, etc. User-rung decorators MUST skip this — adding a + // "user" hint to a profile failure is wrong copy. + RungProfile ConfigErrorRung = "profile" + // RungUser means the resolution failed at the user fallback chain + // (--user override miss, drifted CurrentUser, no users in the + // profile). User-rung decorators decorate THIS rung. + RungUser ConfigErrorRung = "user" +) + // ConfigError is a structured error from config resolution. // It carries enough information for main.go to convert it into an output.ExitError. type ConfigError struct { @@ -12,6 +34,9 @@ type ConfigError struct { Type string // "config" or "auth" Message string Hint string + // Rung is the structural classification of which resolution layer + // produced the error. Empty for legacy / historical errors. + Rung ConfigErrorRung } func (e *ConfigError) Error() string { diff --git a/internal/core/notconfigured.go b/internal/core/notconfigured.go index 770c898d3..7cab78aaa 100644 --- a/internal/core/notconfigured.go +++ b/internal/core/notconfigured.go @@ -23,18 +23,7 @@ import ( func LoadOrNotConfigured() (*MultiAppConfig, error) { multi, err := LoadMultiAppConfig() if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, NotConfiguredError() - } - // Surface the real cause (parse error, permission denied, etc.) - // so the user can fix the broken file. Wrapping as ConfigError - // keeps it on the standard structured-envelope path at the root - // command's error sink. - return nil, &ConfigError{ - Code: 3, - Type: "config", - Message: fmt.Sprintf("failed to load config: %v", err), - } + return nil, PassThroughOrNotConfigured(err) } if multi == nil || len(multi.Apps) == 0 { return nil, NotConfiguredError() @@ -42,6 +31,34 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) { return multi, nil } +// PassThroughOrNotConfigured classifies a non-nil error from LoadMultiAppConfig: +// - *ConfigError → returned verbatim (R2 transparency: forward-incompat +// schema must NOT be coerced into "run init", which would overwrite +// fields a newer-binary file populated). +// - errors.Is(err, os.ErrNotExist) → workspace-aware NotConfiguredError(). +// - otherwise (parse error, permission denied, …) → wrap as *ConfigError +// with "failed to load config: " so the dispatcher renders a +// structured envelope while the operator still sees the real cause. +// +// nil passes through unchanged. +func PassThroughOrNotConfigured(err error) error { + if err == nil { + return nil + } + var cfgErr *ConfigError + if errors.As(err, &cfgErr) { + return cfgErr + } + if errors.Is(err, os.ErrNotExist) { + return NotConfiguredError() + } + return &ConfigError{ + Code: 3, + Type: "config", + Message: fmt.Sprintf("failed to load config: %v", err), + } +} + const ( // localInitHint is the canonical "you're in a regular terminal, run // init" guidance — shared by NotConfiguredError and NoActiveProfileError diff --git a/internal/core/passthrough_or_notconfigured_test.go b/internal/core/passthrough_or_notconfigured_test.go new file mode 100644 index 000000000..4f4fca17e --- /dev/null +++ b/internal/core/passthrough_or_notconfigured_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "errors" + "fmt" + "os" + "strings" + "testing" +) + +// R2 *ConfigError must pass through verbatim so the upgrade hint reaches +// the operator unchanged — wrapping it here would route AI agents to +// `config init`, overwriting fields the newer-binary file populated. +func TestPassThroughOrNotConfigured_R2ConfigErrorPassesThrough(t *testing.T) { + r2 := &ConfigError{ + Code: 3, + Type: "config", + Message: "config.json was written by a newer lark-cli (schemaVersion 99 > supported 1)", + Hint: "upgrade lark-cli, or use a different --profile to avoid overwriting fields the newer binary populated", + } + got := PassThroughOrNotConfigured(r2) + if got != r2 { + t.Fatalf("R2 ConfigError must pass through identical pointer; got %#v", got) + } + var cfgErr *ConfigError + if !errors.As(got, &cfgErr) || cfgErr.Hint != r2.Hint { + t.Errorf("R2 hint lost or rewrapped: %#v", got) + } +} + +// Wrapped *ConfigError (errors.As-discoverable) must also pass through — +// callers may have wrapped via fmt.Errorf("...: %w", cfgErr). +func TestPassThroughOrNotConfigured_WrappedConfigErrorPassesThrough(t *testing.T) { + r2 := &ConfigError{Code: 3, Type: "config", Message: "newer schema", Hint: "upgrade"} + wrapped := fmt.Errorf("load: %w", r2) + got := PassThroughOrNotConfigured(wrapped) + var cfgErr *ConfigError + if !errors.As(got, &cfgErr) { + t.Fatalf("wrapped ConfigError lost: %T", got) + } + if cfgErr.Hint != "upgrade" { + t.Errorf("hint lost through unwrap: %q", cfgErr.Hint) + } +} + +func TestPassThroughOrNotConfigured_FileMissing_LocalReturnsNotConfigured(t *testing.T) { + saveAndRestoreWorkspace(t) + SetCurrentWorkspace(WorkspaceLocal) + + got := PassThroughOrNotConfigured(os.ErrNotExist) + var cfgErr *ConfigError + if !errors.As(got, &cfgErr) { + t.Fatalf("expected *ConfigError, got %T", got) + } + if cfgErr.Message != "not configured" { + t.Errorf("message = %q, want \"not configured\"", cfgErr.Message) + } + if !strings.Contains(cfgErr.Hint, "config init --new") { + t.Errorf("local missing-file hint should mention config init --new; got %q", cfgErr.Hint) + } +} + +func TestPassThroughOrNotConfigured_FileMissing_AgentHintsBind(t *testing.T) { + saveAndRestoreWorkspace(t) + SetCurrentWorkspace(WorkspaceOpenClaw) + + got := PassThroughOrNotConfigured(os.ErrNotExist) + var cfgErr *ConfigError + if !errors.As(got, &cfgErr) { + t.Fatalf("expected *ConfigError, got %T", got) + } + if !strings.Contains(cfgErr.Hint, "config bind --help") { + t.Errorf("agent missing-file hint must point to config bind --help; got %q", cfgErr.Hint) + } +} + +// A non-ConfigError, non-NotExist error (parse failure, permission denied) +// must surface its real cause so the operator can fix the broken file — +// NOT be coerced to "not configured", which sends users in circles. +func TestPassThroughOrNotConfigured_ParseError_WrapsAsConfigErrorWithCause(t *testing.T) { + got := PassThroughOrNotConfigured(fmt.Errorf("invalid config format: unexpected EOF")) + var cfgErr *ConfigError + if !errors.As(got, &cfgErr) { + t.Fatalf("expected *ConfigError, got %T", got) + } + if !strings.Contains(cfgErr.Message, "failed to load config") { + t.Errorf("parse-error message must say 'failed to load config'; got %q", cfgErr.Message) + } + if !strings.Contains(cfgErr.Message, "unexpected EOF") { + t.Errorf("parse-error message must surface the original cause; got %q", cfgErr.Message) + } + if cfgErr.Message == "not configured" { + t.Errorf("parse error must not be coerced to 'not configured'") + } +} + +func TestPassThroughOrNotConfigured_Nil_ReturnsNil(t *testing.T) { + if got := PassThroughOrNotConfigured(nil); got != nil { + t.Errorf("nil input should pass through nil, got %v", got) + } +} diff --git a/internal/core/require_config_r2_test.go b/internal/core/require_config_r2_test.go new file mode 100644 index 000000000..18f796eae --- /dev/null +++ b/internal/core/require_config_r2_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// stubKCForTest matches keychain.KeychainAccess; never reached because +// LoadMultiAppConfig fails first on R2. +type stubKCForTest struct{} + +func (stubKCForTest) Get(service, account string) (string, error) { return "", nil } +func (stubKCForTest) Set(service, account, value string) error { return nil } +func (stubKCForTest) Remove(service, account string) error { return nil } + +// RequireConfigForProfileAndUser must surface R2 *ConfigError verbatim — the +// previous `if err != nil { return nil, NotConfiguredError() }` swallowed it, +// pushing AI agents toward `config init` which would overwrite newer-binary +// fields. +func TestRequireConfigForProfileAndUser_R2_PassesThroughConfigError(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + // Write a config file that triggers R2 (SchemaVersion above the cap). + future := []byte(`{"schemaVersion":99,"apps":[{"appId":"cli_x","appSecret":"s","brand":"feishu","users":[]}]}` + "\n") + if err := os.WriteFile(filepath.Join(dir, "config.json"), future, 0600); err != nil { + t.Fatal(err) + } + + _, err := RequireConfigForProfileAndUser(stubKCForTest{}, "", "") + if err == nil { + t.Fatal("expected R2 error, got nil") + } + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *ConfigError, got %T: %v", err, err) + } + // The R2 message and upgrade hint must reach the operator unchanged. + if !strings.Contains(cfgErr.Message, "newer lark-cli") { + t.Errorf("R2 message lost; got %q", cfgErr.Message) + } + if !strings.Contains(cfgErr.Hint, "upgrade lark-cli") { + t.Errorf("R2 upgrade hint lost; got %q", cfgErr.Hint) + } + if cfgErr.Message == "not configured" { + t.Errorf("R2 collapsed to 'not configured'; this is the regression") + } +} + +// Genuine missing-file path still returns NotConfiguredError(). +func TestRequireConfigForProfileAndUser_FileMissing_StillReturnsNotConfigured(t *testing.T) { + saveAndRestoreWorkspace(t) + SetCurrentWorkspace(WorkspaceLocal) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + _, err := RequireConfigForProfileAndUser(stubKCForTest{}, "", "") + if err == nil { + t.Fatal("expected error, got nil") + } + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *ConfigError, got %T", err) + } + if cfgErr.Message != "not configured" { + t.Errorf("missing-file message = %q, want \"not configured\"", cfgErr.Message) + } +} diff --git a/internal/core/resolve_login_test.go b/internal/core/resolve_login_test.go new file mode 100644 index 000000000..e840df521 --- /dev/null +++ b/internal/core/resolve_login_test.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/internal/keychain" +) + +// Regression: ResolveConfigFromMulti enforces a strict user-rung selector. +// That is correct for "use this user to make a call", but `auth login +// --user ou_new` is the path that ADDS a new user to the profile — +// strict resolution there is unreachable by definition. Pre-fix, +// `auth login --user ou_new_user` errored out before the device flow +// even started: +// +// Error: user "ou_new_user" not found in profile "prod" +// available users in this profile: alice (ou_alice) +// +// The new-user login path was structurally unreachable. +// +// ResolveProfileConfigForLogin is the profile-rung-only resolver +// `auth login` now uses. It must return without error when the user +// override names a brand-new open_id, and must STILL surface profile- +// rung errors verbatim (typed *ConfigError with RungProfile) so +// profile typos don't get mis-routed to "not configured". + +type stubKCForLogin struct{} + +func (stubKCForLogin) Get(service, account string) (string, error) { return "", nil } +func (stubKCForLogin) Set(service, account, value string) error { return nil } +func (stubKCForLogin) Remove(service, account string) error { return nil } + +func TestResolveProfileConfigForLogin_UnknownUser_DoesNotError(t *testing.T) { + multi := &MultiAppConfig{ + CurrentApp: "prod", + Apps: []AppConfig{{ + Name: "prod", + AppId: "cli_prod", + AppSecret: PlainSecret("s"), + Brand: BrandFeishu, + CurrentUser: "ou_alice", + Users: []AppUser{ + {UserOpenId: "ou_alice", UserName: "Alice"}, + }, + }}, + } + + // Unknown user override — would trip ResolveConfigFromMulti's user- + // rung strict check. ResolveProfileConfigForLogin must skip that + // rung entirely. + cfg, err := ResolveProfileConfigForLogin(multi, keychain.KeychainAccess(stubKCForLogin{}), "") + if err != nil { + t.Fatalf("ResolveProfileConfigForLogin: %v", err) + } + if cfg.AppID != "cli_prod" { + t.Errorf("AppID = %q, want cli_prod", cfg.AppID) + } + if cfg.UserOpenId != "" { + t.Errorf("UserOpenId = %q, want empty (caller resolves post-auth)", cfg.UserOpenId) + } + if cfg.UserName != "" { + t.Errorf("UserName = %q, want empty (caller resolves post-auth)", cfg.UserName) + } + + // Sibling proof: ResolveConfigFromMulti with the same unknown user + // MUST still error — the strict-resolution contract is unchanged + // for the non-login path. + if _, err := ResolveConfigFromMulti(multi, keychain.KeychainAccess(stubKCForLogin{}), "", "ou_new_user"); err == nil { + t.Errorf("ResolveConfigFromMulti must still error for unknown user override; got nil") + } +} + +// Profile-rung errors must still pass through with the right Rung +// tag — `auth login --profile=ghost` shouldn't be silently swallowed. +func TestResolveProfileConfigForLogin_UnknownProfile_ReturnsRungProfile(t *testing.T) { + multi := &MultiAppConfig{ + Apps: []AppConfig{{ + Name: "prod", + AppId: "cli_prod", + AppSecret: PlainSecret("s"), + Brand: BrandFeishu, + }}, + } + + _, err := ResolveProfileConfigForLogin(multi, keychain.KeychainAccess(stubKCForLogin{}), "ghost") + if err == nil { + t.Fatalf("expected profile-not-found error for --profile=ghost") + } + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("err type = %T, want *core.ConfigError", err) + } + if cfgErr.Rung != RungProfile { + t.Errorf("Rung = %q, want RungProfile", cfgErr.Rung) + } +} + +// When the profile has no current user and no users at all (a fresh +// `config init`-only state), login must still resolve — that's the +// normal "first login on this profile" case. +func TestResolveProfileConfigForLogin_EmptyUsers_DoesNotError(t *testing.T) { + multi := &MultiAppConfig{ + Apps: []AppConfig{{ + Name: "prod", + AppId: "cli_prod", + AppSecret: PlainSecret("s"), + Brand: BrandFeishu, + Users: nil, + }}, + } + cfg, err := ResolveProfileConfigForLogin(multi, keychain.KeychainAccess(stubKCForLogin{}), "") + if err != nil { + t.Fatalf("ResolveProfileConfigForLogin: %v", err) + } + if cfg.AppID != "cli_prod" { + t.Errorf("AppID = %q, want cli_prod", cfg.AppID) + } +} diff --git a/internal/credential/decorate_user_resolution_test.go b/internal/credential/decorate_user_resolution_test.go new file mode 100644 index 000000000..a7bedb3e2 --- /dev/null +++ b/internal/credential/decorate_user_resolution_test.go @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package credential + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" +) + +// Regression: decorateUserResolutionError previously gated the env-source +// hint on `strings.Contains(cfgErr.Message, "user")`. The substring match +// false-matched a profile-rung error whose hint contained "available +// users in this profile: ..." — a profile resolution failure on a typed +// $LARKSUITE_CLI_OPEN_ID got an "unset env or pass --user" suffix that +// pointed the operator at the wrong fix. +// +// Structural gate via core.ConfigError.Rung is robust to copy drift and +// to legitimate user-rung re-wordings. + +func TestDecorateUserResolutionError_ProfileRung_NoEnvSuffix(t *testing.T) { + // Profile rung error happens to mention "user" in its Hint copy. + in := &core.ConfigError{ + Message: "profile \"ghost\" not found", + Hint: "available profiles: alpha, beta — pick one with --profile and run again", + Rung: core.RungProfile, + } + out := decorateUserResolutionError(in, "env") + got, ok := out.(*core.ConfigError) + if !ok { + t.Fatalf("decorator must pass through; got %T", out) + } + if strings.Contains(got.Hint, envvars.CliOpenID) { + t.Errorf("env suffix wrongly appended to profile-rung hint: %q", got.Hint) + } +} + +func TestDecorateUserResolutionError_UserRung_AppendsEnvSuffix(t *testing.T) { + in := &core.ConfigError{ + Message: "user \"ou_alice\" not found in profile \"prod\"", + Hint: "available users in this profile: bob (ou_bob)", + Rung: core.RungUser, + } + out := decorateUserResolutionError(in, "env") + got, ok := out.(*core.ConfigError) + if !ok { + t.Fatalf("expected *core.ConfigError; got %T", out) + } + if !strings.Contains(got.Hint, envvars.CliOpenID) { + t.Errorf("user-rung env suffix missing; got hint=%q", got.Hint) + } +} + +func TestDecorateUserResolutionError_UserRung_FlagSource_NoDoubleHint(t *testing.T) { + original := "available users in this profile: bob (ou_bob); --user already in copy" + in := &core.ConfigError{ + Message: "user \"ou_alice\" not found in profile \"prod\"", + Hint: original, + Rung: core.RungUser, + } + out := decorateUserResolutionError(in, "flag") + got, ok := out.(*core.ConfigError) + if !ok { + t.Fatalf("expected *core.ConfigError") + } + if got.Hint != original { + t.Errorf("flag source must not modify hint; got %q, want %q", got.Hint, original) + } +} + +// Counter-test: a *core.ConfigError with no Rung tag (legacy) must NOT +// be decorated. Pre-fix the substring check would trigger on any "user" +// substring including documentation copy. Post-fix the structural gate +// only fires on RungUser. +func TestDecorateUserResolutionError_UntaggedConfigError_NoModify(t *testing.T) { + original := "this happens to mention the word user in the hint" + in := &core.ConfigError{ + Message: "some other failure", + Hint: original, + Rung: core.RungUnspecified, + } + out := decorateUserResolutionError(in, "env") + got, ok := out.(*core.ConfigError) + if !ok { + t.Fatalf("expected *core.ConfigError") + } + if got.Hint != original { + t.Errorf("untagged ConfigError must not be decorated; got %q", got.Hint) + } +} + +// Empty source short-circuits before any rung check. +func TestDecorateUserResolutionError_EmptySource_NoChange(t *testing.T) { + in := &core.ConfigError{ + Hint: "h", + Rung: core.RungUser, + } + out := decorateUserResolutionError(in, "") + if out != in { + t.Errorf("empty source must short-circuit identically; got new error") + } +} diff --git a/internal/credential/default_provider.go b/internal/credential/default_provider.go index 9482b2845..e22837d0f 100644 --- a/internal/credential/default_provider.go +++ b/internal/credential/default_provider.go @@ -5,6 +5,8 @@ package credential import ( "context" + "encoding/json" + "errors" "fmt" "io" "net/http" @@ -13,6 +15,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/keychain" @@ -20,19 +23,9 @@ import ( ) // classifyTATResponseCode wraps a non-zero TAT endpoint response code into the -// canonical typed error. The TAT mint endpoint reports invalid credentials -// with two distinct codes: -// -// - 10003: bad app_id format or non-existent app_id ("invalid param") -// - 10014: invalid app_secret ("app secret invalid") -// -// Both surface as CategoryConfig/InvalidClient from the user's perspective — -// the configured credentials cannot mint a tenant access token. 10014 is -// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543). -// 10003 is NOT globally mapped because in other Lark endpoints it carries -// unrelated semantics (e.g. task API uses 10003 for permission denied), so -// the override stays local to this TAT call site instead of leaking into the -// shared codemeta table. +// canonical typed error. 10003 (bad/missing app_id) is overridden locally +// because in other Lark endpoints the same code means permission denied; +// 10014 (invalid app_secret) is handled via the shared codemeta table. func classifyTATResponseCode(code int, msg, brand, appID string) error { if code == 10003 { return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg). @@ -50,32 +43,69 @@ func classifyTATResponseCode(code int, msg, brand, appID string) error { // DefaultAccountProvider resolves account from config.json via keychain. type DefaultAccountProvider struct { - keychain func() keychain.KeychainAccess - profile string + keychain func() keychain.KeychainAccess + profile string + userOverride string + userSource string // "flag", "env", or "" — drives error-hint copy on miss } -func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider { +func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile, userOverride, userSource string) *DefaultAccountProvider { if kc == nil { kc = keychain.Default } - return &DefaultAccountProvider{keychain: kc, profile: profile} + return &DefaultAccountProvider{ + keychain: kc, + profile: profile, + userOverride: userOverride, + userSource: userSource, + } } func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) { - // Load config once — used for both credentials and strict mode. multi, err := core.LoadMultiAppConfig() if err != nil { - return nil, core.NotConfiguredError() + return nil, core.PassThroughOrNotConfigured(err) } - cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile) + cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile, p.userOverride) if err != nil { - return nil, err + // Source-tag the user-resolution error here; the resolver layer is env-agnostic. + return nil, decorateUserResolutionError(err, p.userSource) } cfg.SupportedIdentities = strictModeToIdentitySupport(multi, p.profile) return AccountFromCliConfig(cfg), nil } +// decorateUserResolutionError appends a source-aware remediation suffix to a +// user-rung *core.ConfigError. Pass-through on any non-ConfigError, empty +// source, or non-user rung (which has its own remediation). +// +// Gating is structural via core.ConfigError.Rung — substring-matching the +// Message was fragile: it false-matched a profile-rung error that +// happened to contain "user" (e.g. "available users in this profile: ..." +// rendered into a profile-resolution failure if the copy ever drifted), +// and silently dropped the env-source hint on legitimately user-rung +// errors when the wording changed in any direction. +func decorateUserResolutionError(err error, source string) error { + if err == nil || source == "" { + return err + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + return err + } + if cfgErr.Rung != core.RungUser { + return err + } + switch source { + case "env": + cfgErr.Hint = cfgErr.Hint + "; this value came from " + envvars.CliOpenID + " — unset it or pass --user explicitly to override" + case "flag": + // --user already named in the resolver's hint copy; no-op. + } + return cfgErr +} + // strictModeToIdentitySupport maps the config-level strict mode to // the SupportedIdentities bitflag using an already-loaded MultiAppConfig. func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 { @@ -96,8 +126,8 @@ func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride str } } -// DefaultTokenProvider resolves UAT/TAT using keychain + direct HTTP calls. -// No SDK/LarkClient dependency — eliminates circular dependency with Factory. +// DefaultTokenProvider resolves UAT/TAT using keychain + direct HTTP calls +// (no SDK/LarkClient dep — avoids a circular dependency with Factory). type DefaultTokenProvider struct { defaultAcct *DefaultAccountProvider httpClient func() (*http.Client, error) @@ -123,8 +153,8 @@ func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) } } -// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT -// may be refreshed between calls and GetValidAccessToken handles its own caching. +// resolveUAT resolves a user access token. Not cached — GetValidAccessToken +// handles its own refresh/caching. func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) { acct, err := p.defaultAcct.ResolveAccount(ctx) if err != nil { @@ -146,8 +176,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er return &TokenResult{Token: token, Scopes: scopes}, nil } -// resolveTAT resolves a tenant access token. Result is cached after first call. -// NOTE: Uses sync.Once — only the context from the first call is used. +// resolveTAT resolves a tenant access token, cached after the first call via +// sync.Once — only the first caller's context is used. func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) { p.tatOnce.Do(func() { p.tatResult, p.tatErr = p.doResolveTAT(ctx) diff --git a/internal/credential/default_provider_r2_test.go b/internal/credential/default_provider_r2_test.go new file mode 100644 index 000000000..d25f26b33 --- /dev/null +++ b/internal/credential/default_provider_r2_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package credential + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keychain" +) + +// Regression: a config.json written by a newer lark-cli must surface its +// upgrade hint through the credential layer; the previous `if err != nil { +// return nil, core.NotConfiguredError() }` lost the Hint and routed AI +// agents toward `config init`, which would overwrite fields the newer +// binary populated. +func TestResolveAccount_R2ForwardSchema_PassesThroughHint(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + future := []byte(`{"schemaVersion":99,"apps":[{"appId":"cli_x","appSecret":"s","brand":"feishu","users":[]}]}` + "\n") + if err := os.WriteFile(filepath.Join(dir, "config.json"), future, 0600); err != nil { + t.Fatal(err) + } + + p := NewDefaultAccountProvider(func() keychain.KeychainAccess { return stubKC{} }, "", "", "") + _, err := p.ResolveAccount(context.Background()) + if err == nil { + t.Fatal("expected R2 error from ResolveAccount, got nil") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *core.ConfigError, got %T: %v", err, err) + } + if !strings.Contains(cfgErr.Message, "newer lark-cli") { + t.Errorf("R2 message lost; got %q", cfgErr.Message) + } + if !strings.Contains(cfgErr.Hint, "upgrade lark-cli") { + t.Errorf("R2 upgrade hint lost; got %q", cfgErr.Hint) + } +} diff --git a/internal/credential/default_provider_step7_test.go b/internal/credential/default_provider_step7_test.go new file mode 100644 index 000000000..a8688f56a --- /dev/null +++ b/internal/credential/default_provider_step7_test.go @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package credential + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keychain" +) + +type stubKC struct{} + +func (stubKC) Get(service, account string) (string, error) { return "", keychain.ErrNotFound } +func (stubKC) Set(service, account, value string) error { return nil } +func (stubKC) Remove(service, account string) error { return nil } + +// writeMulti persists cfg to a temp config dir scoped to t. Exercises the +// real core.LoadMultiAppConfig path rather than mocking it. +func writeMulti(t *testing.T, cfg *core.MultiAppConfig) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := core.SaveMultiAppConfig(cfg); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } +} + +func TestResolveAccount_UserOverride_FlowsThroughResolveConfigFromMulti(t *testing.T) { + writeMulti(t, &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{ + {UserOpenId: "ou_a", UserName: "Alice"}, + {UserOpenId: "ou_b", UserName: "Bob"}, + }, + }}, + }) + + p := NewDefaultAccountProvider(func() keychain.KeychainAccess { return stubKC{} }, "", "ou_b", "flag") + acct, err := p.ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("ResolveAccount: %v", err) + } + if acct.UserOpenId != "ou_b" { + t.Errorf("UserOpenId = %q, want ou_b (override should beat Users[0])", acct.UserOpenId) + } +} + +// Empty override must fall through to Users[0] when CurrentUser is unset. +func TestResolveAccount_UserOverrideEmpty_Regression(t *testing.T) { + writeMulti(t, &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_a", UserName: "Alice"}}, + }}, + }) + + p := NewDefaultAccountProvider(func() keychain.KeychainAccess { return stubKC{} }, "", "", "") + acct, err := p.ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("ResolveAccount: %v", err) + } + if acct.UserOpenId != "ou_a" { + t.Errorf("empty override should fall through to Users[0], got %q", acct.UserOpenId) + } +} + +func TestResolveAccount_UserOverrideMiss_TypedError(t *testing.T) { + writeMulti(t, &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_a", UserName: "Alice"}}, + }}, + }) + + p := NewDefaultAccountProvider(func() keychain.KeychainAccess { return stubKC{} }, "", "ou_ghost", "") + _, err := p.ResolveAccount(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *core.ConfigError, got %T: %v", err, err) + } + if cfgErr.Code != 3 || cfgErr.Type != "config" { + t.Errorf("err shape: code=%d type=%q, want 3/config", cfgErr.Code, cfgErr.Type) + } + if !strings.Contains(cfgErr.Message, "ou_ghost") { + t.Errorf("message missing requested user: %q", cfgErr.Message) + } + if !strings.Contains(cfgErr.Hint, "Alice") { + t.Errorf("hint should list available users: %q", cfgErr.Hint) + } + if strings.Contains(cfgErr.Hint, "LARKSUITE_CLI_OPEN_ID") { + t.Errorf("hint should not mention env var when source is empty: %q", cfgErr.Hint) + } +} + +// source="env" must add the unset-env remediation suffix to disambiguate +// stale shell env from a typo'd flag. +func TestResolveAccount_UserOverrideMiss_FromEnv_HasEnvHint(t *testing.T) { + writeMulti(t, &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_a", UserName: "Alice"}}, + }}, + }) + + p := NewDefaultAccountProvider(func() keychain.KeychainAccess { return stubKC{} }, "", "ou_ghost", "env") + _, err := p.ResolveAccount(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *core.ConfigError, got %T: %v", err, err) + } + if !strings.Contains(cfgErr.Hint, "LARKSUITE_CLI_OPEN_ID") { + t.Errorf("source=env hint should mention LARKSUITE_CLI_OPEN_ID, got: %q", cfgErr.Hint) + } + if !strings.Contains(cfgErr.Hint, "unset") { + t.Errorf("source=env hint should suggest unsetting, got: %q", cfgErr.Hint) + } +} + +// source="flag" must not stamp the env suffix; resolver hint already names --user. +func TestResolveAccount_UserOverrideMiss_FromFlag_NoEnvHint(t *testing.T) { + writeMulti(t, &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_a", UserName: "Alice"}}, + }}, + }) + + p := NewDefaultAccountProvider(func() keychain.KeychainAccess { return stubKC{} }, "", "ou_ghost", "flag") + _, err := p.ResolveAccount(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *core.ConfigError, got %T: %v", err, err) + } + if strings.Contains(cfgErr.Hint, "LARKSUITE_CLI_OPEN_ID") { + t.Errorf("source=flag hint should NOT mention LARKSUITE_CLI_OPEN_ID, got: %q", cfgErr.Hint) + } + if !strings.Contains(cfgErr.Hint, "auth login") { + t.Errorf("hint should still carry resolver's recovery copy: %q", cfgErr.Hint) + } +} + +// Profile-miss messages don't contain "user", so the user-env decoration +// must not be stamped onto them. +func TestResolveAccount_ProfileMiss_NotDecoratedWithEnvHint(t *testing.T) { + writeMulti(t, &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "alpha", AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, + }}, + }) + + p := NewDefaultAccountProvider(func() keychain.KeychainAccess { return stubKC{} }, "ghostprofile", "ou_alice", "env") + _, err := p.ResolveAccount(context.Background()) + if err == nil { + t.Fatal("expected profile-miss error, got nil") + } + var cfgErr *core.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *core.ConfigError, got %T: %v", err, err) + } + if !strings.Contains(cfgErr.Message, "profile") { + t.Errorf("expected profile-miss message, got: %q", cfgErr.Message) + } + if strings.Contains(cfgErr.Hint, "LARKSUITE_CLI_OPEN_ID") { + t.Errorf("profile-miss hint should not be stamped with user-env suffix: %q", cfgErr.Hint) + } +} diff --git a/internal/credential/integration_test.go b/internal/credential/integration_test.go index de173d194..7fa4df732 100644 --- a/internal/credential/integration_test.go +++ b/internal/credential/integration_test.go @@ -53,7 +53,6 @@ func TestFullChain_EnvWins(t *testing.T) { } func TestFullChain_Fallthrough(t *testing.T) { - // env provider returns nil (no env vars set), falls through to default token ep := &envprovider.Provider{} mock := &mockDefaultTokenProvider{token: "mock_tok", scopes: "drive:read"} @@ -101,7 +100,7 @@ func TestFullChain_ConfigStrictMode(t *testing.T) { } ep := &envprovider.Provider{} - defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "") + defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "", "", "") cp := credential.NewCredentialProvider( []extcred.Provider{ep}, @@ -117,13 +116,9 @@ func TestFullChain_ConfigStrictMode(t *testing.T) { } } -// TestFullChain_LangSurvivesProductionPath exercises the exact data flow the -// production Factory uses (factory_default.go Phase 3): disk → multi config → -// DefaultAccountProvider.ResolveAccount → Account → ToCliConfig. If Lang gets -// dropped at the credential boundary (as it would when Account lacks the field), -// shortcuts/common/runner.go RuntimeContext.Lang() returns "" and downstream -// consumers (mail signature, etc.) silently fall back to defaults — defeating -// the whole point of persisting --lang. +// TestFullChain_LangSurvivesProductionPath verifies Lang propagates through +// DefaultAccountProvider → Account → ToCliConfig; without it RuntimeContext.Lang() +// returns "" in production and --lang persistence is silently lost. func TestFullChain_LangSurvivesProductionPath(t *testing.T) { t.Setenv(envvars.CliAppID, "") t.Setenv(envvars.CliAppSecret, "") @@ -141,13 +136,13 @@ func TestFullChain_LangSurvivesProductionPath(t *testing.T) { t.Fatalf("SaveMultiAppConfig: %v", err) } - defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "") + defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "", "", "") acct, err := defaultAcct.ResolveAccount(context.Background()) if err != nil { t.Fatalf("ResolveAccount: %v", err) } if acct.Lang != i18n.LangJaJP { - t.Errorf("Account.Lang = %q, want %q (DefaultAccountProvider must propagate Lang from config)", acct.Lang, i18n.LangJaJP) + t.Errorf("Account.Lang = %q, want %q", acct.Lang, i18n.LangJaJP) } cfg := acct.ToCliConfig() @@ -155,6 +150,6 @@ func TestFullChain_LangSurvivesProductionPath(t *testing.T) { t.Fatal("ToCliConfig() = nil") } if cfg.Lang != i18n.LangJaJP { - t.Errorf("CliConfig.Lang = %q, want %q (this is the value RuntimeContext.Lang() reads in production)", cfg.Lang, i18n.LangJaJP) + t.Errorf("CliConfig.Lang = %q, want %q", cfg.Lang, i18n.LangJaJP) } } diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 7b4a23464..af4480ffd 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -12,6 +12,11 @@ const ( CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS" CliStrictMode = "LARKSUITE_CLI_STRICT_MODE" + // CliOpenID selects a user (open_id or username) within the active + // profile when --user is not passed. Read only at bootstrap; the + // credential and core.config resolver layers stay env-agnostic. + CliOpenID = "LARKSUITE_CLI_OPEN_ID" + // Sidecar proxy (auth proxy mode) CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384" CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar diff --git a/internal/errcompat/promote.go b/internal/errcompat/promote.go index 7f5cd6fa7..7822a7341 100644 --- a/internal/errcompat/promote.go +++ b/internal/errcompat/promote.go @@ -31,9 +31,19 @@ func PromoteConfigError(cfgErr *core.ConfigError) error { WithHint("%s", cfgErr.Hint). WithCause(cfgErr) case "config": + // SubtypeInvalidConfig covers every "the file exists but lark-cli + // can't safely use it" case: parse failures, semantic invalidity, + // AND R2 forward-incompat schema (whose message says + // "newer lark-cli ... schemaVersion N > supported M"). Misrouting + // R2 to SubtypeNotConfigured pushes AI agents toward `config init`, + // which would overwrite fields the newer binary populated. subtype := errs.SubtypeNotConfigured lower := strings.ToLower(cfgErr.Message) - if strings.Contains(lower, "parse") || strings.Contains(lower, "invalid") { + if strings.Contains(lower, "parse") || + strings.Contains(lower, "invalid") || + strings.Contains(lower, "schemaversion") || + strings.Contains(lower, "newer lark-cli") || + strings.Contains(lower, "failed to load config") { subtype = errs.SubtypeInvalidConfig } return errs.NewConfigError(subtype, "%s", cfgErr.Message). diff --git a/internal/errcompat/promote_r2_test.go b/internal/errcompat/promote_r2_test.go new file mode 100644 index 000000000..4482345a5 --- /dev/null +++ b/internal/errcompat/promote_r2_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errcompat_test + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errcompat" +) + +// R2 forward-incompat schema must promote to SubtypeInvalidConfig so the +// dispatcher does not push AI agents toward `config init` (which would +// overwrite fields a newer binary populated). +func TestPromoteConfigError_R2_ClassifiesAsInvalidConfig(t *testing.T) { + cases := []struct { + name string + message string + }{ + {"newer_binary_phrase", "config.json was written by a newer lark-cli (schemaVersion 99 > supported 1)"}, + {"schemaversion_phrase", "schemaVersion 5 > supported 1"}, + {"failed_to_load_wrap", "failed to load config: invalid config format: unexpected EOF"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &core.ConfigError{Type: "config", Code: 3, Message: tc.message, Hint: "upgrade lark-cli"} + got := errcompat.PromoteConfigError(cfg) + + var ce *errs.ConfigError + if !errors.As(got, &ce) { + t.Fatalf("expected *errs.ConfigError, got %T", got) + } + if ce.Subtype != errs.SubtypeInvalidConfig { + t.Errorf("subtype = %v, want SubtypeInvalidConfig (R2/parse must NOT route to NotConfigured)", ce.Subtype) + } + if ce.Hint != "upgrade lark-cli" { + t.Errorf("hint dropped during promotion: %q", ce.Hint) + } + }) + } +} diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go new file mode 100644 index 000000000..8d9914b3e --- /dev/null +++ b/internal/migrate/migrate.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package migrate handles schema-version upgrades for lark-cli's +// on-disk state. + +package migrate + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/core" +) + +// flockTimeout bounds the cross-process "login" flock wait. Racing +// migrators are benign: the loser no-ops and the next invocation sees +// the bumped SchemaVersion. +const flockTimeout = 5 * time.Second + +// noOp signals a declined run (no config, schema current, flock +// contended). Bootstrap treats it as success. +var noOp = errors.New("migrate: no-op") + +// IsNoOp reports whether err is the noOp sentinel. +func IsNoOp(err error) bool { return errors.Is(err, noOp) } + +// MaybeMigrate runs the legacy → multi-user migration if needed. +// Safe to call on every invocation; idempotent. Bootstrap callers +// should log-and-continue on error: legacy code paths remain +// back-compat with SchemaVersion=0 input. +// +// errOut receives best-effort warnings prefixed with +// "[lark-cli] [WARN] migrate: ...". Pass io.Discard to silence. +func MaybeMigrate(root larkauth.Root, errOut io.Writer) error { + if errOut == nil { + errOut = io.Discard + } + + // Pre-lock peek: skip without grabbing the flock when the schema + // is already current, so we don't contend with auth login / users + // use on every invocation. + multi, err := core.LoadMultiAppConfig() + if err != nil || multi == nil { + // No config yet — first `auth login` will stamp SchemaVersion. + return noOp + } + if multi.SchemaVersion >= core.CurrentSchemaVersion { + return noOp + } + + // Same flock used by login / users use / users logout / auth logout, + // so a migration in flight can't race with any of them. + flockCtx, cancel := context.WithTimeout(context.Background(), flockTimeout) + defer cancel() + lk, err := root.Locks(larkauth.SingleUser()).Acquire(flockCtx, "login", flockTimeout) + if err != nil { + // Lost the race — another process is migrating. + return noOp + } + defer lk.Release() + + // Reload under the flock: another migrator may have just finished. + multi, err = core.LoadMultiAppConfig() + if err != nil || multi == nil { + return noOp + } + if multi.SchemaVersion >= core.CurrentSchemaVersion { + return noOp + } + + now := time.Now().UTC() + + for i := range multi.Apps { + app := &multi.Apps[i] + for j := range app.Users { + u := &app.Users[j] + if u.UserOpenId == "" { + continue + } + if u.FirstAuthAt == nil { + ts := now + u.FirstAuthAt = &ts + } + ctx := larkauth.ForUser(app.AppId, u.UserOpenId) + + // Only write if missing, so a rerun doesn't clobber richer + // data saved by a later login. + if existing, perr := larkauth.LoadUserProfileFor(root, ctx); perr != nil || existing == nil { + p := larkauth.UserProfile{ + UserOpenId: u.UserOpenId, + UnionId: u.UnionId, + UserName: u.UserName, + CachedAt: now, + FirstAuthAt: now, + } + if u.FirstAuthAt != nil && !u.FirstAuthAt.IsZero() { + p.FirstAuthAt = u.FirstAuthAt.UTC() + } + if err := larkauth.SaveUserProfileFor(root, ctx, p); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] migrate: backfill sidecar profile for %s/%s: %v\n", app.AppId, u.UserOpenId, err) + } + } + + // RecordUserActivity is the same upsert login.go uses + // post-mint, so row shape stays byte-identical. nil scopes + // means "don't touch LastScopes". + if err := larkauth.RecordUserActivity(root, ctx, nil); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] migrate: backfill index row for %s/%s: %v\n", app.AppId, u.UserOpenId, err) + } + } + } + + // SaveMultiAppConfig forward-stamps SchemaVersion (bump-only-forward + // policy lives there). Always save, even when only sidecar/index + // backfills happened, otherwise SchemaVersion=0 stays on disk and + // the next invocation redoes the walk. + if err := core.SaveMultiAppConfig(multi); err != nil { + return fmt.Errorf("migrate: stamp schema version: %w", err) + } + return nil +} diff --git a/internal/migrate/migrate_test.go b/internal/migrate/migrate_test.go new file mode 100644 index 000000000..ce8b00bad --- /dev/null +++ b/internal/migrate/migrate_test.go @@ -0,0 +1,331 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package migrate + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/zalando/go-keyring" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/core" +) + +// withTempConfigDir points GetConfigDir at a fresh temp dir and returns it with a Root. +func withTempConfigDir(t *testing.T) (string, larkauth.Root) { + t.Helper() + keyring.MockInit() + cfgDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir) + t.Setenv("HOME", t.TempDir()) + return cfgDir, larkauth.NewLocalRoot(cfgDir) +} + +// writeLegacyConfigRaw writes JSON directly, bypassing SaveMultiAppConfig +// which would stamp SchemaVersion=1. +func writeLegacyConfigRaw(t *testing.T, json string) { + t.Helper() + cfgPath := core.GetConfigPath() + if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(cfgPath, []byte(json), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} + +// TestMaybeMigrate_NoConfig_NoOp tests fresh install with no config.json. +func TestMaybeMigrate_NoConfig_NoOp(t *testing.T) { + _, root := withTempConfigDir(t) + err := MaybeMigrate(root, io.Discard) + if !IsNoOp(err) { + t.Errorf("expected noOp, got %v", err) + } +} + +// TestMaybeMigrate_AlreadyCurrent_NoOp tests schema already at v1. +func TestMaybeMigrate_AlreadyCurrent_NoOp(t *testing.T) { + _, root := withTempConfigDir(t) + multi := &core.MultiAppConfig{ + SchemaVersion: core.CurrentSchemaVersion, + CurrentApp: "p", + Apps: []core.AppConfig{{ + Name: "p", AppId: "app-x", Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_a", UserName: "Alice"}}, + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + if err := MaybeMigrate(root, io.Discard); !IsNoOp(err) { + t.Errorf("expected noOp, got %v", err) + } +} + +// TestMaybeMigrate_LegacyConfig_StampsSchemaAndBackfillsSidecarAndIndex covers the headline migration path. +func TestMaybeMigrate_LegacyConfig_StampsSchemaAndBackfillsSidecarAndIndex(t *testing.T) { + cfgDir, root := withTempConfigDir(t) + writeLegacyConfigRaw(t, `{ + "currentApp": "p", + "apps": [ + { + "name": "p", + "appId": "app-x", + "appSecret": "secret", + "brand": "lark", + "users": [ + {"userOpenId": "ou_a", "userName": "Alice"}, + {"userOpenId": "ou_b", "userName": "Bob"} + ] + } + ] +}`) + + // Sanity check: legacy file really has SchemaVersion=0. + pre, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("Load (pre): %v", err) + } + if pre.SchemaVersion != 0 { + t.Fatalf("pre-migrate SchemaVersion = %d, want 0", pre.SchemaVersion) + } + + var warns bytes.Buffer + if err := MaybeMigrate(root, &warns); err != nil { + t.Fatalf("MaybeMigrate: %v", err) + } + + post, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("Load (post): %v", err) + } + if post.SchemaVersion != core.CurrentSchemaVersion { + t.Errorf("post-migrate SchemaVersion = %d, want %d", post.SchemaVersion, core.CurrentSchemaVersion) + } + + for _, u := range post.Apps[0].Users { + if u.FirstAuthAt == nil { + t.Errorf("AppUser %s: FirstAuthAt nil after migrate", u.UserOpenId) + } + } + + for _, oid := range []string{"ou_a", "ou_b"} { + path := filepath.Join(cfgDir, "users", "app-x", oid, "user_profile.json") + if _, err := os.Stat(path); err != nil { + t.Errorf("sidecar profile missing at %s: %v", path, err) + } + } + + entries, err := larkauth.UserIndexEntries(root) + if err != nil { + t.Fatalf("UserIndexEntries: %v", err) + } + seen := map[string]bool{} + for _, e := range entries { + seen[e.UserOpenId] = true + } + for _, oid := range []string{"ou_a", "ou_b"} { + if !seen[oid] { + t.Errorf("index row missing for %s; got: %#v", oid, entries) + } + } + + if warns.Len() > 0 { + t.Errorf("unexpected WARN output: %s", warns.String()) + } +} + +// TestMaybeMigrate_Idempotent: second run is a noOp and leaves state unchanged. +func TestMaybeMigrate_Idempotent(t *testing.T) { + _, root := withTempConfigDir(t) + writeLegacyConfigRaw(t, `{ + "currentApp": "p", + "apps": [{"name":"p","appId":"app-x","appSecret":"s","brand":"lark","users":[{"userOpenId":"ou_a","userName":"Alice"}]}] +}`) + + if err := MaybeMigrate(root, io.Discard); err != nil { + t.Fatalf("first migrate: %v", err) + } + first, _ := core.LoadMultiAppConfig() + firstFirstAuthAt := first.Apps[0].Users[0].FirstAuthAt + if firstFirstAuthAt == nil { + t.Fatal("first migrate did not stamp FirstAuthAt") + } + + // Second run: should be noOp because schema is now current. + err := MaybeMigrate(root, io.Discard) + if !IsNoOp(err) { + t.Errorf("second migrate: expected noOp, got %v", err) + } + + second, _ := core.LoadMultiAppConfig() + if second.Apps[0].Users[0].FirstAuthAt == nil || + !second.Apps[0].Users[0].FirstAuthAt.Equal(*firstFirstAuthAt) { + t.Errorf("FirstAuthAt mutated by no-op rerun: was %v, now %v", + firstFirstAuthAt, second.Apps[0].Users[0].FirstAuthAt) + } +} + +// TestMaybeMigrate_PreservesExistingFirstAuthAt: pre-populated FirstAuthAt must not be clobbered. +func TestMaybeMigrate_PreservesExistingFirstAuthAt(t *testing.T) { + _, root := withTempConfigDir(t) + pinned := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) + writeLegacyConfigRaw(t, `{ + "currentApp": "p", + "apps": [{ + "name":"p","appId":"app-x","appSecret":"s","brand":"lark", + "users":[{"userOpenId":"ou_a","userName":"Alice","firstAuthAt":"`+pinned+`"}] + }] +}`) + + if err := MaybeMigrate(root, io.Discard); err != nil { + t.Fatalf("MaybeMigrate: %v", err) + } + got, _ := core.LoadMultiAppConfig() + gotTS := got.Apps[0].Users[0].FirstAuthAt + if gotTS == nil { + t.Fatal("FirstAuthAt nil after migrate") + } + want, _ := time.Parse(time.RFC3339Nano, pinned) + if !gotTS.Equal(want) { + t.Errorf("FirstAuthAt = %v, want %v (preserved)", gotTS, want) + } +} + +// TestMaybeMigrate_PreservesExistingSidecar: an existing sidecar may have richer data +// (real CachedAt/FirstAuthAt) than the synthesized one, so it must not be overwritten. +func TestMaybeMigrate_PreservesExistingSidecar(t *testing.T) { + _, root := withTempConfigDir(t) + writeLegacyConfigRaw(t, `{ + "currentApp": "p", + "apps": [{"name":"p","appId":"app-x","appSecret":"s","brand":"lark","users":[{"userOpenId":"ou_a","userName":"Alice"}]}] +}`) + pinned := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + ctx := larkauth.ForUser("app-x", "ou_a") + if err := larkauth.SaveUserProfileFor(root, ctx, larkauth.UserProfile{ + UserOpenId: "ou_a", UserName: "Alice (cached)", CachedAt: pinned, FirstAuthAt: pinned, + }); err != nil { + t.Fatalf("SaveUserProfileFor: %v", err) + } + + if err := MaybeMigrate(root, io.Discard); err != nil { + t.Fatalf("MaybeMigrate: %v", err) + } + got, err := larkauth.LoadUserProfileFor(root, ctx) + if err != nil { + t.Fatalf("LoadUserProfileFor: %v", err) + } + if got == nil { + t.Fatal("sidecar deleted by migrator") + } + if !got.FirstAuthAt.Equal(pinned) { + t.Errorf("sidecar FirstAuthAt = %v, want %v (preserved)", got.FirstAuthAt, pinned) + } + if got.UserName != "Alice (cached)" { + t.Errorf("sidecar UserName = %q, want %q (preserved)", got.UserName, "Alice (cached)") + } +} + +// TestMaybeMigrate_NoUsers_StillStampsSchema: schema stamp on empty Users keeps +// subsequent loads from re-triggering the migrator. +func TestMaybeMigrate_NoUsers_StillStampsSchema(t *testing.T) { + _, root := withTempConfigDir(t) + writeLegacyConfigRaw(t, `{ + "currentApp": "p", + "apps": [{"name":"p","appId":"app-x","appSecret":"s","brand":"lark","users":[]}] +}`) + if err := MaybeMigrate(root, io.Discard); err != nil { + t.Fatalf("MaybeMigrate: %v", err) + } + got, _ := core.LoadMultiAppConfig() + if got.SchemaVersion != core.CurrentSchemaVersion { + t.Errorf("SchemaVersion = %d, want %d", got.SchemaVersion, core.CurrentSchemaVersion) + } +} + +// TestMaybeMigrate_SkipsUsersWithEmptyOpenId: empty UserOpenId is the gate; +// such rows are left alone, not stamped or sidecared. +func TestMaybeMigrate_SkipsUsersWithEmptyOpenId(t *testing.T) { + cfgDir, root := withTempConfigDir(t) + writeLegacyConfigRaw(t, `{ + "currentApp": "p", + "apps": [{"name":"p","appId":"app-x","appSecret":"s","brand":"lark","users":[{"userOpenId":"","userName":"Ghost"},{"userOpenId":"ou_a","userName":"Alice"}]}] +}`) + if err := MaybeMigrate(root, io.Discard); err != nil { + t.Fatalf("MaybeMigrate: %v", err) + } + got, _ := core.LoadMultiAppConfig() + if got.Apps[0].Users[0].FirstAuthAt != nil { + t.Error("ghost AppUser was stamped despite empty open_id") + } + if got.Apps[0].Users[1].FirstAuthAt == nil { + t.Error("Alice was NOT stamped despite valid open_id") + } + ghostDir := filepath.Join(cfgDir, "users", "app-x", "") + entries, err := os.ReadDir(ghostDir) + if err != nil { + t.Fatalf("ReadDir users/app-x: %v", err) + } + for _, e := range entries { + if e.Name() == "" { + t.Error("migrator created a sidecar dir for empty-openId user") + } + } +} + +// TestIsNoOp covers the sentinel API. +func TestIsNoOp(t *testing.T) { + if !IsNoOp(noOp) { + t.Error("IsNoOp(noOp) = false, want true") + } + if IsNoOp(errors.New("other")) { + t.Error("IsNoOp(other) = true, want false") + } + if IsNoOp(nil) { + t.Error("IsNoOp(nil) = true, want false") + } +} + +// TestMaybeMigrate_ForwardIncompatSchema_NoOp: when SchemaVersion > current, +// LoadMultiAppConfig returns *core.ConfigError and the migrator must treat that +// as a silent no-op so bootstrap (which swallows the return) doesn't crash or +// emit warnings on every invocation. The next config-touching command surfaces +// the upgrade hint at its own load site. +// +// Symmetric to TestLoadMultiAppConfig_RejectsForwardIncompatSchema in internal/core. +func TestMaybeMigrate_ForwardIncompatSchema_NoOp(t *testing.T) { + _, root := withTempConfigDir(t) + writeLegacyConfigRaw(t, `{ + "schemaVersion": 99, + "apps": [ + {"appId":"cli_x","appSecret":"s","brand":"feishu","users":[{"userOpenId":"ou_a","userName":"Alice"}]} + ] + }`) + + var errBuf bytes.Buffer + err := MaybeMigrate(root, &errBuf) + if !IsNoOp(err) { + t.Fatalf("MaybeMigrate on SchemaVersion=99: want noOp, got %v", err) + } + // Migrator must stay silent: bootstrap fires this on every invocation. + if errBuf.Len() != 0 { + t.Errorf("MaybeMigrate emitted unexpected output on forward-incompat config: %q", errBuf.String()) + } + + // On-disk config must remain untouched. Read raw rather than via the + // now-rejecting loader. + raw, err := os.ReadFile(filepath.Join(core.GetConfigDir(), "config.json")) + if err != nil { + t.Fatalf("read raw config: %v", err) + } + if !bytes.Contains(raw, []byte(`"schemaVersion": 99`)) { + t.Errorf("MaybeMigrate mutated a forward-incompat config: %s", raw) + } +} diff --git a/internal/validate/sanitize.go b/internal/validate/sanitize.go index aafe574f8..0185359e1 100644 --- a/internal/validate/sanitize.go +++ b/internal/validate/sanitize.go @@ -15,15 +15,31 @@ import ( var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;?>=!]*[a-zA-Z]|\x1b\][^\x07]*\x07`) // SanitizeForTerminal strips ANSI escape sequences, C0 control characters -// (except \n and \t), and dangerous Unicode from text, preserving the actual -// readable content. It should be applied to table format output and stderr -// messages, but NOT to json/ndjson output where programmatic consumers need -// the raw data. +// (except \n and \t), DEL (0x7f), and dangerous Unicode (zero-width joiners, +// RTL overrides, etc.) from text, preserving the readable content. // -// API responses may contain injected ANSI sequences that clear the screen, -// fake a colored "OK" status, or change the terminal title. In AI Agent -// scenarios, such injections can also pollute the LLM's context window -// with misleading output. +// Apply this anywhere a string is destined for a terminal — table-format +// stdout, every stderr message, and ALSO any human-readable string that is +// embedded inside a JSON/NDJSON payload for compatibility (e.g. a `message` +// field that mirrors what stderr just printed). The rule is "sanitize on +// write to a TTY," not "sanitize before json.Marshal": a JSON consumer that +// pretty-prints the payload still surfaces those bytes to a terminal, and +// the sanitization is a one-way transform that strips only renderer-control +// codes — readable characters, including Unicode letters, are preserved. +// +// Do NOT apply to typed identity / data fields whose programmatic consumers +// need raw bytes (e.g. `holder_user_name`, `requested[]`, `granted[]`): +// those carry data, and any escape-stripping there is the consumer's job +// once they know what context they will render to. JSON's own escaping +// already protects byte-level transport for the wire format. +// +// API responses (and persisted MultiAppConfig values originally sourced +// from API responses) may contain injected ANSI sequences that clear the +// screen, fake a colored "OK" status, or change the terminal title. In AI +// Agent scenarios, such injections can also pollute the LLM's context +// window with misleading output. The sanitize-on-write boundary keeps the +// data layer pristine while preventing any TTY-bound rendering surface +// from being hijacked. func SanitizeForTerminal(text string) string { if strings.ContainsRune(text, '\x1b') { text = ansiEscape.ReplaceAllString(text, "") diff --git a/skills/lark-shared/SKILL.md b/skills/lark-shared/SKILL.md index 494cabaed..13a220b12 100644 --- a/skills/lark-shared/SKILL.md +++ b/skills/lark-shared/SKILL.md @@ -104,6 +104,62 @@ lark-cli auth login --device-code - **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL - **禁止缓存 `verification_url` 或 `device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用 +#### `auth login --json` 输出契约(NDJSON) + +`auth login --json` 输出的是 **NDJSON**(每行一个 JSON 对象)—— **只解析最后一条非空行作为成功载荷**。三种调用路径下输出形态如下: + +| 调用 | 输出行(顺序) | +|---|---| +| `--no-wait --json` | 1 行:`{verification_url, device_code, expires_in, hint}`(**无 `event` 字段**) | +| `--device-code --json` | 1 行:`{event: "authorization_complete", ...}` | +| `--json`(同步阻塞,罕见) | 2 行:先 `{event: "device_authorization", verification_uri, verification_uri_complete, user_code, expires_in, agent_hint}`,再 `{event: "authorization_complete", ...}` | + +`authorization_complete` 行的关键字段: + +- `event`(恒为 `"authorization_complete"`) +- `user_open_id` —— 实际授权的 open_id(**不是** profile 上的 active user) +- `user_name` —— 实际授权身份的 display name +- `scope` —— 单数,本次最终授予的 scope 列表,空格分隔字符串 +- `requested` / `granted` / `newly_granted` / `already_granted` / `missing` —— 数组(永不为 `null`,无内容时为 `[]`);`requested` 是本次请求的 scope,`granted` 是 `scope` 字段的数组形式,`newly_granted` 是与上次登录相比新拿到的,`already_granted` 是延续的,`missing` 是请求了但被拒的 +- 可选 `warning` —— scope 不足软告警,schema 见下文 +- 可选 `holder_mismatch_warning` —— 多用户软告警,schema 见下文 + +授权失败会发 `{event: "authorization_failed", error}`。 + +只看最后一行就够了:split-flow 第一步只有 `--no-wait` 那一行;第二步只有 `authorization_complete`;同步路径下最后一行也是 `authorization_complete`。 + +##### `holder_mismatch_warning` 字段(多用户软告警) + +当存在以下条件**全部成立**时,`authorization_complete` 会带 `holder_mismatch_warning`: + +- 用户**没有**通过 `--user` / `LARKSUITE_CLI_OPEN_ID` 显式指定目标身份 +- 当前 profile 的 `currentUser`(来自上次 `auth login` 或 `auth users use`)与本次设备授权的 open_id 不一致 + +这是**软告警,不是错误**:本次登录会成功,新身份会追加到 profile 的 `Users[]`,但 active user **不会**自动切换。要切换 active user,运行 `lark-cli auth users use `。 + +干净登录(首次登录或 currentUser 与本次授权的身份相同)**完全不会**有这个 key —— consumer 应通过 key 是否存在判断(不要 nil-check)。 + +字段 schema: + +```json +{ + "type": "holder_currentuser_mismatch", + "message": "[lark-cli] [WARN] auth login: ...", + "holder_open_id": "ou_alice", + "holder_user_name": "Alice", + "fresh_open_id": "ou_bob", + "fresh_user_name": "Bob" +} +``` + +- `type`:discriminator,与现有 scope `warning.type`(`missing_scope`)共用 schema 形状但取值独立。`holder_currentuser_mismatch` 是当前唯一取值;后续若新增 holder 告警子型,会用 `holder_*` 前缀复用同一字段。 +- `message`:人类可读 stderr WARN 文本的副本,**已经过 terminal escape 净化**(没有 ANSI / C0 控制字节)。 +- `holder_*` / `fresh_*` 字段:**未净化的原始字节**(按 JSON 消费者契约,由消费者自行 escape)。`holder_*` 是 profile 中保留的 active user,`fresh_*` 是这次刚授权的身份。 + +scope 告警(`warning`,type=`missing_scope`)和 holder 告警(`holder_mismatch_warning`,type=`holder_currentuser_mismatch`)是**两个独立的 key**,互不影响。一次登录可以同时触发两者;分别 branch 即可。 + +stderr WARN 与这个 JSON 字段是**双通道**:在 JSON 模式下 stderr WARN **仍然会输出**,方便人 tail `2>&1`。 + ## 更新检查 lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。