mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:28:21 +08:00
* fix(identitydiag): harden verify path and tighten status semantics Follow-ups to #957: - bound bot/user verify calls with a 10s timeout (mirrors the doctor endpoint probe) so a hanging server cannot wedge `auth status --verify` or `doctor` - return StatusNotConfigured (not StatusMissing) when the user-identity path is blocked by missing app config, matching the bot side - surface the `{code, msg}` envelope on bot-info HTTP 4xx responses so callers see why bot auth was rejected, not just the bare HTTP code - introduce identity{User,Bot,None} constants in cmd/auth/status.go and use the exported StatusMessage() in the human-readable note instead of raw status codes like "not_configured" - collapse the duplicated verify-failed identity construction in the user path into a local helper - cover the new failure paths with unit tests (HTTP 4xx with envelope, business error code, user server-rejected, expired user token, strict-mode user-only, missing app config for user) Change-Id: I581348a65f15b1452a6f48a3e3245d09257314ac * fix(identitydiag): decode bot/v3/info from "bot" field, not "data" `/open-apis/bot/v3/info` returns `{code, msg, bot: {...}}` — the bot payload is under `bot`, not `data` as the newer Lark API convention would suggest. The decoder was reading from a non-existent `data` field, so `envelope.Data.OpenID` was always empty and every successful verify was reported as `Bot identity: verify failed: open_id is empty`. The pre-existing test mocks used `{"data": {...}}` matching the buggy decoder, so unit tests passed while production reads of every Lark account failed verification. Fix: - change the JSON tag on the envelope from `json:"data"` to `json:"bot"` - update mocks in identitydiag and cmd/auth/status tests to emit `bot` Verified locally: `lark-cli doctor` now reports `bot_identity: pass` for both a normal account and a bot-only profile, restoring the behavior that #957 set out to deliver. Change-Id: Ib26dfdd5a0cc37d2d62537ae2bf5e854e67cb83c * fix(shortcuts/common): decode bot/v3/info from "bot" field, not "data" Same schema bug as the one fixed in identitydiag — `RuntimeContext. fetchBotInfo` reads from a non-existent "data" key, so every successful call would report "open_id is empty" once a caller starts depending on it. There are no production callers of `RuntimeContext.BotInfo()` yet (only tests + the `TestNewRuntimeContextWithBotInfo` helper), so this bug is dormant — but the pre-existing tests pass with the same wrong schema in their mocks, so the first real consumer would silently break. Fix: tag `json:"data"` → `json:"bot"` plus aligning the four mock fixtures in runner_botinfo_test.go. The Go field name `Data` is kept to minimize the diff; only the JSON contract is corrected. Change-Id: I11e1e871603e5349f8df29b1d58e35d07b628dfd
351 lines
11 KiB
Go
351 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package identitydiag
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"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",
|
|
"bot": 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",
|
|
"bot": 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_VerifyBotIdentity_HTTPErrorSurfacesEnvelope(t *testing.T) {
|
|
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
|
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/bot/v3/info",
|
|
Status: http.StatusUnauthorized,
|
|
Body: map[string]interface{}{
|
|
"code": 99991663,
|
|
"msg": "app ticket invalid",
|
|
},
|
|
})
|
|
|
|
got := Diagnose(context.Background(), f, cfg, true)
|
|
if got.Bot.Status != StatusVerifyFailed || got.Bot.Available {
|
|
t.Fatalf("bot = %#v, want verify_failed and unavailable", got.Bot)
|
|
}
|
|
if got.Bot.Verified == nil || *got.Bot.Verified {
|
|
t.Fatalf("bot verified = %v, want false", got.Bot.Verified)
|
|
}
|
|
if !strings.Contains(got.Bot.Message, "401") || !strings.Contains(got.Bot.Message, "99991663") {
|
|
t.Fatalf("bot message = %q, want both HTTP code and envelope code", got.Bot.Message)
|
|
}
|
|
}
|
|
|
|
func TestDiagnose_VerifyBotIdentity_BusinessErrorCode(t *testing.T) {
|
|
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
|
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/bot/v3/info",
|
|
Body: map[string]interface{}{
|
|
"code": 10013,
|
|
"msg": "scope not granted",
|
|
},
|
|
})
|
|
|
|
got := Diagnose(context.Background(), f, cfg, true)
|
|
if got.Bot.Status != StatusVerifyFailed || got.Bot.Available {
|
|
t.Fatalf("bot = %#v, want verify_failed and unavailable", got.Bot)
|
|
}
|
|
if !strings.Contains(got.Bot.Message, "10013") || !strings.Contains(got.Bot.Message, "scope not granted") {
|
|
t.Fatalf("bot message = %q, want envelope code/msg", got.Bot.Message)
|
|
}
|
|
}
|
|
|
|
func TestDiagnose_VerifyUserIdentity_ServerRejects(t *testing.T) {
|
|
keyring.MockInit()
|
|
t.Setenv("HOME", t.TempDir())
|
|
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
|
|
|
cfg := &core.CliConfig{
|
|
AppID: "test-app-reject",
|
|
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,
|
|
"bot": map[string]interface{}{"open_id": "ou_bot", "app_name": "bot"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: larkauth.PathUserInfoV1,
|
|
Body: map[string]interface{}{
|
|
"code": 99991661,
|
|
"msg": "access token invalid",
|
|
},
|
|
})
|
|
|
|
got := Diagnose(context.Background(), f, cfg, true)
|
|
if got.User.Status != StatusVerifyFailed || got.User.Available {
|
|
t.Fatalf("user = %#v, want verify_failed and unavailable", got.User)
|
|
}
|
|
if got.User.Verified == nil || *got.User.Verified {
|
|
t.Fatalf("user verified = %v, want false", got.User.Verified)
|
|
}
|
|
if !strings.Contains(got.User.Message, "server rejected token") {
|
|
t.Fatalf("user message = %q, want 'server rejected token'", got.User.Message)
|
|
}
|
|
}
|
|
|
|
func TestDiagnose_UserIdentityExpired(t *testing.T) {
|
|
keyring.MockInit()
|
|
t.Setenv("HOME", t.TempDir())
|
|
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
|
|
|
cfg := &core.CliConfig{
|
|
AppID: "test-app-expired",
|
|
AppSecret: "secret",
|
|
Brand: core.BrandFeishu,
|
|
UserOpenId: "ou_expired",
|
|
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(-time.Minute).UnixMilli(),
|
|
GrantedAt: now.Add(-24 * 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 != StatusMissing || got.User.Available {
|
|
t.Fatalf("user = %#v, want missing and unavailable", got.User)
|
|
}
|
|
if got.User.Hint == "" {
|
|
t.Fatalf("user hint is empty, want re-login hint")
|
|
}
|
|
}
|
|
|
|
func TestDiagnose_BotIdentityStrictUserOnly(t *testing.T) {
|
|
// SupportedIdentities = SupportsUser (1) only — bot path should be
|
|
// reported as not_configured even though an app secret is present.
|
|
cfg := &core.CliConfig{
|
|
AppID: "test-app",
|
|
AppSecret: "secret",
|
|
Brand: core.BrandFeishu,
|
|
SupportedIdentities: 1,
|
|
}
|
|
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_UserIdentityMissingAppConfig(t *testing.T) {
|
|
cfg := &core.CliConfig{Brand: core.BrandFeishu}
|
|
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
|
|
|
got := Diagnose(context.Background(), f, cfg, false)
|
|
if got.User.Status != StatusNotConfigured || got.User.Available {
|
|
t.Fatalf("user = %#v, want not_configured and unavailable", got.User)
|
|
}
|
|
}
|
|
|
|
func TestStatusMessage(t *testing.T) {
|
|
cases := map[string]string{
|
|
StatusReady: StatusReady,
|
|
StatusNotConfigured: "not configured",
|
|
StatusVerifyFailed: "verify failed",
|
|
StatusNeedsRefresh: "needs refresh",
|
|
StatusMissing: "missing",
|
|
"unknown": "unknown",
|
|
}
|
|
for in, want := range cases {
|
|
if got := StatusMessage(in); got != want {
|
|
t.Errorf("StatusMessage(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|