fix(auth): split bot and user identity diagnostics (#957)

This commit is contained in:
RZERO
2026-05-19 13:46:57 +08:00
committed by GitHub
parent afa084e7a4
commit b8469d2dc6
6 changed files with 723 additions and 122 deletions

View File

@@ -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, ""
}

96
cmd/auth/status_test.go Normal file
View File

@@ -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"`
}

View File

@@ -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).

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}
}