From b8469d2dc6ec89a7df9ae4793560dd45881d1d85 Mon Sep 17 00:00:00 2001 From: RZERO Date: Tue, 19 May 2026 13:46:57 +0800 Subject: [PATCH] fix(auth): split bot and user identity diagnostics (#957) --- cmd/auth/status.go | 134 ++++----- cmd/auth/status_test.go | 96 +++++++ cmd/doctor/doctor.go | 75 ++--- cmd/doctor/doctor_test.go | 56 ++++ internal/identitydiag/diagnostics.go | 316 ++++++++++++++++++++++ internal/identitydiag/diagnostics_test.go | 168 ++++++++++++ 6 files changed, 723 insertions(+), 122 deletions(-) create mode 100644 cmd/auth/status_test.go create mode 100644 internal/identitydiag/diagnostics.go create mode 100644 internal/identitydiag/diagnostics_test.go diff --git a/cmd/auth/status.go b/cmd/auth/status.go index 60118b35..914fd17a 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -5,13 +5,11 @@ package auth import ( "context" - "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/identitydiag" "github.com/larksuite/cli/internal/output" ) @@ -60,73 +58,77 @@ func authStatusRun(opts *StatusOptions) error { "defaultAs": defaultAs, } - if config.UserOpenId == "" { - result["identity"] = "bot" - result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in." - output.PrintJson(f.IOStreams.Out, result) - return nil - } - - stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId) - if stored == nil { - result["identity"] = "bot" - result["userName"] = config.UserName - result["userOpenId"] = config.UserOpenId - result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login" - output.PrintJson(f.IOStreams.Out, result) - return nil - } - - status := larkauth.TokenStatus(stored) - if status == "expired" { - result["identity"] = "bot" - result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login" - } else { - result["identity"] = "user" - } - result["userName"] = config.UserName - result["userOpenId"] = config.UserOpenId - result["tokenStatus"] = status - result["scope"] = stored.Scope - result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339) - result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339) - result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339) - - // --verify: call the server to confirm token is actually usable. - if opts.Verify && status != "expired" { - verified, verifyErr := verifyTokenOnServer(f, config) - result["verified"] = verified - if verifyErr != "" { - result["verifyError"] = verifyErr - } - } + diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify) + result["identities"] = diagnostics + result["identity"] = effectiveIdentity(diagnostics) + addLegacyUserFields(result, diagnostics.User) + addEffectiveVerification(result, diagnostics) + addStatusNote(result, diagnostics) output.PrintJson(f.IOStreams.Out, result) return nil } -// verifyTokenOnServer obtains a valid access token (refreshing if needed) -// and calls /authen/v1/user_info to confirm the server accepts it. -// Returns (true, "") on success or (false, reason) on failure. -func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) { - httpClient, err := f.HttpClient() - if err != nil { - return false, "failed to create HTTP client: " + err.Error() +func effectiveIdentity(d identitydiag.Result) string { + switch { + case d.User.Available: + return "user" + case d.Bot.Available: + return "bot" + default: + return "none" + } +} + +func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) { + if user.OpenID == "" { + return + } + result["userName"] = user.UserName + result["userOpenId"] = user.OpenID + if user.TokenStatus != "" { + result["tokenStatus"] = user.TokenStatus + } + if user.Scope != "" { + result["scope"] = user.Scope + } + if user.ExpiresAt != "" { + result["expiresAt"] = user.ExpiresAt + } + if user.RefreshExpiresAt != "" { + result["refreshExpiresAt"] = user.RefreshExpiresAt + } + if user.GrantedAt != "" { + result["grantedAt"] = user.GrantedAt + } +} + +func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) { + switch result["identity"] { + case "user": + if d.User.Verified != nil { + result["verified"] = *d.User.Verified + if !*d.User.Verified { + result["verifyError"] = d.User.Message + } + } + case "bot": + if d.Bot.Verified != nil { + result["verified"] = *d.Bot.Verified + if !*d.Bot.Verified { + result["verifyError"] = d.Bot.Message + } + } + } +} + +func addStatusNote(result map[string]interface{}, d identitydiag.Result) { + switch { + case !d.User.Available && d.Bot.Available: + result["note"] = "User identity is " + d.User.Status + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity." + case d.User.Status == identitydiag.StatusNeedsRefresh: + result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call." + case !d.User.Available && !d.Bot.Available: + result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`." } - - token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut)) - if err != nil { - return false, "token unusable: " + err.Error() - } - - sdk, err := f.LarkClient() - if err != nil { - return false, "failed to create SDK client: " + err.Error() - } - - if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil { - return false, "server rejected token: " + err.Error() - } - - return true, "" } diff --git a/cmd/auth/status_test.go b/cmd/auth/status_test.go new file mode 100644 index 00000000..28ea264c --- /dev/null +++ b/cmd/auth/status_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu, + }) + + if err := authStatusRun(&StatusOptions{Factory: f}); err != nil { + t.Fatalf("authStatusRun() error = %v", err) + } + + var got statusOutput + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if got.Identity != "bot" { + t.Fatalf("identity = %q, want bot", got.Identity) + } + if got.Identities.Bot.Status != "ready" || !got.Identities.Bot.Available { + t.Fatalf("bot = %#v, want ready and available", got.Identities.Bot) + } + if got.Identities.User.Status != "missing" || got.Identities.User.Available { + t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User) + } +} + +func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu, + }) + reg.Register(&httpmock.Stub{ + Method: http.MethodGet, + URL: "/open-apis/bot/v3/info", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "open_id": "ou_bot", + "app_name": "diagnostic bot", + }, + }, + }) + + if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil { + t.Fatalf("authStatusRun() error = %v", err) + } + + var got statusOutput + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if got.Identity != "bot" { + t.Fatalf("identity = %q, want bot", got.Identity) + } + if got.Verified == nil || !*got.Verified { + t.Fatalf("verified = %v, want true", got.Verified) + } + if got.Identities.Bot.Verified == nil || !*got.Identities.Bot.Verified { + t.Fatalf("bot verified = %v, want true", got.Identities.Bot.Verified) + } + if got.Identities.Bot.OpenID != "ou_bot" { + t.Fatalf("bot open id = %q, want ou_bot", got.Identities.Bot.OpenID) + } + if got.Identities.User.Status != "missing" { + t.Fatalf("user status = %q, want missing", got.Identities.User.Status) + } +} + +type statusOutput struct { + Identity string `json:"identity"` + Verified *bool `json:"verified"` + Identities struct { + Bot statusIdentity `json:"bot"` + User statusIdentity `json:"user"` + } `json:"identities"` +} + +type statusIdentity struct { + Status string `json:"status"` + Available bool `json:"available"` + Verified *bool `json:"verified"` + OpenID string `json:"openId"` +} diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 1c6cd21a..9314ebfc 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -14,10 +14,10 @@ import ( "github.com/spf13/cobra" - larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identitydiag" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/update" ) @@ -51,7 +51,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command { // checkResult represents one diagnostic check. type checkResult struct { Name string `json:"name"` - Status string `json:"status"` // "pass", "fail", "skip" + Status string `json:"status"` // "pass", "warn", "fail", "skip" Message string `json:"message"` Hint string `json:"hint,omitempty"` } @@ -118,59 +118,31 @@ func doctorRun(opts *DoctorOptions) error { ep := core.ResolveEndpoints(cfg.Brand) - // ── 3. Token exists ── - if cfg.UserOpenId == "" { - checks = append(checks, fail("token_exists", "no user logged in", "run: lark-cli auth login --help")) - checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) - return finishDoctor(f, checks) - } - stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId) - if stored == nil { - checks = append(checks, fail("token_exists", "no token in keychain for "+cfg.UserOpenId, "run: lark-cli auth login --help")) - checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) - return finishDoctor(f, checks) - } - checks = append(checks, pass("token_exists", fmt.Sprintf("token found for %s (%s)", cfg.UserName, cfg.UserOpenId))) - - // ── 4. Token local validity ── - status := larkauth.TokenStatus(stored) - switch status { - case "valid": - checks = append(checks, pass("token_local", "token valid, expires "+time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339))) - case "needs_refresh": - checks = append(checks, pass("token_local", "token needs refresh (will auto-refresh on next call)")) - default: // expired - checks = append(checks, fail("token_local", "token expired", "run: lark-cli auth login --help")) - checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) - return finishDoctor(f, checks) - } - - // ── 5. Token server verification ── - if opts.Offline { - checks = append(checks, skip("token_verified", "skipped (--offline)")) + // ── 3. Identity readiness ── + diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline) + checks = append(checks, + identityCheck("bot_identity", diagnostics.Bot), + identityCheck("user_identity", diagnostics.User), + ) + if diagnostics.Bot.Available || diagnostics.User.Available { + checks = append(checks, pass("identity_ready", "at least one identity is available")) } else { - httpClient := mustHTTPClient(f) - token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut)) - if err != nil { - checks = append(checks, fail("token_verified", "cannot obtain valid token: "+err.Error(), "run: lark-cli auth login --help")) - } else { - sdk, err := f.LarkClient() - if err != nil { - checks = append(checks, fail("token_verified", "SDK init failed: "+err.Error(), "")) - } else if err := larkauth.VerifyUserToken(opts.Ctx, sdk, token); err != nil { - checks = append(checks, fail("token_verified", "server rejected token: "+err.Error(), "run: lark-cli auth login --help")) - } else { - checks = append(checks, pass("token_verified", "server confirmed token is valid")) - } - } + checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify")) } - // ── 6 & 7. Endpoint reachability ── + // ── 4 & 5. Endpoint reachability ── checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) return finishDoctor(f, checks) } +func identityCheck(name string, id identitydiag.Identity) checkResult { + if id.Available { + return pass(name, id.Message) + } + return warn(name, id.Message, id.Hint) +} + // networkChecks probes Open API and MCP endpoints concurrently. func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult { if opts.Offline { @@ -232,15 +204,6 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error { return nil } -// mustHTTPClient returns f.HttpClient() or a default client. -func mustHTTPClient(f *cmdutil.Factory) *http.Client { - c, err := f.HttpClient() - if err != nil { - return &http.Client{Timeout: 30 * time.Second} - } - return c -} - // checkCLIUpdate actively queries the npm registry for the latest version. // Unlike the root-level async check, this does a synchronous fetch with timeout // and works regardless of build version (dev builds included). diff --git a/cmd/doctor/doctor_test.go b/cmd/doctor/doctor_test.go index 5ffd7709..0f4fe8f7 100644 --- a/cmd/doctor/doctor_test.go +++ b/cmd/doctor/doctor_test.go @@ -95,3 +95,59 @@ func TestNetworkChecks_Offline(t *testing.T) { } } } + +func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + { + Name: "default", + AppId: "test-app", + AppSecret: core.PlainSecret("secret"), + Brand: core.BrandFeishu, + }, + }, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu, + }) + err := doctorRun(&DoctorOptions{ + Factory: f, + Ctx: context.Background(), + Offline: true, + }) + if err != nil { + t.Fatalf("doctorRun() error = %v", err) + } + + var got struct { + OK bool `json:"ok"` + Checks []checkResult `json:"checks"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if !got.OK { + t.Fatalf("ok = false, want true; checks = %#v", got.Checks) + } + assertCheck(t, got.Checks, "bot_identity", "pass") + assertCheck(t, got.Checks, "user_identity", "warn") + assertCheck(t, got.Checks, "identity_ready", "pass") +} + +func assertCheck(t *testing.T, checks []checkResult, name, status string) { + t.Helper() + for _, check := range checks { + if check.Name == name { + if check.Status != status { + t.Fatalf("%s status = %q, want %q", name, check.Status, status) + } + return + } + } + t.Fatalf("check %q not found in %#v", name, checks) +} diff --git a/internal/identitydiag/diagnostics.go b/internal/identitydiag/diagnostics.go new file mode 100644 index 00000000..c6f68b17 --- /dev/null +++ b/internal/identitydiag/diagnostics.go @@ -0,0 +1,316 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package identitydiag + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" +) + +const ( + StatusReady = "ready" + StatusNotConfigured = "not_configured" + StatusMissing = "missing" + StatusNeedsRefresh = "needs_refresh" + StatusVerifyFailed = "verify_failed" +) + +// Result describes the independently usable bot and user identities. +type Result struct { + Bot Identity `json:"bot"` + User Identity `json:"user"` +} + +// Identity is a single identity diagnostic result. +type Identity struct { + Status string `json:"status"` + Available bool `json:"available"` + Verified *bool `json:"verified,omitempty"` + Message string `json:"message,omitempty"` + Hint string `json:"hint,omitempty"` + OpenID string `json:"openId,omitempty"` + AppName string `json:"appName,omitempty"` + UserName string `json:"userName,omitempty"` + TokenStatus string `json:"tokenStatus,omitempty"` + Scope string `json:"scope,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + RefreshExpiresAt string `json:"refreshExpiresAt,omitempty"` + GrantedAt string `json:"grantedAt,omitempty"` +} + +// Diagnose checks bot and user identities separately. When verify is false, +// it only reports local readiness and skips server calls. +func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Result { + if ctx == nil { + ctx = context.Background() + } + return Result{ + Bot: diagnoseBot(ctx, f, cfg, verify), + User: diagnoseUser(ctx, f, cfg, verify), + } +} + +func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity { + if cfg == nil || cfg.AppID == "" { + return Identity{ + Status: StatusNotConfigured, + Message: "Bot identity: not configured (missing app config)", + Hint: "run: lark-cli config --help", + } + } + if !cfg.CanBot() { + return Identity{ + Status: StatusNotConfigured, + Message: "Bot identity: not configured (bot identity is not available in current credential context)", + Hint: "check strict mode or the active credential provider", + } + } + if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) { + return Identity{ + Status: StatusNotConfigured, + Message: "Bot identity: not configured (missing app secret or bot token)", + Hint: "run: lark-cli config --help", + } + } + + id := Identity{ + Status: StatusReady, + Available: true, + Message: "Bot identity: ready", + } + if !verify { + return id + } + + token, err := resolveBotToken(ctx, f, cfg) + if err != nil { + status := StatusVerifyFailed + var unavailable *credential.TokenUnavailableError + if errors.As(err, &unavailable) { + status = StatusNotConfigured + } + return Identity{ + Status: status, + Verified: boolPtr(false), + Message: "Bot identity: " + statusMessage(status) + ": " + err.Error(), + Hint: "check app credentials or the active credential provider", + } + } + + info, err := fetchBotInfo(ctx, f, cfg, token) + if err != nil { + return Identity{ + Status: StatusVerifyFailed, + Verified: boolPtr(false), + Message: "Bot identity: verify failed: " + err.Error(), + Hint: "check app credentials, scopes, network, or tenant access token configuration", + } + } + + id.Verified = boolPtr(true) + id.OpenID = info.OpenID + id.AppName = info.AppName + return id +} + +func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity { + if cfg == nil || cfg.AppID == "" { + return Identity{ + Status: StatusMissing, + Message: "User identity: missing (missing app config)", + Hint: "run: lark-cli config --help", + } + } + if cfg.UserOpenId == "" { + return Identity{ + Status: StatusMissing, + Message: "User identity: missing (no user logged in)", + Hint: "run: lark-cli auth login --help", + } + } + + id := Identity{ + UserName: cfg.UserName, + OpenID: cfg.UserOpenId, + } + stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId) + if stored == nil { + id.Status = StatusMissing + id.Message = "User identity: missing (no token in keychain for " + cfg.UserOpenId + ")" + id.Hint = "run: lark-cli auth login --help" + return id + } + + fillTokenFields(&id, stored) + switch larkauth.TokenStatus(stored) { + case "valid": + id.Status = StatusReady + id.Available = true + id.Message = "User identity: ready" + case "needs_refresh": + id.Status = StatusNeedsRefresh + id.Available = true + id.Message = "User identity: needs refresh (will auto-refresh on next user API call)" + default: + id.Status = StatusMissing + id.Message = "User identity: missing (refresh token expired)" + id.Hint = "run: lark-cli auth login --help" + return id + } + + if !verify { + return id + } + + httpClient, err := f.HttpClient() + if err != nil { + id.Status = StatusVerifyFailed + id.Available = false + id.Verified = boolPtr(false) + id.Message = "User identity: verify failed: create HTTP client: " + err.Error() + return id + } + token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut)) + if err != nil { + id.Status = StatusVerifyFailed + id.Available = false + id.Verified = boolPtr(false) + id.Message = "User identity: verify failed: token unusable: " + err.Error() + id.Hint = "run: lark-cli auth login --help" + return id + } + sdk, err := f.LarkClient() + if err != nil { + id.Status = StatusVerifyFailed + id.Available = false + id.Verified = boolPtr(false) + id.Message = "User identity: verify failed: SDK init failed: " + err.Error() + return id + } + if err := larkauth.VerifyUserToken(ctx, sdk, token); err != nil { + id.Status = StatusVerifyFailed + id.Available = false + id.Verified = boolPtr(false) + id.Message = "User identity: verify failed: server rejected token: " + err.Error() + id.Hint = "run: lark-cli auth login --help" + return id + } + + id.Verified = boolPtr(true) + if id.Status == StatusReady { + id.Message = "User identity: ready" + } else { + id.Message = "User identity: needs refresh (server verification succeeded after refresh)" + } + return id +} + +func resolveBotToken(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig) (string, error) { + if f == nil || f.Credential == nil { + return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT} + } + result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, cfg.AppID)) + if err != nil { + return "", err + } + if result == nil || result.Token == "" { + return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT} + } + return result.Token, nil +} + +type botInfo struct { + OpenID string + AppName string +} + +func fetchBotInfo(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, token string) (*botInfo, error) { + httpClient, err := f.HttpClient() + if err != nil { + return nil, fmt.Errorf("create HTTP client: %w", err) + } + url := strings.TrimRight(core.ResolveEndpoints(cfg.Brand).Open, "/") + "/open-apis/bot/v3/info" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + OpenID string `json:"open_id"` + AppName string `json:"app_name"` + } `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + if envelope.Code != 0 { + return nil, fmt.Errorf("[%d] %s", envelope.Code, envelope.Msg) + } + if envelope.Data.OpenID == "" { + return nil, errors.New("open_id is empty") + } + return &botInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil +} + +func fillTokenFields(id *Identity, token *larkauth.StoredUAToken) { + id.TokenStatus = larkauth.TokenStatus(token) + id.Scope = token.Scope + id.ExpiresAt = formatMillis(token.ExpiresAt) + id.RefreshExpiresAt = formatMillis(token.RefreshExpiresAt) + id.GrantedAt = formatMillis(token.GrantedAt) +} + +func formatMillis(ms int64) string { + if ms <= 0 { + return "" + } + return time.UnixMilli(ms).Format(time.RFC3339) +} + +func statusMessage(status string) string { + switch status { + case StatusNotConfigured: + return "not configured" + case StatusVerifyFailed: + return "verify failed" + case StatusNeedsRefresh: + return "needs refresh" + case StatusMissing: + return "missing" + default: + return status + } +} + +func boolPtr(v bool) *bool { + return &v +} diff --git a/internal/identitydiag/diagnostics_test.go b/internal/identitydiag/diagnostics_test.go new file mode 100644 index 00000000..3d42da85 --- /dev/null +++ b/internal/identitydiag/diagnostics_test.go @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package identitydiag + +import ( + "context" + "net/http" + "testing" + "time" + + 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/zalando/go-keyring" +) + +func TestDiagnose_NoUserReportsBotReadyAndUserMissing(t *testing.T) { + cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu} + f, _, _, _ := cmdutil.TestFactory(t, cfg) + + got := Diagnose(context.Background(), f, cfg, false) + if got.Bot.Status != StatusReady || !got.Bot.Available { + t.Fatalf("bot = %#v, want ready and available", got.Bot) + } + if got.User.Status != StatusMissing || got.User.Available { + t.Fatalf("user = %#v, want missing and unavailable", got.User) + } +} + +func TestDiagnose_BotIdentityNotConfigured(t *testing.T) { + cfg := &core.CliConfig{AppID: "test-app", Brand: core.BrandFeishu} + f, _, _, _ := cmdutil.TestFactory(t, cfg) + + got := Diagnose(context.Background(), f, cfg, false) + if got.Bot.Status != StatusNotConfigured || got.Bot.Available { + t.Fatalf("bot = %#v, want not_configured and unavailable", got.Bot) + } +} + +func TestDiagnose_VerifyBotIdentity(t *testing.T) { + cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu} + f, _, _, reg := cmdutil.TestFactory(t, cfg) + stub := &httpmock.Stub{ + Method: http.MethodGet, + URL: "/open-apis/bot/v3/info", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "open_id": "ou_bot", + "app_name": "diagnostic bot", + }, + }, + } + reg.Register(stub) + + got := Diagnose(context.Background(), f, cfg, true) + if got.Bot.Status != StatusReady || !got.Bot.Available { + t.Fatalf("bot = %#v, want ready and available", got.Bot) + } + if got.Bot.Verified == nil || !*got.Bot.Verified { + t.Fatalf("bot verified = %v, want true", got.Bot.Verified) + } + if got.Bot.OpenID != "ou_bot" || got.Bot.AppName != "diagnostic bot" { + t.Fatalf("bot info = %#v, want open id and app name", got.Bot) + } + if got := stub.CapturedHeaders.Get("Authorization"); got != "Bearer test-token" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token") + } +} + +func TestDiagnose_VerifyUserIdentity(t *testing.T) { + keyring.MockInit() + t.Setenv("HOME", t.TempDir()) + t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) + + cfg := &core.CliConfig{ + AppID: "test-app-user", + AppSecret: "secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_user", + UserName: "tester", + } + now := time.Now() + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: cfg.AppID, + UserOpenId: cfg.UserOpenId, + AccessToken: "user-access-token", + RefreshToken: "refresh-token", + ExpiresAt: now.Add(time.Hour).UnixMilli(), + RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(), + GrantedAt: now.Add(-time.Hour).UnixMilli(), + Scope: "offline_access", + }); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + + f, _, _, reg := cmdutil.TestFactory(t, cfg) + reg.Register(&httpmock.Stub{ + Method: http.MethodGet, + URL: "/open-apis/bot/v3/info", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "open_id": "ou_bot", + "app_name": "diagnostic bot", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: http.MethodGet, + URL: larkauth.PathUserInfoV1, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + + got := Diagnose(context.Background(), f, cfg, true) + if got.User.Status != StatusReady || !got.User.Available { + t.Fatalf("user = %#v, want ready and available", got.User) + } + if got.User.Verified == nil || !*got.User.Verified { + t.Fatalf("user verified = %v, want true", got.User.Verified) + } + if got.User.OpenID != "ou_user" || got.User.UserName != "tester" { + t.Fatalf("user = %#v, want user identity details", got.User) + } +} + +func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) { + keyring.MockInit() + t.Setenv("HOME", t.TempDir()) + t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) + + cfg := &core.CliConfig{ + AppID: "test-app-needs-refresh", + AppSecret: "secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_refresh", + UserName: "tester", + } + now := time.Now() + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: cfg.AppID, + UserOpenId: cfg.UserOpenId, + AccessToken: "user-access-token", + RefreshToken: "refresh-token", + ExpiresAt: now.Add(time.Minute).UnixMilli(), + RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(), + GrantedAt: now.Add(-time.Hour).UnixMilli(), + Scope: "offline_access", + }); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, cfg) + got := Diagnose(context.Background(), f, cfg, false) + if got.User.Status != StatusNeedsRefresh || !got.User.Available { + t.Fatalf("user = %#v, want needs_refresh and available", got.User) + } + if got.User.TokenStatus != "needs_refresh" { + t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus) + } +}