diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 467755bf1..0398cc368 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -34,6 +34,8 @@ type LoginOptions struct { DeviceCode string } +var pollDeviceToken = larkauth.PollDeviceToken + // NewCmdAuthLogin creates the auth login subcommand. func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { opts := &LoginOptions{Factory: f} @@ -235,6 +237,9 @@ func authLoginRun(opts *LoginOptions) error { // --no-wait: return immediately with device code and URL if opts.NoWait { + if err := saveLoginRequestedScope(authResp.DeviceCode, finalScope); err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to cache requested scopes: %v\n", err) + } data := map[string]interface{}{ "verification_url": authResp.VerificationUriComplete, "device_code": authResp.DeviceCode, @@ -244,7 +249,7 @@ func authLoginRun(opts *LoginOptions) error { encoder := json.NewEncoder(f.IOStreams.Out) encoder.SetEscapeHTML(false) if err := encoder.Encode(data); err != nil { - fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err) + return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err) } return nil } @@ -261,7 +266,7 @@ func authLoginRun(opts *LoginOptions) error { encoder := json.NewEncoder(f.IOStreams.Out) encoder.SetEscapeHTML(false) if err := encoder.Encode(data); err != nil { - fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err) + return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err) } } else { fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL) @@ -270,20 +275,26 @@ func authLoginRun(opts *LoginOptions) error { // Step 3: Poll for token log(msg.WaitingAuth) - result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, + result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) if !result.OK { if opts.JSON { - b, _ := json.Marshal(map[string]interface{}{ + encoder := json.NewEncoder(f.IOStreams.Out) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(map[string]interface{}{ "event": "authorization_failed", "error": result.Message, - }) - fmt.Fprintln(f.IOStreams.Out, string(b)) + }); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err) + } return output.ErrBare(output.ExitAuth) } return output.ErrAuth("authorization failed: %s", result.Message) } + if result.Token == nil { + return output.ErrAuth("authorization succeeded but no token returned") + } // Step 6: Get user info log(msg.AuthSuccess) @@ -296,6 +307,8 @@ func authLoginRun(opts *LoginOptions) error { return output.ErrAuth("failed to get user info: %v", err) } + scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope) + // Step 7: Store token now := time.Now().UnixMilli() storedToken := &larkauth.StoredUAToken{ @@ -318,21 +331,11 @@ func authLoginRun(opts *LoginOptions) error { return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err) } - if opts.JSON { - b, _ := json.Marshal(map[string]interface{}{ - "event": "authorization_complete", - "user_open_id": openId, - "user_name": userName, - "scope": result.Token.Scope, - }) - fmt.Fprintln(f.IOStreams.Out, string(b)) - } else { - fmt.Fprintln(f.IOStreams.ErrOut) - output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId)) - if result.Token.Scope != "" { - fmt.Fprintf(f.IOStreams.ErrOut, msg.GrantedScopes, result.Token.Scope) - } + if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil { + return handleLoginScopeIssue(opts, msg, f, issue, openId, userName) } + + writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary) return nil } @@ -345,13 +348,26 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo if err != nil { return err } + requestedScope, err := loadLoginRequestedScope(opts.DeviceCode) + if err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err) + } + cleanupRequestedScope := func() { + if err := removeLoginRequestedScope(opts.DeviceCode); err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err) + } + } log(msg.WaitingAuth) - result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, + result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, opts.DeviceCode, 5, 180, f.IOStreams.ErrOut) if !result.OK { + if shouldRemoveLoginRequestedScope(result) { + cleanupRequestedScope() + } return output.ErrAuth("authorization failed: %s", result.Message) } + defer cleanupRequestedScope() if result.Token == nil { return output.ErrAuth("authorization succeeded but no token returned") } @@ -367,6 +383,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo return output.ErrAuth("failed to get user info: %v", err) } + scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope) + // Store token now := time.Now().UnixMilli() storedToken := &larkauth.StoredUAToken{ @@ -389,7 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err) } - output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId)) + if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil { + return handleLoginScopeIssue(opts, msg, f, issue, openId, userName) + } + + writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary) return nil } diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go index f16349679..9de3361d8 100644 --- a/cmd/auth/login_messages.go +++ b/cmd/auth/login_messages.go @@ -20,11 +20,17 @@ type loginMsg struct { ConfirmAuth string // Non-interactive prompts (login.go) - OpenURL string - WaitingAuth string - AuthSuccess string - LoginSuccess string - GrantedScopes string + OpenURL string + WaitingAuth string + AuthSuccess string + LoginSuccess string + ScopeMismatch string + ScopeHint string + RequestedScopes string + NewlyGrantedScopes string + MissingScopes string + NoScopes string + StatusHint string // Non-interactive hint (no flags) HintHeader string @@ -50,11 +56,17 @@ var loginMsgZh = &loginMsg{ ErrNoDomain: "请至少选择一个业务域", ConfirmAuth: "确认授权?", - OpenURL: "在浏览器中打开以下链接进行认证:\n\n", - WaitingAuth: "等待用户授权...", - AuthSuccess: "授权成功,正在获取用户信息...", - LoginSuccess: "登录成功! 用户: %s (%s)", - GrantedScopes: " 已授权 scopes: %s\n", + OpenURL: "在浏览器中打开以下链接进行认证:\n\n", + WaitingAuth: "等待用户授权...", + AuthSuccess: "授权成功,正在获取用户信息...", + LoginSuccess: "登录成功! 用户: %s (%s)", + ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s", + ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + RequestedScopes: " 本次请求 scopes: %s\n", + NewlyGrantedScopes: " 本次新授予 scopes: %s\n", + MissingScopes: " 本次未授予 scopes: %s\n", + NoScopes: "(空)", + StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", HintHeader: "请指定要授权的权限:\n", HintCommon1: " --recommend 授权推荐权限", @@ -79,11 +91,17 @@ var loginMsgEn = &loginMsg{ ErrNoDomain: "please select at least one domain", ConfirmAuth: "Confirm authorization?", - OpenURL: "Open this URL in your browser to authenticate:\n\n", - WaitingAuth: "Waiting for user authorization...", - AuthSuccess: "Authorization successful, fetching user info...", - LoginSuccess: "Login successful! User: %s (%s)", - GrantedScopes: " Granted scopes: %s\n", + OpenURL: "Open this URL in your browser to authenticate:\n\n", + WaitingAuth: "Waiting for user authorization...", + AuthSuccess: "Authorization successful, fetching user info...", + LoginSuccess: "Login successful! User: %s (%s)", + ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s", + ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.", + RequestedScopes: " Requested scopes: %s\n", + NewlyGrantedScopes: " Newly granted scopes: %s\n", + MissingScopes: " Not granted scopes: %s\n", + NoScopes: "(none)", + StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.", HintHeader: "Please specify the scopes to authorize:\n", HintCommon1: " --recommend authorize recommended scopes", diff --git a/cmd/auth/login_messages_test.go b/cmd/auth/login_messages_test.go index 500866ded..f0c8808b7 100644 --- a/cmd/auth/login_messages_test.go +++ b/cmd/auth/login_messages_test.go @@ -69,12 +69,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) { t.Errorf("%s LoginSuccess has no format verb", lang) } - // GrantedScopes should contain %s - got = fmt.Sprintf(msg.GrantedScopes, "scope1 scope2") - if got == msg.GrantedScopes { - t.Errorf("%s GrantedScopes has no format verb", lang) - } - // SummaryDomains should contain %s got = fmt.Sprintf(msg.SummaryDomains, "calendar, task") if got == msg.SummaryDomains { diff --git a/cmd/auth/login_result.go b/cmd/auth/login_result.go new file mode 100644 index 000000000..ef288d45e --- /dev/null +++ b/cmd/auth/login_result.go @@ -0,0 +1,232 @@ +package auth + +import ( + "encoding/json" + "fmt" + "strings" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" +) + +type loginScopeSummary struct { + Requested []string + NewlyGranted []string + AlreadyGranted []string + Granted []string + Missing []string +} + +type loginScopeIssue struct { + Message string + Hint string + Summary *loginScopeSummary +} + +// ensureRequestedScopesGranted checks whether all requested scopes were granted +// and returns a structured issue when any requested scope is missing. +func ensureRequestedScopesGranted(requestedScope, grantedScope string, msg *loginMsg, summary *loginScopeSummary) *loginScopeIssue { + requested := uniqueScopeList(requestedScope) + if len(requested) == 0 { + return nil + } + + missing := larkauth.MissingScopes(grantedScope, requested) + if len(missing) == 0 { + return nil + } + + if summary == nil { + summary = &loginScopeSummary{ + Requested: requested, + Granted: strings.Fields(grantedScope), + Missing: missing, + } + } + return &loginScopeIssue{ + Message: fmt.Sprintf(msg.ScopeMismatch, strings.Join(missing, " ")), + Hint: msg.ScopeHint, + Summary: summary, + } +} + +// loadLoginScopeSummary builds a scope summary by comparing the requested scopes, +// previously stored scopes, and the newly granted scopes from the current login. +func loadLoginScopeSummary(appID, openId, requestedScope, grantedScope string) *loginScopeSummary { + previousScope := "" + if previous := larkauth.GetStoredToken(appID, openId); previous != nil { + previousScope = previous.Scope + } + return buildLoginScopeSummary(requestedScope, previousScope, grantedScope) +} + +// buildLoginScopeSummary classifies requested scopes into newly granted, +// already granted, and missing buckets while preserving the final granted list. +func buildLoginScopeSummary(requestedScope, previousScope, grantedScope string) *loginScopeSummary { + requested := uniqueScopeList(requestedScope) + previous := uniqueScopeList(previousScope) + granted := uniqueScopeList(grantedScope) + previousSet := make(map[string]bool, len(previous)) + for _, scope := range previous { + previousSet[scope] = true + } + grantedSet := make(map[string]bool, len(granted)) + for _, scope := range granted { + grantedSet[scope] = true + } + + summary := &loginScopeSummary{ + Requested: requested, + Granted: granted, + } + for _, scope := range requested { + if !grantedSet[scope] { + summary.Missing = append(summary.Missing, scope) + continue + } + if previousSet[scope] { + summary.AlreadyGranted = append(summary.AlreadyGranted, scope) + continue + } + summary.NewlyGranted = append(summary.NewlyGranted, scope) + } + return summary +} + +// uniqueScopeList splits a scope string into a de-duplicated ordered slice. +func uniqueScopeList(scope string) []string { + seen := make(map[string]bool) + var result []string + for _, item := range strings.Fields(scope) { + if seen[item] { + continue + } + seen[item] = true + result = append(result, item) + } + return result +} + +// formatScopeList joins scopes for display and falls back to the provided empty +// label when the input slice is empty. +func formatScopeList(scopes []string, empty string) string { + if len(scopes) == 0 { + return empty + } + return strings.Join(scopes, " ") +} + +// emptyIfNil normalizes nil slices to empty slices for stable JSON output. +func emptyIfNil(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +// writeLoginScopeBreakdown renders the requested/newly granted/missing scope +// breakdown to stderr. +func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) { + if summary == nil { + summary = &loginScopeSummary{} + } + fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes)) + fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes)) + fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes)) +} + +// 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) { + if summary == nil { + summary = &loginScopeSummary{} + } + if opts.JSON { + b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil)) + fmt.Fprintln(f.IOStreams.Out, string(b)) + return + } + + fmt.Fprintln(f.IOStreams.ErrOut) + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId)) + writeLoginScopeBreakdown(f.IOStreams, msg, summary) + if len(summary.Missing) == 0 && msg.StatusHint != "" { + fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint) + } +} + +// 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 { + 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)) + return nil + } + detail := map[string]interface{}{ + "requested": issue.Summary.Requested, + "granted": issue.Summary.Granted, + "missing": issue.Summary.Missing, + } + return &output.ExitError{ + Code: output.ExitAuth, + Detail: &output.ErrDetail{ + Type: "missing_scope", + Message: issue.Message, + Hint: issue.Hint, + Detail: detail, + }, + } + } + + fmt.Fprintln(f.IOStreams.ErrOut) + if loginSucceeded { + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId)) + } else { + fmt.Fprintln(f.IOStreams.ErrOut, issue.Message) + } + if loginSucceeded { + fmt.Fprintln(f.IOStreams.ErrOut, issue.Message) + } + writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary) + if issue.Hint != "" { + fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint) + } + if loginSucceeded { + return nil + } + return output.ErrBare(output.ExitAuth) +} + +// 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{} { + if summary == nil { + summary = &loginScopeSummary{} + } + payload := map[string]interface{}{ + "event": "authorization_complete", + "user_open_id": openId, + "user_name": userName, + "scope": strings.Join(summary.Granted, " "), + "requested": emptyIfNil(summary.Requested), + "newly_granted": emptyIfNil(summary.NewlyGranted), + "already_granted": emptyIfNil(summary.AlreadyGranted), + "missing": emptyIfNil(summary.Missing), + "granted": emptyIfNil(summary.Granted), + } + if issue != nil { + payload["warning"] = map[string]interface{}{ + "type": "missing_scope", + "message": issue.Message, + "hint": issue.Hint, + } + } + return payload +} diff --git a/cmd/auth/login_scope_cache.go b/cmd/auth/login_scope_cache.go new file mode 100644 index 000000000..2549c0e35 --- /dev/null +++ b/cmd/auth/login_scope_cache.go @@ -0,0 +1,91 @@ +package auth + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "regexp" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +type loginScopeCacheRecord struct { + RequestedScope string `json:"requested_scope"` +} + +// loginScopeCacheDir returns the directory used to persist auth login --no-wait +// requested scopes keyed by device_code. +func loginScopeCacheDir() string { + return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes") +} + +// loginScopeCachePath returns the cache file path for a given device_code. +func loginScopeCachePath(deviceCode string) string { + return filepath.Join(loginScopeCacheDir(), sanitizeLoginScopeCacheKey(deviceCode)+".json") +} + +// sanitizeLoginScopeCacheKey converts a device_code into a safe filename token. +func sanitizeLoginScopeCacheKey(deviceCode string) string { + sanitized := loginScopeCacheSafeChars.ReplaceAllString(deviceCode, "_") + if sanitized == "" { + return "default" + } + return sanitized +} + +// saveLoginRequestedScope persists the requested scope string for a device_code. +func saveLoginRequestedScope(deviceCode, requestedScope string) error { + if err := vfs.MkdirAll(loginScopeCacheDir(), 0700); err != nil { + return err + } + data, err := json.Marshal(loginScopeCacheRecord{RequestedScope: requestedScope}) + if err != nil { + return err + } + return validate.AtomicWrite(loginScopeCachePath(deviceCode), data, 0600) +} + +// loadLoginRequestedScope loads the cached requested scope string for a device_code. +// It returns an empty string if no cache entry exists. +func loadLoginRequestedScope(deviceCode string) (string, error) { + data, err := vfs.ReadFile(loginScopeCachePath(deviceCode)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return "", err + } + var record loginScopeCacheRecord + if err := json.Unmarshal(data, &record); err != nil { + _ = vfs.Remove(loginScopeCachePath(deviceCode)) + return "", err + } + return record.RequestedScope, nil +} + +// removeLoginRequestedScope deletes the cache entry for a device_code. +func removeLoginRequestedScope(deviceCode string) error { + err := vfs.Remove(loginScopeCachePath(deviceCode)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// shouldRemoveLoginRequestedScope indicates whether the requested-scope cache +// should be removed after polling finishes. +func shouldRemoveLoginRequestedScope(result *larkauth.DeviceFlowResult) bool { + if result == nil { + return false + } + if result.OK || result.Error == "access_denied" { + return true + } + return result.Error == "expired_token" && result.Message != "Polling was cancelled" +} diff --git a/cmd/auth/login_scope_cache_test.go b/cmd/auth/login_scope_cache_test.go new file mode 100644 index 000000000..4d0bea91c --- /dev/null +++ b/cmd/auth/login_scope_cache_test.go @@ -0,0 +1,48 @@ +package auth + +import ( + "errors" + "os" + "testing" + + "github.com/larksuite/cli/internal/vfs" +) + +func TestLoginRequestedScopeCache_RoundTrip(t *testing.T) { + setupLoginConfigDir(t) + + deviceCode := "device/code:123" + requestedScope := "im:message:send im:message:reply" + + if err := saveLoginRequestedScope(deviceCode, requestedScope); err != nil { + t.Fatalf("saveLoginRequestedScope() error = %v", err) + } + got, err := loadLoginRequestedScope(deviceCode) + if err != nil { + t.Fatalf("loadLoginRequestedScope() error = %v", err) + } + if got != requestedScope { + t.Fatalf("requestedScope = %q, want %q", got, requestedScope) + } + if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); err != nil { + t.Fatalf("Stat(cachePath) error = %v", err) + } + if err := removeLoginRequestedScope(deviceCode); err != nil { + t.Fatalf("removeLoginRequestedScope() error = %v", err) + } + if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(cachePath) error = %v, want not exist", err) + } +} + +func TestLoadLoginRequestedScope_MissingReturnsEmpty(t *testing.T) { + setupLoginConfigDir(t) + + got, err := loadLoginRequestedScope("missing-device-code") + if err != nil { + t.Fatalf("loadLoginRequestedScope() error = %v", err) + } + if got != "" { + t.Fatalf("requestedScope = %q, want empty", got) + } +} diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 06f9717e8..fa6942b3e 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -5,16 +5,29 @@ package auth import ( "context" + "encoding/json" + "errors" + "io" + "net/http" "sort" "strings" "testing" + 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" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts/common" + "github.com/zalando/go-keyring" ) +type failWriter struct{} + +func (failWriter) Write([]byte) (int, error) { + return 0, errors.New("write failed") +} + func TestSuggestDomain_PrefixMatch(t *testing.T) { known := map[string]bool{ "calendar": true, @@ -282,6 +295,606 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { } } +func TestEnsureRequestedScopesGranted(t *testing.T) { + issue := ensureRequestedScopesGranted("im:message:send im:message:reply", "im:message:reply", getLoginMsg("en"), nil) + if issue == nil { + t.Fatal("expected missing scope issue") + } + if !strings.Contains(issue.Message, "im:message:send") { + t.Fatalf("message %q missing requested scope", issue.Message) + } + for _, want := range []string{"Do not retry continuously", "scope being disabled", "lark-cli auth status"} { + if !strings.Contains(issue.Hint, want) { + t.Fatalf("hint %q missing %q", issue.Hint, want) + } + } + if got := strings.Join(issue.Summary.Missing, " "); got != "im:message:send" { + t.Fatalf("Missing = %q", got) + } +} + +func TestBuildLoginScopeSummary(t *testing.T) { + summary := buildLoginScopeSummary("im:message:send im:message:reply im:message:send", "im:message:reply", "im:message:send im:message:reply im:chat:read") + if got := strings.Join(summary.Requested, " "); got != "im:message:send im:message:reply" { + t.Fatalf("Requested = %q", got) + } + if got := strings.Join(summary.NewlyGranted, " "); got != "im:message:send" { + t.Fatalf("NewlyGranted = %q", got) + } + if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" { + t.Fatalf("AlreadyGranted = %q", got) + } + if len(summary.Missing) != 0 { + t.Fatalf("Missing = %v, want empty", summary.Missing) + } + if got := strings.Join(summary.Granted, " "); got != "im:message:send im:message:reply im:chat:read" { + t.Fatalf("Granted = %q", got) + } +} + +func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{ + Requested: []string{"im:message:send", "im:message:reply"}, + NewlyGranted: []string{"im:message:send"}, + AlreadyGranted: []string{"im:message:reply"}, + Granted: []string{"im:message:send", "im:message:reply"}, + }) + + var data map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &data); err != nil { + t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String()) + } + if data["event"] != "authorization_complete" { + t.Fatalf("event = %v", data["event"]) + } + if data["scope"] != "im:message:send im:message:reply" { + t.Fatalf("scope = %v", data["scope"]) + } + if len(data["newly_granted"].([]interface{})) != 1 { + t.Fatalf("newly_granted = %#v", data["newly_granted"]) + } + if len(data["already_granted"].([]interface{})) != 1 { + t.Fatalf("already_granted = %#v", data["already_granted"]) + } +} + +func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{ + Message: "授权完成,但以下请求 scopes 未被授予: im:message:send", + Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + Summary: &loginScopeSummary{ + Requested: []string{"im:message:send"}, + Missing: []string{"im:message:send"}, + Granted: []string{"base:app:copy"}, + }, + }, "ou_user", "tester") + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + got := stderr.String() + for _, want := range []string{ + "OK: 登录成功! 用户: tester (ou_user)", + "授权完成,但以下请求 scopes 未被授予: im:message:send", + "本次请求 scopes: im:message:send", + "本次新授予 scopes: (空)", + "本次未授予 scopes: im:message:send", + "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试", + "scope 被禁用", + "lark-cli auth status", + } { + if !strings.Contains(got, want) { + t.Fatalf("stderr missing %q, got:\n%s", want, got) + } + } + if strings.Contains(got, "最终已授权 scopes:") { + t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got) + } + if strings.Contains(got, "ERROR:") { + t.Fatalf("stderr should not contain error prefix, got:\n%s", got) + } +} + +func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{ + Message: "authorization completed, but these requested scopes were not granted: im:message:send", + Hint: "Granted scopes: base:app:copy. Check app scopes.", + Summary: &loginScopeSummary{ + Requested: []string{"im:message:send"}, + Missing: []string{"im:message:send"}, + Granted: []string{"base:app:copy"}, + }, + }, "ou_user", "tester") + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &data); err != nil { + t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String()) + } + if data["event"] != "authorization_complete" { + t.Fatalf("event = %v", data["event"]) + } + if data["user_open_id"] != "ou_user" { + t.Fatalf("user_open_id = %v", data["user_open_id"]) + } + warning, ok := data["warning"].(map[string]interface{}) + if !ok { + t.Fatalf("warning = %#v", data["warning"]) + } + if warning["type"] != "missing_scope" { + t.Fatalf("warning.type = %v", warning["type"]) + } +} + +func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{ + Granted: []string{"offline_access"}, + }) + + var data map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &data); err != nil { + t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String()) + } + for _, k := range []string{"requested", "newly_granted", "already_granted", "missing", "granted"} { + v, ok := data[k] + if !ok { + t.Fatalf("missing key %q in payload: %v", k, data) + } + if _, ok := v.([]interface{}); !ok { + t.Fatalf("%s = %#v, want JSON array", k, v) + } + } +} + +func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) { + tests := []struct { + name string + summary *loginScopeSummary + expectedPresent []string + expectedAbsent []string + }{ + { + name: "mixed newly granted and already granted", + summary: &loginScopeSummary{ + Requested: []string{"im:message:send", "im:message:reply"}, + NewlyGranted: []string{"im:message:send"}, + AlreadyGranted: []string{"im:message:reply"}, + Granted: []string{"im:message:send", "im:message:reply"}, + }, + expectedPresent: []string{ + "登录成功! 用户: tester (ou_user)", + "本次请求 scopes: im:message:send im:message:reply", + "本次新授予 scopes: im:message:send", + "本次未授予 scopes: (空)", + "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + }, + expectedAbsent: []string{ + "最终已授权 scopes:", + "已有 scopes:", + }, + }, + { + name: "all already granted", + summary: &loginScopeSummary{ + Requested: []string{"im:message:send"}, + AlreadyGranted: []string{"im:message:send"}, + Granted: []string{"im:message:send", "contact:user.base:readonly"}, + }, + expectedPresent: []string{ + "本次请求 scopes: im:message:send", + "本次新授予 scopes: (空)", + "本次未授予 scopes: (空)", + "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + }, + expectedAbsent: []string{ + "最终已授权 scopes:", + "已有 scopes:", + }, + }, + { + name: "missing scopes are shown", + summary: &loginScopeSummary{ + Requested: []string{"im:message:send", "im:message:reply"}, + Missing: []string{"im:message:send"}, + Granted: []string{"im:message:reply"}, + }, + expectedPresent: []string{ + "本次请求 scopes: im:message:send im:message:reply", + "本次新授予 scopes: (空)", + "本次未授予 scopes: im:message:send", + }, + expectedAbsent: []string{ + "已有 scopes:", + "最终已授权 scopes:", + "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + }, + }, + } + + 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) + + got := stderr.String() + for _, want := range tt.expectedPresent { + if !strings.Contains(got, want) { + t.Fatalf("stderr missing %q, got:\n%s", want, got) + } + } + for _, unwanted := range tt.expectedAbsent { + if strings.Contains(got, unwanted) { + t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got) + } + } + }) + } +} + +func TestBuildLoginScopeSummary_WithMissingScopes(t *testing.T) { + summary := buildLoginScopeSummary("im:message:send im:message:reply", "im:message:reply", "im:message:reply") + if got := strings.Join(summary.NewlyGranted, " "); got != "" { + t.Fatalf("NewlyGranted = %q, want empty", got) + } + if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" { + t.Fatalf("AlreadyGranted = %q", got) + } + if got := strings.Join(summary.Missing, " "); got != "im:message:send" { + t.Fatalf("Missing = %q", got) + } +} + +func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + t.Setenv("HOME", t.TempDir()) + + multi := &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + {Name: "default", AppId: "cli_test"}, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + AppSecret: "secret", + Brand: core.BrandFeishu, + }) + + 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": "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_user", + "name": "tester", + }, + }, + }) + + err := authLoginRun(&LoginOptions{ + Factory: f, + Ctx: context.Background(), + Scope: "im:message:send", + }) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + got := stderr.String() + for _, want := range []string{ + "OK: 登录成功! 用户: tester (ou_user)", + "授权完成,但以下请求 scopes 未被授予: im:message:send", + "本次请求 scopes: im:message:send", + "本次未授予 scopes: im:message:send", + "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试", + "scope 被禁用", + "lark-cli auth status", + } { + if !strings.Contains(got, want) { + t.Fatalf("stderr missing %q, got:\n%s", want, got) + } + } + if strings.Contains(got, "最终已授权 scopes:") { + t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got) + } + if strings.Contains(got, "ERROR:") { + t.Fatalf("stderr should not contain error prefix, got:\n%s", got) + } + stored := larkauth.GetStoredToken("cli_test", "ou_user") + if stored == nil { + t.Fatal("expected token to be stored when authorization succeeds with missing scopes") + } + if stored.Scope != "offline_access" { + t.Fatalf("stored scope = %q", stored.Scope) + } + cfg, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig() error = %v", err) + } + if len(cfg.Apps) != 1 || len(cfg.Apps[0].Users) != 1 { + t.Fatalf("unexpected users in config: %#v", cfg.Apps) + } + if cfg.Apps[0].Users[0].UserOpenId != "ou_user" { + t.Fatalf("stored user open id = %q", cfg.Apps[0].Users[0].UserOpenId) + } + if cfg.Apps[0].Users[0].UserName != "tester" { + t.Fatalf("stored user name = %q", cfg.Apps[0].Users[0].UserName) + } +} + +func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + t.Setenv("HOME", t.TempDir()) + + multi := &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + {Name: "default", AppId: "cli_test"}, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + AppSecret: "secret", + Brand: core.BrandFeishu, + }) + + 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": "ou_user", + "name": "tester", + }, + }, + }) + + err := authLoginRun(&LoginOptions{ + Factory: f, + Ctx: context.Background(), + Scope: "im:message:send", + NoWait: true, + }) + if err != nil { + t.Fatalf("no-wait authLoginRun() error = %v", err) + } + if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "im:message:send" { + t.Fatalf("loadLoginRequestedScope() = (%q, %v), want requested scope", got, err) + } + + stdout.Reset() + stderr.Reset() + + err = authLoginRun(&LoginOptions{ + Factory: f, + Ctx: context.Background(), + DeviceCode: "device-code", + }) + if err != nil { + t.Fatalf("device-code authLoginRun() error = %v", err) + } + got := stderr.String() + for _, want := range []string{ + "OK: 登录成功! 用户: tester (ou_user)", + "本次请求 scopes: im:message:send", + "本次新授予 scopes: im:message:send", + "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + } { + if !strings.Contains(got, want) { + t.Fatalf("stderr missing %q, got:\n%s", want, got) + } + } + if strings.Contains(got, "最终已授权 scopes:") { + t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got) + } + if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" { + t.Fatalf("loadLoginRequestedScope() after cleanup = (%q, %v), want empty", got, err) + } +} + +func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + + writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{ + Requested: []string{"im:message:send"}, + NewlyGranted: []string{"im:message:send"}, + Granted: []string{"im:message:send"}, + }) + + got := stderr.String() + for _, want := range []string{ + "Login successful! User: tester (ou_user)", + "Requested scopes: im:message:send", + "Newly granted scopes: im:message:send", + "Not granted scopes: (none)", + "Run `lark-cli auth status` to inspect all scopes currently granted to the account.", + } { + if !strings.Contains(got, want) { + t.Fatalf("stderr missing %q, got:\n%s", want, got) + } + } +} + +func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + + if err := saveLoginRequestedScope("device-code", "im:message:send"); err != nil { + t.Fatalf("saveLoginRequestedScope() error = %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 { + return &larkauth.DeviceFlowResult{OK: true, Token: nil} + } + + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + AppSecret: "secret", + Brand: core.BrandFeishu, + }) + + err := authLoginRun(&LoginOptions{ + Factory: f, + Ctx: context.Background(), + DeviceCode: "device-code", + }) + if err == nil { + t.Fatal("expected error for nil token") + } + if !strings.Contains(err.Error(), "authorization succeeded but no token returned") { + t.Fatalf("error = %v, want nil token error", err) + } + if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" { + t.Fatalf("loadLoginRequestedScope() after nil token = (%q, %v), want empty", got, err) + } +} + +func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + AppSecret: "secret", + Brand: core.BrandFeishu, + }) + f.IOStreams.Out = failWriter{} + + 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": 5, + }, + }) + + err := authLoginRun(&LoginOptions{ + Factory: f, + Ctx: context.Background(), + Scope: "im:message:send", + NoWait: true, + JSON: true, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "failed to write JSON output") { + t.Fatalf("error = %v, want JSON write failure", err) + } +} + +func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + AppSecret: "secret", + Brand: core.BrandFeishu, + }) + f.IOStreams.Out = failWriter{} + + 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": 5, + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := authLoginRun(&LoginOptions{ + Factory: f, + Ctx: ctx, + Scope: "im:message:send", + JSON: true, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "failed to write JSON output") { + t.Fatalf("error = %v, want JSON write failure", err) + } +} + func TestGetDomainMetadata_ExcludesEvent(t *testing.T) { domains := getDomainMetadata("zh") for _, dm := range domains {