Files
larksuite-cli/cmd/config/binder_test.go
evandance 99e314fe0b feat(errs): typed envelope contract for auth-domain errors (#1135)
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.
2026-05-30 19:08:41 +08:00

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