mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:
- a fixed nine-category taxonomy on the wire, each mapped to a
stable shell exit code (authentication/authorization/config = 3,
network = 4, internal = 5, policy = 6, confirmation = 10)
- identity-aware detail fields (missing_scopes, requested_scopes,
granted_scopes, console_url, log_id, retryable, hint) carried
uniformly on the envelope
- a single canonical policy envelope at exit 6; the legacy
auth_error carve-out is retired
- per-subtype canonical message + hint that preserves Lark's
diagnostic phrasing and routes recovery to the right actor:
app developer (app_scope_not_applied), user (missing_scope,
token_scope_insufficient, user_unauthorized), or tenant admin
(app_unavailable, app_disabled)
- wrong app credentials classify as config/invalid_client whether
surfaced by the Open API endpoint (99991543) or the tenant
access-token mint endpoint (10003 / 10014), instead of
collapsing to a transport error or api/unknown
- local shortcut scope preflight emits the same
authorization/missing_scope envelope (identity + deterministic
missing-scope set) used by the post-call permission path, so AI
consumers read the same structured shape from precheck and from
server-returned permission denial
- streaming download/upload failures keep the same network subtype
split (timeout / TLS / DNS / transport) as the non-stream path
instead of collapsing every cause to a generic transport failure
- console_url is carried only on the bot-perspective
app_scope_not_applied envelope (where the recovery action is
"developer applies the scope at the developer console"); the
user-perspective missing_scope envelope drops the field, since
the only actionable user recovery is `lark-cli auth login --scope`
and pointing an end user at a console they cannot modify is
misleading
- bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
Type tags to wire 'config' with the original module name kept
as a metric label
All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
201 lines
7.3 KiB
Go
201 lines
7.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package config
|
|
|
|
import (
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// fakeBinder is a test double for SourceBinder. selectCandidate only touches
|
|
// Name and ConfigPath (for error messages); ListCandidates/Build are not called
|
|
// from selectCandidate, so we can leave them as no-ops.
|
|
type fakeBinder struct {
|
|
name string
|
|
path string
|
|
}
|
|
|
|
func (b *fakeBinder) Name() string { return b.name }
|
|
func (b *fakeBinder) ConfigPath() string { return b.path }
|
|
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
|
|
func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil }
|
|
|
|
// tuiUnreachable is a tuiPrompt that fails the test if called. It's the
|
|
// guardrail that proves the non-TUI decision paths really do stay out of the
|
|
// interactive prompt — otherwise a green test could still hide a silent TUI.
|
|
func tuiUnreachable(t *testing.T) func([]Candidate) (*Candidate, error) {
|
|
t.Helper()
|
|
return func([]Candidate) (*Candidate, error) {
|
|
t.Fatal("tuiPrompt must not be called in flag mode")
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
// assertCandidate compares the full Candidate struct via DeepEqual so that
|
|
// any future field added to Candidate is covered automatically.
|
|
func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
|
|
t.Helper()
|
|
if got == nil {
|
|
t.Fatal("expected non-nil Candidate")
|
|
}
|
|
if !reflect.DeepEqual(*got, want) {
|
|
t.Errorf("candidate mismatch:\n got: %+v\n want: %+v", *got, want)
|
|
}
|
|
}
|
|
|
|
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
|
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
|
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
|
Type: "config",
|
|
Message: "no Feishu app configured in openclaw.json",
|
|
Hint: "configure channels.feishu.appId in openclaw.json",
|
|
})
|
|
}
|
|
|
|
func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
|
|
// Locks in the generic fallback so that any future source added to
|
|
// newBinder gets a well-formed validation error on "zero candidates"
|
|
// even before it has a bespoke error message.
|
|
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
|
|
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
|
Type: "config",
|
|
Message: "hermes: no app configured",
|
|
})
|
|
}
|
|
|
|
func TestSelectCandidate_SingleCandidate_NoFlag_AutoSelect(t *testing.T) {
|
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
|
candidates := []Candidate{{AppID: "cli_only", Label: "default"}}
|
|
got, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
assertCandidate(t, got, Candidate{AppID: "cli_only", Label: "default"})
|
|
}
|
|
|
|
func TestSelectCandidate_AppIDFlag_ExactMatch(t *testing.T) {
|
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
|
candidates := []Candidate{
|
|
{AppID: "cli_work", Label: "work"},
|
|
{AppID: "cli_home", Label: "home"},
|
|
}
|
|
got, err := selectCandidate(b, candidates, "cli_home", false, tuiUnreachable(t))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"})
|
|
}
|
|
|
|
func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
|
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
|
candidates := []Candidate{
|
|
{AppID: "cli_work", Label: "work"},
|
|
{AppID: "cli_home", Label: "home"},
|
|
}
|
|
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "validation",
|
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
|
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
|
})
|
|
}
|
|
|
|
func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
|
|
// Flag-mode with multiple candidates and no --app-id must produce a
|
|
// validation error and the candidate list, never an interactive prompt.
|
|
// isTUI is the single gate; a real terminal alone must not trigger TUI.
|
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
|
candidates := []Candidate{
|
|
{AppID: "cli_work", Label: "work"},
|
|
{AppID: "cli_home", Label: "home"},
|
|
}
|
|
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "validation",
|
|
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
|
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
|
})
|
|
}
|
|
|
|
func TestSelectCandidate_MultiCandidate_NoFlag_TUI(t *testing.T) {
|
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
|
candidates := []Candidate{
|
|
{AppID: "cli_work", Label: "work"},
|
|
{AppID: "cli_home", Label: "home"},
|
|
}
|
|
var gotCandidates []Candidate
|
|
got, err := selectCandidate(b, candidates, "", true, func(cs []Candidate) (*Candidate, error) {
|
|
gotCandidates = cs
|
|
return &cs[1], nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Whole-slice DeepEqual so additions to Candidate propagate to this check.
|
|
if !reflect.DeepEqual(gotCandidates, candidates) {
|
|
t.Errorf("tuiPrompt received %+v, want %+v", gotCandidates, candidates)
|
|
}
|
|
assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"})
|
|
}
|
|
|
|
func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
|
|
// Even with only one candidate, a wrong --app-id must error rather than
|
|
// silently auto-selecting. An explicit mismatch is always a user mistake,
|
|
// not a reason to override their intent.
|
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
|
candidates := []Candidate{{AppID: "cli_only"}}
|
|
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
|
Type: "validation",
|
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
|
Hint: "available app IDs:\n cli_only",
|
|
})
|
|
}
|
|
|
|
func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
|
|
// An explicit --app-id short-circuits the prompt even in TUI mode: a
|
|
// flag the user typed should never be second-guessed by an interactive
|
|
// prompt asking the same question.
|
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
|
candidates := []Candidate{
|
|
{AppID: "cli_a"},
|
|
{AppID: "cli_b"},
|
|
}
|
|
got, err := selectCandidate(b, candidates, "cli_b", true, tuiUnreachable(t))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
assertCandidate(t, got, Candidate{AppID: "cli_b"})
|
|
}
|
|
|
|
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("LARK_CHANNEL_CONFIG", "")
|
|
|
|
got := resolveLarkChannelConfigPath()
|
|
want := filepath.Join(home, ".lark-channel", "config.json")
|
|
if got != want {
|
|
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestResolveLarkChannelConfigPath_EnvOverride(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("LARK_CHANNEL_CONFIG", "~/bridge/projection.json")
|
|
|
|
got := resolveLarkChannelConfigPath()
|
|
want := filepath.Join(home, "bridge", "projection.json")
|
|
if got != want {
|
|
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
|
|
}
|
|
}
|