Files
larksuite__cli/internal/cmdutil/factory_test.go
zhaoyukun.yk ef5da4b5ba fix: classify invalid CLI input as validation errors
Cobra surfaced several command-line validation failures as plain errors.
Classifying them by message text could report correctable input as
internal/unknown, misleading agents and returning the wrong exit code.

Classify errors at the boundary that produces them. Args and residual
Cobra validation become validation/invalid_argument, while raw execution
hooks and plugin failures become internal/unknown. Preserve typed errors,
causes, bare exits, and partial failures.

Make final-tree instrumentation stateless, type pre-callback framework
failures at their source, and keep rendering and Shutdown lifecycle
observations consistent. A Shutdown handler receives the error the command
returned with its wrapping intact, so errors.Is still reaches the
producer's sentinels, and it cannot change what the user was told because
that is decided before the event fires. The envelope is still written
after the event, keeping it the trailing content of stderr where readers
look for it even when a failing hook warns on the same stream.

Guard the error copier against a typed error being added without it, so
handlers cannot silently start sharing a producer's value again. Add
regression coverage for repeated execution, late help, lazy completion,
writer failures, shortcut diagnostics, credential-provider
classification, lifecycle isolation, and stderr write order.
2026-08-19 16:38:25 +08:00

592 lines
20 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"strings"
"testing"
"testing/fstest"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/skillref"
"github.com/larksuite/cli/internal/surface"
)
func TestFactoryResolveSkillReference(t *testing.T) {
content := fstest.MapFS{
"lark-doc/SKILL.md": {Data: []byte("canonical")},
"acme-doc/SKILL.md": {Data: []byte("remapped")},
}
from, err := skillref.Parse("lark-doc")
if err != nil {
t.Fatal(err)
}
to, err := skillref.Parse("acme-doc")
if err != nil {
t.Fatal(err)
}
resolver, err := skillref.New(content, []skillref.Mapping{{From: from, To: to}})
if err != nil {
t.Fatal(err)
}
t.Run("explicit remap", func(t *testing.T) {
f := &Factory{SkillContent: content, SkillReferences: resolver}
if got, ok := f.ResolveSkillReference("lark-doc"); !ok || got != "acme-doc" {
t.Fatalf("ResolveSkillReference() = %q, %v; want acme-doc, true", got, ok)
}
})
t.Run("identity fallback", func(t *testing.T) {
f := &Factory{SkillContent: content}
if got, ok := f.ResolveSkillReference("lark-doc"); !ok || got != "lark-doc" {
t.Fatalf("ResolveSkillReference() = %q, %v; want lark-doc, true", got, ok)
}
if got, ok := f.ResolveSkillReference("missing"); ok || got != "" {
t.Fatalf("missing ResolveSkillReference() = %q, %v; want empty, false", got, ok)
}
if got, ok := f.ResolveSkillReference("../invalid"); ok || got != "" {
t.Fatalf("invalid ResolveSkillReference() = %q, %v; want empty, false", got, ok)
}
})
t.Run("concealed skills read", func(t *testing.T) {
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandSkillsRead: surface.CommandConcealed,
})
f := &Factory{
SkillContent: content,
SkillReferences: resolver,
Recovery: recovery.NewProjector(func() *surface.Plan { return plan }),
}
if got, ok := f.ResolveSkillReference("lark-doc"); ok || got != "" {
t.Fatalf("concealed ResolveSkillReference() = %q, %v; want empty, false", got, ok)
}
})
}
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
func newCmdWithAsFlag(asValue string, changed bool) *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("as", "auto", "identity")
if changed {
_ = cmd.Flags().Set("as", asValue)
}
return cmd
}
// --- ResolveAs tests ---
func TestResolveAs_ExplicitAs(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("bot", true)
got := f.ResolveAs(context.Background(), cmd, core.AsBot)
if got != core.AsBot {
t.Errorf("want bot, got %s", got)
}
if f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be false for explicit --as")
}
if f.ResolvedIdentity != core.AsBot {
t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity)
}
}
func TestResolveAs_ExplicitAsUser(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("user", true)
got := f.ResolveAs(context.Background(), cmd, core.AsUser)
if got != core.AsUser {
t.Errorf("want user, got %s", got)
}
if f.ResolvedIdentity != core.AsUser {
t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity)
}
}
func TestResolveAs_ExplicitAuto_FallsToAutoDetect(t *testing.T) {
// --as auto explicitly: should fall through to auto-detect
// Config has no UserOpenId → auto-detect returns bot
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("auto", true)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (auto-detect, no login), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true for auto-detect path")
}
}
func TestResolveAs_DefaultAs_FromConfig(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{
AppID: "a", AppSecret: "s",
DefaultAs: "bot",
})
cmd := newCmdWithAsFlag("auto", false) // --as not changed
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (from default-as config), got %s", got)
}
if f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be false for default-as path")
}
}
func TestResolveAs_DefaultAs_EnvDoesNotBypassConfigSource(t *testing.T) {
t.Setenv(envvars.CliDefaultAs, "user")
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (env default-as should not bypass config source), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true when no account default-as is set")
}
}
func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) {
// default-as = "auto" should fall through to auto-detect
f, _, _, _ := TestFactory(t, &core.CliConfig{
AppID: "a", AppSecret: "s",
DefaultAs: "auto",
})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
// No UserOpenId → auto-detect returns bot
if got != core.AsBot {
t.Errorf("want bot (auto-detect), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true")
}
}
func TestResolveAs_NilCmd_AutoDetect(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
got := f.ResolveAs(context.Background(), nil, "auto")
if got != core.AsBot {
t.Errorf("want bot, got %s", got)
}
}
// --- CheckIdentity tests ---
func TestCheckIdentity_Supported(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
err := f.CheckIdentity(core.AsBot, []string{"bot", "user"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if f.ResolvedIdentity != core.AsBot {
t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity)
}
}
func TestCheckIdentity_Supported_UserOnly(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
err := f.CheckIdentity(core.AsUser, []string{"user"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if f.ResolvedIdentity != core.AsUser {
t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity)
}
}
func TestCheckIdentity_Unsupported_Explicit(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.IdentityAutoDetected = false // explicit --as
err := f.CheckIdentity(core.AsUser, []string{"bot"})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--as user is not supported") {
t.Errorf("unexpected error message: %v", err)
}
if !strings.Contains(err.Error(), "bot") {
t.Errorf("error should mention supported identity: %v", err)
}
}
func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.IdentityAutoDetected = true
err := f.CheckIdentity(core.AsUser, []string{"bot"})
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(ve.Message, "resolved identity") {
t.Errorf("expected 'resolved identity' in message, got: %v", ve.Message)
}
if !strings.Contains(ve.Hint, "use --as bot") {
t.Errorf("expected hint to suggest --as bot, got: %v", ve.Hint)
}
}
// --- NewAPIClient / NewAPIClientWithConfig tests ---
func TestNewAPIClient(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
ac, err := f.NewAPIClient()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac.Config.AppID != "a" {
t.Errorf("want AppID a, got %s", ac.Config.AppID)
}
}
func TestNewAPIClientWithConfig(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
ac, err := f.NewAPIClientWithConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac.Config.AppID != "a" {
t.Errorf("want AppID a, got %s", ac.Config.AppID)
}
if ac.SDK == nil {
t.Error("SDK should not be nil")
}
if ac.HTTP == nil {
t.Error("HTTP should not be nil")
}
}
func TestNewAPIClientWithConfig_NilIOStreams(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
f.IOStreams = nil
ac, err := f.NewAPIClientWithConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac == nil {
t.Fatal("expected non-nil APIClient")
}
}
// --- ResolveStrictMode tests ---
func TestResolveStrictMode_Off(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off, got %q", got)
}
}
func TestResolveStrictMode_BotFromAccount(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} // SupportsBot = 2
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
t.Errorf("expected bot, got %q", got)
}
}
func TestResolveStrictMode_UserFromAccount(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} // SupportsUser = 1
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeUser {
t.Errorf("expected user, got %q", got)
}
}
func TestResolveStrictMode_BothIdentities(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 3} // SupportsAll = 3
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off when both supported, got %q", got)
}
}
func TestResolveStrictMode_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.Credential = nil
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off with nil credential, got %q", got)
}
}
// --- CheckStrictMode tests ---
func TestCheckStrictMode_BotMode_BotAllowed(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
t.Errorf("bot should be allowed in bot mode, got: %v", err)
}
}
func TestCheckStrictMode_BotMode_UserBlocked(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
err := f.CheckStrictMode(context.Background(), core.AsUser)
if err == nil {
t.Fatal("expected error for user in bot mode")
}
if !strings.Contains(err.Error(), "strict mode") {
t.Errorf("error should mention strict mode, got: %v", err)
}
}
func TestCheckStrictMode_UserMode_UserAllowed(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
t.Errorf("user should be allowed in user mode, got: %v", err)
}
}
func TestCheckStrictMode_UserMode_BotBlocked(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
err := f.CheckStrictMode(context.Background(), core.AsBot)
if err == nil {
t.Fatal("expected error for bot in user mode")
}
}
func TestCheckStrictMode_Off_BothAllowed(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
t.Errorf("user should be allowed when off: %v", err)
}
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
t.Errorf("bot should be allowed when off: %v", err)
}
}
// --- ResolveAs strict mode tests ---
func TestResolveAs_StrictModeBot_ForceBot(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("bot mode should force bot, got %s", got)
}
}
func TestResolveAs_StrictModeUser_ForceUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsUser {
t.Errorf("user mode should force user, got %s", got)
}
}
func TestResolveAs_StrictModeUser_PreservesExplicitBot(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("bot", true)
got := f.ResolveAs(context.Background(), cmd, core.AsBot)
if got != core.AsBot {
t.Errorf("explicit bot should be preserved for strict-mode validation, got %s", got)
}
if err := f.CheckStrictMode(context.Background(), got); err == nil {
t.Fatal("expected strict-mode error for explicit bot in user mode")
}
}
func TestResolveAs_StrictModeBot_PreservesExplicitUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("user", true)
got := f.ResolveAs(context.Background(), cmd, core.AsUser)
if got != core.AsUser {
t.Errorf("explicit user should be preserved for strict-mode validation, got %s", got)
}
if err := f.CheckStrictMode(context.Background(), got); err == nil {
t.Fatal("expected strict-mode error for explicit user in bot mode")
}
}
func TestResolveAs_StrictModeUser_ExplicitAutoForcesUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", true)
got := f.ResolveAs(context.Background(), cmd, core.AsAuto)
if got != core.AsUser {
t.Errorf("--as auto should use strict-mode user identity, got %s", got)
}
}
func TestResolveAs_StrictModeBot_IgnoresDefaultAsUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", DefaultAs: "user", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("bot mode should override default-as user, got %s", got)
}
}
// stubExtProvider is a minimal extcred.Provider for testing external-provider guards.
type stubExtProvider struct {
name string
acct *extcred.Account
err error
}
func (s *stubExtProvider) Name() string { return s.name }
func (s *stubExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return s.acct, s.err
}
func (s *stubExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if ve.Message == "" {
t.Error("expected non-empty message")
}
if ve.Hint == "" {
t.Error("expected non-empty hint")
}
}
func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
// No extension providers → built-in path → no error
f, _, _, _ := TestFactory(t, nil)
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, nil)
f.Credential = nil
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err != nil {
t.Fatalf("unexpected error with nil Credential: %v", err)
}
}
// A provider that already classified its own failure must keep that
// classification. Rewrapping it would replace, for example, a retryable
// network timeout with an internal fault and change the exit code with it.
func TestRequireBuiltinCredentialProvider_KeepsProviderClassification(t *testing.T) {
typed := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "provider lookup timed out")
stub := &stubExtProvider{name: "env", err: typed}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error %T carries no Problem", err)
}
if problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTimeout {
t.Errorf("classified as %s/%s, want %s/%s (the provider's own classification)",
problem.Category, problem.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTimeout)
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Errorf("error = %T, want the provider's *errs.NetworkError preserved", err)
}
}
func TestRequireBuiltinCredentialProvider_PropagatesProviderError(t *testing.T) {
sentinel := errors.New("provider unavailable")
stub := &stubExtProvider{name: "env", err: sentinel}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if !errors.Is(err, sentinel) {
t.Fatalf("error = %v, want sentinel", err)
}
}
// A provider failure that carries no classification of its own is a fault in
// the tool, not in what the user typed. This runs in PersistentPreRunE, ahead
// of the command body, where an unclassified error is read as a bad command
// line — so the classification has to be applied here, and the exit code the
// user sees has to say internal fault rather than invalid input.
func TestRequireBuiltinCredentialProvider_UnclassifiedProviderErrorBecomesInternal(t *testing.T) {
sentinel := errors.New("provider unavailable")
stub := &stubExtProvider{name: "env", err: sentinel}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error %T carries no Problem; an unclassified failure would be blamed on the user", err)
}
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Errorf("classified as %s/%s, want %s/%s",
problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeUnknown)
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Errorf("error = %T, want *errs.InternalError", err)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
if !errors.Is(err, sentinel) {
t.Error("classification dropped the provider's own error")
}
}