mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +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
298 lines
7.7 KiB
Go
298 lines
7.7 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package common
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
)
|
|
|
|
// botInfoTestConfig returns a CliConfig suitable for bot info tests.
|
|
func botInfoTestConfig(t *testing.T) *core.CliConfig {
|
|
t.Helper()
|
|
return &core.CliConfig{
|
|
AppID: "test-app",
|
|
AppSecret: "test-secret",
|
|
Brand: core.BrandFeishu,
|
|
}
|
|
}
|
|
|
|
// runBotInfoShortcut mounts a shortcut that calls BotInfo() and executes it.
|
|
// The shortcut stores the result (or error) in the provided pointers.
|
|
func runBotInfoShortcut(t *testing.T, f *cmdutil.Factory, gotInfo **BotInfo, gotErr *error) {
|
|
t.Helper()
|
|
s := Shortcut{
|
|
Service: "test",
|
|
Command: "+bot-info",
|
|
AuthTypes: []string{"bot"},
|
|
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
|
info, err := rctx.BotInfo()
|
|
*gotInfo = info
|
|
*gotErr = err
|
|
return nil
|
|
},
|
|
}
|
|
parent := &cobra.Command{Use: "test"}
|
|
s.Mount(parent, f)
|
|
parent.SetArgs([]string{"+bot-info", "--as", "bot"})
|
|
parent.SilenceErrors = true
|
|
parent.SilenceUsage = true
|
|
if err := parent.Execute(); err != nil {
|
|
t.Fatalf("shortcut execution failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchBotInfo_Success(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/bot/v3/info",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"bot": map[string]interface{}{
|
|
"open_id": "ou_bot_abc123",
|
|
"app_name": "TestBot",
|
|
},
|
|
},
|
|
})
|
|
|
|
var info *BotInfo
|
|
var err error
|
|
runBotInfoShortcut(t, f, &info, &err)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if info.OpenID != "ou_bot_abc123" {
|
|
t.Errorf("OpenID = %q, want %q", info.OpenID, "ou_bot_abc123")
|
|
}
|
|
if info.AppName != "TestBot" {
|
|
t.Errorf("AppName = %q, want %q", info.AppName, "TestBot")
|
|
}
|
|
}
|
|
|
|
func TestFetchBotInfo_ShortcutHeaders(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
|
stub := &httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/bot/v3/info",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"bot": map[string]interface{}{
|
|
"open_id": "ou_bot_header",
|
|
"app_name": "HeaderBot",
|
|
},
|
|
},
|
|
}
|
|
reg.Register(stub)
|
|
|
|
var info *BotInfo
|
|
var err error
|
|
runBotInfoShortcut(t, f, &info, &err)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Verify shortcut context headers were injected
|
|
if stub.CapturedHeaders.Get("X-Cli-Shortcut") == "" {
|
|
t.Error("missing X-Cli-Shortcut header on /bot/v3/info request")
|
|
}
|
|
if stub.CapturedHeaders.Get("X-Cli-Execution-Id") == "" {
|
|
t.Error("missing X-Cli-Execution-Id header on /bot/v3/info request")
|
|
}
|
|
}
|
|
|
|
func TestFetchBotInfo_OnceSemantics(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
|
// Only register one stub — if fetchBotInfo is called twice, the second call
|
|
// would fail with "no stub" since the first stub is already matched.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/bot/v3/info",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"bot": map[string]interface{}{
|
|
"open_id": "ou_bot_once",
|
|
"app_name": "OnceBot",
|
|
},
|
|
},
|
|
})
|
|
|
|
s := Shortcut{
|
|
Service: "test",
|
|
Command: "+bot-info-once",
|
|
AuthTypes: []string{"bot"},
|
|
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
|
// Call BotInfo twice — second should use cached result
|
|
_, _ = rctx.BotInfo()
|
|
info, err := rctx.BotInfo()
|
|
if err != nil {
|
|
t.Errorf("second BotInfo() call failed: %v", err)
|
|
}
|
|
if info.OpenID != "ou_bot_once" {
|
|
t.Errorf("OpenID = %q, want %q", info.OpenID, "ou_bot_once")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
parent := &cobra.Command{Use: "test"}
|
|
s.Mount(parent, f)
|
|
parent.SetArgs([]string{"+bot-info-once", "--as", "bot"})
|
|
parent.SilenceErrors = true
|
|
parent.SilenceUsage = true
|
|
if err := parent.Execute(); err != nil {
|
|
t.Fatalf("shortcut execution failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/bot/v3/info",
|
|
Body: map[string]interface{}{
|
|
"code": 99991,
|
|
"msg": "no permission",
|
|
},
|
|
})
|
|
|
|
var info *BotInfo
|
|
var err error
|
|
runBotInfoShortcut(t, f, &info, &err)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for non-zero code")
|
|
}
|
|
if !strings.Contains(err.Error(), "[99991]") {
|
|
t.Errorf("error = %q, want substring [99991]", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/bot/v3/info",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"bot": map[string]interface{}{
|
|
"open_id": "",
|
|
"app_name": "EmptyBot",
|
|
},
|
|
},
|
|
})
|
|
|
|
var info *BotInfo
|
|
var err error
|
|
runBotInfoShortcut(t, f, &info, &err)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for empty open_id")
|
|
}
|
|
if !strings.Contains(err.Error(), "open_id is empty") {
|
|
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestFetchBotInfo_HTTP4xx(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/bot/v3/info",
|
|
Status: 403,
|
|
Body: map[string]interface{}{"code": 403, "msg": "forbidden"},
|
|
})
|
|
|
|
var info *BotInfo
|
|
var err error
|
|
runBotInfoShortcut(t, f, &info, &err)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for HTTP 403")
|
|
}
|
|
if !strings.Contains(err.Error(), "403") {
|
|
t.Errorf("error = %q, want substring '403'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestFetchBotInfo_InvalidJSON(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/bot/v3/info",
|
|
RawBody: []byte("not json"),
|
|
})
|
|
|
|
var info *BotInfo
|
|
var err error
|
|
runBotInfoShortcut(t, f, &info, &err)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
// Error may come from SDK-level parse or our unmarshal wrapper
|
|
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
|
|
t.Errorf("error = %q, want JSON parse failure", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestFetchBotInfo_CanBotFalse(t *testing.T) {
|
|
cfg := botInfoTestConfig(t)
|
|
cfg.SupportedIdentities = 1 // user only
|
|
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
|
|
|
// Use a dual-auth shortcut running as user, calling BotInfo() internally.
|
|
// No /bot/v3/info stub — CanBot should short-circuit before API call.
|
|
var info *BotInfo
|
|
var err error
|
|
s := Shortcut{
|
|
Service: "test",
|
|
Command: "+bot-info-canbot",
|
|
AuthTypes: []string{"user", "bot"},
|
|
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
|
i, e := rctx.BotInfo()
|
|
info = i
|
|
err = e
|
|
return nil
|
|
},
|
|
}
|
|
parent := &cobra.Command{Use: "test"}
|
|
s.Mount(parent, f)
|
|
parent.SetArgs([]string{"+bot-info-canbot", "--as", "user"})
|
|
parent.SilenceErrors = true
|
|
parent.SilenceUsage = true
|
|
if execErr := parent.Execute(); execErr != nil {
|
|
t.Fatalf("shortcut execution failed: %v", execErr)
|
|
}
|
|
|
|
if err == nil {
|
|
t.Fatal("expected error when bot identity not available")
|
|
}
|
|
if info != nil {
|
|
t.Errorf("expected nil info, got %+v", info)
|
|
}
|
|
if !strings.Contains(err.Error(), "not available") {
|
|
t.Errorf("error = %q, want substring 'not available'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestBotInfo_NilFunc(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
rctx := TestNewRuntimeContext(cmd, &core.CliConfig{})
|
|
_, err := rctx.BotInfo()
|
|
if err == nil {
|
|
t.Fatal("expected error for nil botInfoFunc")
|
|
}
|
|
if !strings.Contains(err.Error(), "not fully initialized") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|