mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
ef5da4b5ba
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.
767 lines
29 KiB
Go
767 lines
29 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/cmd/api"
|
|
"github.com/larksuite/cli/cmd/auth"
|
|
cmdconfig "github.com/larksuite/cli/cmd/config"
|
|
"github.com/larksuite/cli/cmd/schema"
|
|
"github.com/larksuite/cli/errs"
|
|
internalauth "github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/deprecation"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/recovery"
|
|
"github.com/larksuite/cli/internal/registry"
|
|
"github.com/larksuite/cli/internal/surface"
|
|
)
|
|
|
|
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
|
// auth, config, and schema commands have auth check disabled,
|
|
// while api does not.
|
|
func TestPersistentPreRunE_AuthCheckDisabledAnnotations(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
authCmd := auth.NewCmdAuth(f)
|
|
if !cmdutil.IsAuthCheckDisabled(authCmd) {
|
|
t.Error("expected auth command to have auth check disabled")
|
|
}
|
|
|
|
configCmd := cmdconfig.NewCmdConfig(f)
|
|
if !cmdutil.IsAuthCheckDisabled(configCmd) {
|
|
t.Error("expected config command to have auth check disabled")
|
|
}
|
|
|
|
schemaCmd := schema.NewCmdSchema(f, nil)
|
|
if !cmdutil.IsAuthCheckDisabled(schemaCmd) {
|
|
t.Error("expected schema command to have auth check disabled")
|
|
}
|
|
|
|
apiCmd := api.NewCmdApi(f, nil)
|
|
if cmdutil.IsAuthCheckDisabled(apiCmd) {
|
|
t.Error("expected api command to NOT have auth check disabled")
|
|
}
|
|
}
|
|
|
|
func TestPersistentPreRunE_AuthSubcommands(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
authCmd := auth.NewCmdAuth(f)
|
|
for _, sub := range authCmd.Commands() {
|
|
if !cmdutil.IsAuthCheckDisabled(sub) {
|
|
t.Errorf("expected auth subcommand %q to inherit disabled auth check", sub.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
configCmd := cmdconfig.NewCmdConfig(f)
|
|
for _, sub := range configCmd.Commands() {
|
|
if !cmdutil.IsAuthCheckDisabled(sub) {
|
|
t.Errorf("expected config subcommand %q to inherit disabled auth check", sub.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
|
// The human skills-install guidance now lives in the root usage-template
|
|
// footer (below the command list), not in the agent-facing Long.
|
|
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
|
|
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
|
|
}
|
|
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
|
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
|
|
}
|
|
}
|
|
|
|
func TestConfigureFlagCompletions(t *testing.T) {
|
|
t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) })
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantDisabled bool
|
|
}{
|
|
{"plain command", []string{"im", "+send"}, true},
|
|
{"help flag", []string{"im", "--help"}, true},
|
|
{"no args", []string{}, true},
|
|
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
|
|
{"__completeNoDesc request", []string{"__completeNoDesc", "im", "+send", ""}, false},
|
|
{"completion subcommand", []string{"completion", "bash"}, false},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled)
|
|
configureFlagCompletions(tc.args)
|
|
if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled {
|
|
t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// isCompletionCommand must classify BOTH cobra completion aliases as
|
|
// completion requests so the Shutdown emit and update-notice paths skip
|
|
// shell-completion invocations. __completeNoDesc is an Alias of
|
|
// __complete (cobra/completions.go ShellCompNoDescRequestCmd) and
|
|
// dispatches the same RunE; bash/zsh completion typically calls the
|
|
// NoDesc variant.
|
|
func TestIsCompletionCommand(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want bool
|
|
}{
|
|
{"plain command", []string{"im", "+send"}, false},
|
|
{"__complete", []string{"__complete", "im"}, true},
|
|
{"__completeNoDesc", []string{"__completeNoDesc", "im"}, true},
|
|
{"completion subcommand", []string{"completion", "bash"}, true},
|
|
{"completion in tail", []string{"foo", "bar", "completion"}, true},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isCompletionCommand(tc.args); got != tc.want {
|
|
t.Fatalf("isCompletionCommand(%v) = %v, want %v", tc.args, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
|
|
// *errs.SecurityPolicyError flows through the canonical typed envelope
|
|
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
|
|
// top-level identity, exit code 6 — after the dispatcher carve-out is removed.
|
|
func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
t.Run("21000 challenge_required", func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
spErr := &errs.SecurityPolicyError{
|
|
Problem: errs.Problem{
|
|
Category: errs.CategoryPolicy,
|
|
Subtype: errs.SubtypeChallengeRequired,
|
|
Code: 21000,
|
|
Message: "blocked by access policy",
|
|
Hint: "complete challenge in your browser",
|
|
},
|
|
ChallengeURL: "https://example.com/challenge",
|
|
}
|
|
|
|
gotExit := handleRootError(f, spErr, nil)
|
|
if gotExit != int(output.ExitContentSafety) {
|
|
t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety)
|
|
}
|
|
|
|
var env map[string]any
|
|
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
|
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
|
}
|
|
errObj, ok := env["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("envelope missing top-level error object: %s", errOut.String())
|
|
}
|
|
if got := errObj["type"]; got != "policy" {
|
|
t.Errorf("error.type = %v, want %q", got, "policy")
|
|
}
|
|
if got := errObj["subtype"]; got != "challenge_required" {
|
|
t.Errorf("error.subtype = %v, want %q", got, "challenge_required")
|
|
}
|
|
if got, ok := errObj["code"].(float64); !ok || int(got) != 21000 {
|
|
t.Errorf("error.code = %v (%T), want 21000 (number)", errObj["code"], errObj["code"])
|
|
}
|
|
if got := errObj["challenge_url"]; got != "https://example.com/challenge" {
|
|
t.Errorf("error.challenge_url = %v, want challenge url", got)
|
|
}
|
|
if got := errObj["hint"]; got != "complete challenge in your browser" {
|
|
t.Errorf("error.hint = %v, want hint message", got)
|
|
}
|
|
if _, exists := errObj["retryable"]; exists {
|
|
t.Errorf("error.retryable leaked into canonical envelope: %v", errObj["retryable"])
|
|
}
|
|
})
|
|
|
|
t.Run("21001 access_denied", func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
spErr := &errs.SecurityPolicyError{
|
|
Problem: errs.Problem{
|
|
Category: errs.CategoryPolicy,
|
|
Subtype: errs.SubtypeAccessDenied,
|
|
Code: 21001,
|
|
Message: "access denied",
|
|
},
|
|
}
|
|
|
|
gotExit := handleRootError(f, spErr, nil)
|
|
if gotExit != int(output.ExitContentSafety) {
|
|
t.Errorf("exit code = %d, want %d", gotExit, output.ExitContentSafety)
|
|
}
|
|
|
|
var env map[string]any
|
|
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
|
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
|
}
|
|
errObj := env["error"].(map[string]any)
|
|
if got := errObj["type"]; got != "policy" {
|
|
t.Errorf("error.type = %v, want %q", got, "policy")
|
|
}
|
|
if got := errObj["subtype"]; got != "access_denied" {
|
|
t.Errorf("error.subtype = %v, want %q", got, "access_denied")
|
|
}
|
|
if got, ok := errObj["code"].(float64); !ok || int(got) != 21001 {
|
|
t.Errorf("error.code = %v, want 21001 (number)", errObj["code"])
|
|
}
|
|
})
|
|
}
|
|
|
|
// newAuthErrorWithNeedAuthMarker builds a typed *errs.AuthenticationError whose Message
|
|
// contains the need_user_authorization marker — the same shape that
|
|
// resolveAccessToken now produces when the credential chain returns
|
|
// *internalauth.NeedAuthorizationError.
|
|
func newAuthErrorWithNeedAuthMarker() *errs.AuthenticationError {
|
|
cause := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
|
|
return &errs.AuthenticationError{
|
|
Problem: errs.Problem{
|
|
Category: errs.CategoryAuthentication,
|
|
Subtype: errs.SubtypeUnknown,
|
|
Message: fmt.Sprintf("API call failed: %s", cause),
|
|
},
|
|
Cause: cause,
|
|
}
|
|
}
|
|
|
|
// failingWriter writes up to limit bytes then returns io.ErrShortWrite on
|
|
// the write that would push past the limit. Used to simulate a stderr that
|
|
// dies mid-envelope.
|
|
type failingWriter struct {
|
|
limit int
|
|
n int
|
|
}
|
|
|
|
func (f *failingWriter) Write(p []byte) (int, error) {
|
|
if f.n+len(p) > f.limit {
|
|
canWrite := f.limit - f.n
|
|
if canWrite < 0 {
|
|
canWrite = 0
|
|
}
|
|
f.n += canWrite
|
|
return canWrite, io.ErrShortWrite
|
|
}
|
|
f.n += len(p)
|
|
return len(p), nil
|
|
}
|
|
|
|
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins that a
|
|
// backward-compat alias failing on a cobra-level required flag (which
|
|
// short-circuits before RunE) routes through the structured envelope, so the
|
|
// deprecation notice OnInvoke records in PreRunE is carried on the wire instead
|
|
// of being dropped on a plain "Error:" line.
|
|
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
deprecation.SetPending(&deprecation.Notice{
|
|
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
|
})
|
|
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
|
|
// errs.* error, so it reaches the deprecation fallback.
|
|
exit := handleRootError(f, normalizeRootError(fmt.Errorf(`required flag(s) %q not set`, "values")), nil)
|
|
|
|
out := errOut.String()
|
|
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
|
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
|
|
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
|
|
}
|
|
// The envelope is typed validation, so the exit code must derive from that
|
|
// category (2) — the wire type and the exit code must not disagree.
|
|
if exit != int(output.ExitValidation) {
|
|
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression
|
|
// baseline for auth/config errors: it pins the typed envelope and exit code the
|
|
// dispatcher produces for the two source-of-truth shapes, which are constructed
|
|
// typed at their origin in internal/auth and internal/core.
|
|
func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
t.Run("token missing exits 3 with token_missing authentication envelope", func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"), nil)
|
|
if exit != int(output.ExitAuth) {
|
|
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
|
|
}
|
|
|
|
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
|
if got := errObj["type"]; got != "authentication" {
|
|
t.Errorf("error.type = %v, want %q", got, "authentication")
|
|
}
|
|
if got := errObj["subtype"]; got != "token_missing" {
|
|
t.Errorf("error.subtype = %v, want %q", got, "token_missing")
|
|
}
|
|
if got, _ := errObj["message"].(string); !strings.Contains(got, "need_user_authorization") {
|
|
t.Errorf("error.message = %q, must keep the need_user_authorization marker", got)
|
|
}
|
|
if got, _ := errObj["message"].(string); !strings.Contains(got, "u_golden") {
|
|
t.Errorf("error.message = %q, must carry the user open id", got)
|
|
}
|
|
if got, _ := errObj["hint"].(string); !strings.Contains(got, "auth login") {
|
|
t.Errorf("error.hint = %q, must point at auth login", got)
|
|
}
|
|
if got := errObj["user_open_id"]; got != "u_golden" {
|
|
t.Errorf("error.user_open_id = %v, want %q", got, "u_golden")
|
|
}
|
|
})
|
|
|
|
t.Run("not configured exits 3 with not_configured config envelope", func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
exit := handleRootError(f, core.NotConfiguredError(), nil)
|
|
if exit != int(output.ExitAuth) {
|
|
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
|
|
}
|
|
|
|
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
|
if got := errObj["type"]; got != "config" {
|
|
t.Errorf("error.type = %v, want %q", got, "config")
|
|
}
|
|
if got := errObj["subtype"]; got != "not_configured" {
|
|
t.Errorf("error.subtype = %v, want %q", got, "not_configured")
|
|
}
|
|
if got, _ := errObj["message"].(string); !strings.Contains(got, "not configured") {
|
|
t.Errorf("error.message = %q, want the not-configured message", got)
|
|
}
|
|
if got, _ := errObj["hint"].(string); !strings.Contains(got, "config init") {
|
|
t.Errorf("error.hint = %q, must point at config init", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// decodeErrorEnvelope unmarshals a typed error envelope and returns its
|
|
// top-level "error" object, failing the test if the shape is unexpected.
|
|
func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
|
|
t.Helper()
|
|
var env map[string]any
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
t.Fatalf("envelope is not valid JSON: %v\n%s", err, raw)
|
|
}
|
|
errObj, ok := env["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("envelope missing top-level error object: %s", raw)
|
|
}
|
|
return errObj
|
|
}
|
|
|
|
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
|
|
// usage error (missing required flag) is typed as invalid_argument with exit 2
|
|
// even with no deprecation pending — never cobra's plain "Error:" line.
|
|
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
|
deprecation.SetPending(nil)
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
exit := handleRootError(f, normalizeRootError(fmt.Errorf(`required flag(s) %q not set`, "values")), nil)
|
|
|
|
out := errOut.String()
|
|
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
|
t.Fatalf("want a structured envelope, got a plain Error: line:\n%s", out)
|
|
}
|
|
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
|
if got := errObj["type"]; got != "validation" {
|
|
t.Errorf("error.type = %v, want %q", got, "validation")
|
|
}
|
|
if got, _ := errObj["message"].(string); !strings.Contains(got, "values") {
|
|
t.Errorf("error.message = %q, must carry the failing flag name", got)
|
|
}
|
|
if exit != int(output.ExitValidation) {
|
|
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
|
|
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
|
|
// typed boundary from a helper) is classified as an internal fault (exit 5),
|
|
// not blamed on the user's input as a validation error.
|
|
func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
|
deprecation.SetPending(nil)
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
exit := handleRootError(f, typedCommandError(fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF)), nil)
|
|
|
|
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
|
if got := errObj["type"]; got != "internal" {
|
|
t.Errorf("error.type = %v, want %q (leaked untyped error must not be mislabeled validation)", got, "internal")
|
|
}
|
|
if exit != int(output.ExitInternal) {
|
|
t.Errorf("exit = %d, want %d (internal envelope → category-derived exit)", exit, int(output.ExitInternal))
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
|
// stderr write fails mid-envelope, handleRootError still returns the typed
|
|
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
|
// plain "Error:" path with exit 1. ExitCodeOf is computed from the typed
|
|
// err BEFORE the envelope write so the exit code is preserved even when
|
|
// the consumer's stderr pipe dies.
|
|
func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
w := &failingWriter{limit: 20}
|
|
f.IOStreams.ErrOut = w
|
|
|
|
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
|
|
exit := handleRootError(f, err, nil)
|
|
if exit != int(output.ExitAuth) {
|
|
t.Errorf("exit = %d, want %d (typed exit code preserved despite write failure)", exit, int(output.ExitAuth))
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_BareErrorExitCodeNoStderr pins the silent-exit
|
|
// contract: a *output.BareError is honored for its exit code while stderr stays
|
|
// empty (stdout already carries the result, so the dispatcher must not layer a
|
|
// second envelope on top).
|
|
func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
exit := handleRootError(f, output.ErrBare(output.ExitAuth), nil)
|
|
if exit != int(output.ExitAuth) {
|
|
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
|
|
}
|
|
if errOut.Len() != 0 {
|
|
t.Errorf("stderr must stay empty for a bare predicate signal, got:\n%s", errOut.String())
|
|
}
|
|
}
|
|
|
|
// TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved pins that a typed
|
|
// *errs.AuthenticationError carrying a legacy *NeedAuthorizationError in its
|
|
// Cause chain renders the producer's TokenExpired subtype + custom hint
|
|
// verbatim — the legacy sentinel in the Cause chain never coarsens the wire
|
|
// shape.
|
|
func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
errOut := &bytes.Buffer{}
|
|
f.IOStreams.ErrOut = errOut
|
|
|
|
innerLegacy := &internalauth.NeedAuthorizationError{UserOpenId: "u_123"}
|
|
outer := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired").
|
|
WithHint("custom producer hint").
|
|
WithCause(innerLegacy)
|
|
|
|
exit := handleRootError(f, outer, nil)
|
|
if exit != int(output.ExitAuth) {
|
|
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
|
|
}
|
|
got := errOut.String()
|
|
if !strings.Contains(got, `"subtype": "token_expired"`) {
|
|
t.Errorf("envelope lost producer Subtype TokenExpired; got %s", got)
|
|
}
|
|
if !strings.Contains(got, "custom producer hint") {
|
|
t.Errorf("envelope lost producer Hint; got %s", got)
|
|
}
|
|
}
|
|
|
|
// TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT pins
|
|
// that a typed AuthenticationError carrying the need_user_authorization marker
|
|
// gets executable scoped recovery when the current command is a registered
|
|
// service method.
|
|
func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
f.ResolvedIdentity = core.AsUser
|
|
|
|
var target registry.CommandEntry
|
|
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
|
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
|
|
target = entry
|
|
break
|
|
}
|
|
}
|
|
if target.Command == "" {
|
|
t.Fatal("failed to locate a calendar create command in local registry metadata")
|
|
}
|
|
parts := strings.Split(target.Command, " ")
|
|
if len(parts) != 2 {
|
|
t.Fatalf("expected resource/method command, got %q", target.Command)
|
|
}
|
|
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
serviceCmd := &cobra.Command{Use: "calendar"}
|
|
resourceCmd := &cobra.Command{Use: parts[0]}
|
|
methodCmd := &cobra.Command{Use: parts[1]}
|
|
root.AddCommand(serviceCmd)
|
|
serviceCmd.AddCommand(resourceCmd)
|
|
resourceCmd.AddCommand(methodCmd)
|
|
f.CurrentCommand = methodCmd
|
|
|
|
source := internalauth.NewNeedUserAuthorizationError("u_service")
|
|
var authErr *errs.AuthenticationError
|
|
if !errors.As(source, &authErr) {
|
|
t.Fatalf("source = %T, want *errs.AuthenticationError", source)
|
|
}
|
|
originalHint := authErr.Hint
|
|
rendered := presentRootError(f, source, recovery.NewProjector(nil))
|
|
problem, ok := errs.ProblemOf(rendered)
|
|
if !ok {
|
|
t.Fatalf("rendered error = %T, want typed error", rendered)
|
|
}
|
|
|
|
if problem.Category != errs.CategoryAuthentication {
|
|
t.Errorf("Category = %q, want authentication", problem.Category)
|
|
}
|
|
if problem.Subtype != errs.SubtypeTokenMissing {
|
|
t.Errorf("Subtype = %q, want %q", problem.Subtype, errs.SubtypeTokenMissing)
|
|
}
|
|
if !errors.Is(rendered, authErr.Cause) {
|
|
t.Errorf("rendered error lost need-authorization cause %v: %v", authErr.Cause, rendered)
|
|
}
|
|
if !strings.Contains(problem.Message, "need_user_authorization") {
|
|
t.Errorf("Message should preserve need_user_authorization marker; got %q", problem.Message)
|
|
}
|
|
if !strings.Contains(problem.Hint, `auth login --scope "calendar:calendar.event:create" --no-wait --json`) {
|
|
t.Errorf("expected scoped two-turn recovery, got %q", problem.Hint)
|
|
}
|
|
if authErr.Hint != originalHint || !strings.Contains(authErr.Hint, "--recommend --no-wait --json") {
|
|
t.Errorf("presenter mutated producer's generic recovery: before %q, after %q", originalHint, authErr.Hint)
|
|
}
|
|
|
|
concealedPlan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
|
surface.CommandAuthLogin: surface.CommandConcealed,
|
|
})
|
|
concealed := presentRootError(f, internalauth.NewNeedUserAuthorizationError("u_service"), recovery.NewProjector(func() *surface.Plan {
|
|
return concealedPlan
|
|
}))
|
|
concealedProblem, ok := errs.ProblemOf(concealed)
|
|
if !ok {
|
|
t.Fatalf("concealed rendered error = %T, want typed error", concealed)
|
|
}
|
|
wantFallback := recovery.UserAuthorization("calendar:calendar.event:create").Render(concealedPlan)
|
|
if concealedProblem.Hint != wantFallback {
|
|
t.Errorf("concealed recovery = %q, want fallback %q", concealedProblem.Hint, wantFallback)
|
|
}
|
|
if strings.Contains(concealedProblem.Hint, "auth login") {
|
|
t.Errorf("concealed recovery leaked auth command: %q", concealedProblem.Hint)
|
|
}
|
|
}
|
|
|
|
// TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT pins the
|
|
// same hint behavior for mounted shortcut commands.
|
|
func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
f.ResolvedIdentity = core.AsUser
|
|
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
serviceCmd := &cobra.Command{Use: "docs"}
|
|
shortcutCmd := &cobra.Command{Use: "+create"}
|
|
root.AddCommand(serviceCmd)
|
|
serviceCmd.AddCommand(shortcutCmd)
|
|
f.CurrentCommand = shortcutCmd
|
|
|
|
authErr := newAuthErrorWithNeedAuthMarker()
|
|
rendered := presentRootError(f, authErr, recovery.NewProjector(nil))
|
|
problem, ok := errs.ProblemOf(rendered)
|
|
if !ok {
|
|
t.Fatalf("rendered error = %T, want typed error", rendered)
|
|
}
|
|
if problem.Category != errs.CategoryAuthentication {
|
|
t.Errorf("Category = %q, want %q", problem.Category, errs.CategoryAuthentication)
|
|
}
|
|
if problem.Subtype != errs.SubtypeUnknown {
|
|
t.Errorf("Subtype = %q, want %q", problem.Subtype, errs.SubtypeUnknown)
|
|
}
|
|
|
|
if !strings.Contains(problem.Hint, `auth login --scope "docx:document:create docs:document.media:upload docx:document:write_only docx:document:readonly" --no-wait --json`) {
|
|
t.Errorf("expected shortcut scoped recovery, got %q", problem.Hint)
|
|
}
|
|
}
|
|
|
|
// TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes pins that
|
|
// conditional scopes declared on a shortcut surface in the hint.
|
|
func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
f.ResolvedIdentity = core.AsUser
|
|
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
serviceCmd := &cobra.Command{Use: "drive"}
|
|
shortcutCmd := &cobra.Command{Use: "+status"}
|
|
root.AddCommand(serviceCmd)
|
|
serviceCmd.AddCommand(shortcutCmd)
|
|
f.CurrentCommand = shortcutCmd
|
|
|
|
authErr := newAuthErrorWithNeedAuthMarker()
|
|
rendered := presentRootError(f, authErr, recovery.NewProjector(nil))
|
|
problem, ok := errs.ProblemOf(rendered)
|
|
if !ok {
|
|
t.Fatalf("rendered error = %T, want typed error", rendered)
|
|
}
|
|
if problem.Category != errs.CategoryAuthentication {
|
|
t.Errorf("Category = %q, want %q", problem.Category, errs.CategoryAuthentication)
|
|
}
|
|
if problem.Subtype != errs.SubtypeUnknown {
|
|
t.Errorf("Subtype = %q, want %q", problem.Subtype, errs.SubtypeUnknown)
|
|
}
|
|
|
|
if !strings.Contains(problem.Hint, `auth login --scope "drive:drive.metadata:readonly drive:file:download" --no-wait --json`) {
|
|
t.Errorf("expected conditional scoped recovery for drive +status, got %q", problem.Hint)
|
|
}
|
|
}
|
|
|
|
// TestApplyNeedAuthorizationHint_AppendsExistingHint pins that the
|
|
// declared-scope recovery is appended (separated by newline) when the typed
|
|
// AuthenticationError already carries a Hint from elsewhere.
|
|
func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
f.ResolvedIdentity = core.AsUser
|
|
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
serviceCmd := &cobra.Command{Use: "docs"}
|
|
shortcutCmd := &cobra.Command{Use: "+create"}
|
|
root.AddCommand(serviceCmd)
|
|
serviceCmd.AddCommand(shortcutCmd)
|
|
f.CurrentCommand = shortcutCmd
|
|
|
|
authErr := newAuthErrorWithNeedAuthMarker()
|
|
authErr.Hint = "existing hint"
|
|
rendered := presentRootError(f, authErr, recovery.NewProjector(nil))
|
|
problem, ok := errs.ProblemOf(rendered)
|
|
if !ok {
|
|
t.Fatalf("rendered error = %T, want typed error", rendered)
|
|
}
|
|
if problem.Category != errs.CategoryAuthentication {
|
|
t.Errorf("Category = %q, want %q", problem.Category, errs.CategoryAuthentication)
|
|
}
|
|
if problem.Subtype != errs.SubtypeUnknown {
|
|
t.Errorf("Subtype = %q, want %q", problem.Subtype, errs.SubtypeUnknown)
|
|
}
|
|
if !errors.Is(rendered, authErr.Cause) {
|
|
t.Errorf("rendered error lost need-authorization cause %v: %v", authErr.Cause, rendered)
|
|
}
|
|
|
|
want := "existing hint\n" + recovery.UserAuthorization(
|
|
"docx:document:create",
|
|
"docs:document.media:upload",
|
|
"docx:document:write_only",
|
|
"docx:document:readonly",
|
|
).String()
|
|
if problem.Hint != want {
|
|
t.Errorf("expected appended hint %q, got %q", want, problem.Hint)
|
|
}
|
|
if authErr.Hint != "existing hint" {
|
|
t.Errorf("presenter mutated producer hint: %q", authErr.Hint)
|
|
}
|
|
}
|
|
|
|
// TestNormalizeRootError pins the classification executeWithOptions applies
|
|
// (via normalizeRootError) immediately after rootCmd.Execute(), before the
|
|
// Shutdown lifecycle hook fires: a residual cobra usage error becomes a
|
|
// typed validation error, while already-typed errors and the two exit-code-only
|
|
// signals pass through unchanged. Application errors are typed earlier at
|
|
// their callback boundary. Without this, a plugin's
|
|
// Shutdown handler would observe an untyped cobra error that disagrees with
|
|
// the Category/Subtype the stderr envelope ultimately carries.
|
|
func TestNormalizeRootError(t *testing.T) {
|
|
t.Run("nil", func(t *testing.T) {
|
|
if got := normalizeRootError(nil); got != nil {
|
|
t.Errorf("normalizeRootError(nil) = %v, want nil", got)
|
|
}
|
|
})
|
|
|
|
t.Run("already typed passes through unchanged", func(t *testing.T) {
|
|
typed := errs.NewPermissionError(errs.SubtypePermissionDenied, "denied")
|
|
if got := normalizeRootError(typed); got != error(typed) {
|
|
t.Errorf("normalizeRootError(typed) = %v, want the same typed error unchanged", got)
|
|
}
|
|
})
|
|
|
|
t.Run("PartialFailureError passes through unchanged", func(t *testing.T) {
|
|
pfErr := &output.PartialFailureError{Code: 1}
|
|
if got := normalizeRootError(pfErr); got != error(pfErr) {
|
|
t.Errorf("normalizeRootError(PartialFailureError) = %v, want the same signal unchanged", got)
|
|
}
|
|
})
|
|
|
|
t.Run("BareError passes through unchanged", func(t *testing.T) {
|
|
bareErr := output.ErrBare(output.ExitAuth)
|
|
if got := normalizeRootError(bareErr); got != error(bareErr) {
|
|
t.Errorf("normalizeRootError(BareError) = %v, want the same signal unchanged", got)
|
|
}
|
|
})
|
|
|
|
t.Run("cobra usage error becomes typed validation", func(t *testing.T) {
|
|
original := fmt.Errorf(`required flag(s) %q not set`, "csv")
|
|
got := normalizeRootError(original)
|
|
var ve *errs.ValidationError
|
|
if !errors.As(got, &ve) {
|
|
t.Fatalf("normalizeRootError(cobra usage error) = %v, want *errs.ValidationError", got)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if !errors.Is(got, original) {
|
|
t.Error("normalizeRootError(cobra usage error) lost the original cause")
|
|
}
|
|
})
|
|
}
|