mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
* refactor: retire legacy error envelopes and enforce typed contract
Consolidate all command error reporting onto the typed errs.* contract, remove
the legacy error surface that predated it, and tighten the lint guards so the
contract holds across the whole repository going forward.
Every failure now reaches stderr as one envelope shape: a category, an
optional subtype, a human- and agent-readable message, and a recovery hint,
with invalid parameters listed under `params`. The legacy ExitError envelope,
its constructors, and the boundary bridge that promoted untyped config and
authorization errors are deleted, leaving a single path from error to wire.
Predicate commands keep their silent-exit behavior through a dedicated signal
that carries only an exit code.
Infrastructure paths that still emitted ad-hoc envelopes — flag parsing,
unknown commands and subcommands, plugin and policy guards, confirmation
prompts, and auth/config failures — now classify into the same taxonomy.
Business, API, auth, and config exit codes are preserved; the one behavioral
change is that Cobra usage failures (missing required flag, unknown command,
bad arguments) now emit the typed validation envelope and exit 2, matching the
explicit flag and subcommand guards, instead of Cobra's plain-text exit 1.
Enforcement is repo-wide rather than per-path:
- The errscontract guards run by default everywhere instead of through a
migration allowlist, so legacy envelopes cannot be reintroduced anywhere.
- errorlint runs across the whole repository: every error wrap must use %w and
every comparison must use errors.Is/errors.As, so interior wraps stay legal
but can no longer break the chain the typed boundary relies on.
- The errs-no-bare-wrap guard is keyed by structural prefix instead of an
explicit per-domain allowlist, so new shortcut domains are covered without
editing a list. It runs where forbidigo is enabled (the shortcut domains and
the auth/config/service command groups); repo-wide chain integrity for the
remaining command paths is carried by errorlint above.
* test: align cli_e2e success assertions to the ok envelope
The api and service success path now emits the {"ok":true} envelope, so the
cli_e2e workflow assertions that still expected the old {"code":0} shape via
AssertStdoutStatus(t, 0) fail once they run with live credentials. Switch those
workflow assertions to AssertStdoutStatus(t, true); the fake-payload helper test
in core_test.go keeps its code-shape assertion.
415 lines
13 KiB
Go
415 lines
13 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package hook_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/platform"
|
|
"github.com/larksuite/cli/internal/hook"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// fakeViewSource is a minimal CommandView for tests -- it ignores the
|
|
// cobra command and returns a fixed view.
|
|
type fakeViewSource struct{ view platform.CommandView }
|
|
|
|
func (f fakeViewSource) View(*cobra.Command) platform.CommandView { return f.view }
|
|
|
|
type fakeView struct {
|
|
path string
|
|
risk string
|
|
}
|
|
|
|
func (v fakeView) Path() string { return v.path }
|
|
func (v fakeView) Domain() string { return "" }
|
|
func (v fakeView) Risk() (platform.Risk, bool) { return platform.Risk(v.risk), v.risk != "" }
|
|
func (v fakeView) Identities() []platform.Identity { return nil }
|
|
func (v fakeView) Annotation(string) (string, bool) { return "", false }
|
|
|
|
func makeLeaf(use string) *cobra.Command {
|
|
return &cobra.Command{Use: use, RunE: func(*cobra.Command, []string) error { return nil }}
|
|
}
|
|
|
|
// Observers fire on Before AND After even when RunE returns an error.
|
|
// This is the failure-path observability contract -- After must always
|
|
// run so audit hooks see completion regardless of outcome.
|
|
func TestInstall_observersBeforeAndAfterAlwaysRun(t *testing.T) {
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error {
|
|
return errors.New("boom")
|
|
}}
|
|
root.AddCommand(leaf)
|
|
|
|
reg := hook.NewRegistry()
|
|
|
|
var seen []string
|
|
reg.AddObserver(hook.ObserverEntry{
|
|
Name: "before", When: platform.Before, Selector: platform.All(),
|
|
Fn: func(_ context.Context, inv platform.Invocation) {
|
|
seen = append(seen, fmt.Sprintf("before:err=%v", inv.Err()))
|
|
},
|
|
})
|
|
reg.AddObserver(hook.ObserverEntry{
|
|
Name: "after", When: platform.After, Selector: platform.All(),
|
|
Fn: func(_ context.Context, inv platform.Invocation) {
|
|
seen = append(seen, fmt.Sprintf("after:err=%v", inv.Err()))
|
|
},
|
|
})
|
|
|
|
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
|
|
|
|
err := leaf.RunE(leaf, nil)
|
|
if err == nil || err.Error() != "boom" {
|
|
t.Fatalf("expected RunE to return original error, got %v", err)
|
|
}
|
|
|
|
wantBefore := "before:err=<nil>" // before fires with Err still nil
|
|
wantAfter := "after:err=boom" // after sees the failed RunE error
|
|
if len(seen) != 2 || seen[0] != wantBefore || seen[1] != wantAfter {
|
|
t.Fatalf("observer ordering / Err propagation broken, got %v", seen)
|
|
}
|
|
}
|
|
|
|
// Wrap chain composes outermost-first (registration order). A regression
|
|
// that inverts the composition would change which Wrapper short-circuits
|
|
// first for safety-sensitive layers.
|
|
func TestInstall_wrapperChainOrder(t *testing.T) {
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
var order []string
|
|
leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error {
|
|
order = append(order, "RunE")
|
|
return nil
|
|
}}
|
|
root.AddCommand(leaf)
|
|
|
|
reg := hook.NewRegistry()
|
|
reg.AddWrapper(hook.WrapperEntry{
|
|
Name: "outer", Selector: platform.All(),
|
|
Fn: func(next platform.Handler) platform.Handler {
|
|
return func(ctx context.Context, inv platform.Invocation) error {
|
|
order = append(order, "outer-before")
|
|
err := next(ctx, inv)
|
|
order = append(order, "outer-after")
|
|
return err
|
|
}
|
|
},
|
|
})
|
|
reg.AddWrapper(hook.WrapperEntry{
|
|
Name: "inner", Selector: platform.All(),
|
|
Fn: func(next platform.Handler) platform.Handler {
|
|
return func(ctx context.Context, inv platform.Invocation) error {
|
|
order = append(order, "inner-before")
|
|
err := next(ctx, inv)
|
|
order = append(order, "inner-after")
|
|
return err
|
|
}
|
|
},
|
|
})
|
|
|
|
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
|
|
if err := leaf.RunE(leaf, nil); err != nil {
|
|
t.Fatalf("RunE: %v", err)
|
|
}
|
|
want := []string{"outer-before", "inner-before", "RunE", "inner-after", "outer-after"}
|
|
if !equalStrings(order, want) {
|
|
t.Fatalf("Wrapper order = %v, want %v", order, want)
|
|
}
|
|
}
|
|
|
|
// Denial guard physical isolation: the most safety-critical invariant.
|
|
// A denied command must NEVER reach a Wrap chain. We register a Wrap
|
|
// that, given the chance, would silently allow the call (return nil,
|
|
// don't call next, no AbortError). The guard must skip Wrap entirely
|
|
// so the denyStub's error reaches the caller.
|
|
//
|
|
// Without this guarantee, any plugin Wrap matching All() could
|
|
// bypass user policy / strict-mode denials.
|
|
func TestInstall_denialGuard_physicalIsolation(t *testing.T) {
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
denyStubCalled := false
|
|
leaf := &cobra.Command{
|
|
Use: "+forbidden",
|
|
RunE: func(*cobra.Command, []string) error {
|
|
denyStubCalled = true
|
|
return errors.New("CommandPruned: this is the denyStub")
|
|
},
|
|
Annotations: map[string]string{
|
|
"lark:policy_denied_layer": "policy",
|
|
"lark:policy_denied_source": "yaml",
|
|
},
|
|
}
|
|
root.AddCommand(leaf)
|
|
|
|
reg := hook.NewRegistry()
|
|
|
|
maliciousWrapCalled := false
|
|
reg.AddWrapper(hook.WrapperEntry{
|
|
Name: "malicious", Selector: platform.All(),
|
|
Fn: func(next platform.Handler) platform.Handler {
|
|
return func(ctx context.Context, inv platform.Invocation) error {
|
|
maliciousWrapCalled = true
|
|
return nil // suppress the denial
|
|
}
|
|
},
|
|
})
|
|
|
|
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+forbidden"}})
|
|
|
|
err := leaf.RunE(leaf, nil)
|
|
if maliciousWrapCalled {
|
|
t.Errorf("denial guard violated: Wrap was invoked on a denied command")
|
|
}
|
|
if !denyStubCalled {
|
|
t.Errorf("denyStub (original RunE) should still run on the denial path")
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("denyStub error must propagate, got nil")
|
|
}
|
|
}
|
|
|
|
// Observer panics must not break the main flow. The guard converts the
|
|
// panic to a stderr warning and continues; the command still runs.
|
|
func TestInstall_observerPanicIsolated(t *testing.T) {
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
runECalled := false
|
|
leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error {
|
|
runECalled = true
|
|
return nil
|
|
}}
|
|
root.AddCommand(leaf)
|
|
|
|
reg := hook.NewRegistry()
|
|
reg.AddObserver(hook.ObserverEntry{
|
|
Name: "buggy", When: platform.Before, Selector: platform.All(),
|
|
Fn: func(context.Context, platform.Invocation) {
|
|
panic("plugin author wrote bad code")
|
|
},
|
|
})
|
|
|
|
// Capture stderr to make sure the warning was emitted. Restore the
|
|
// previous sink so a subsequent test isn't stuck writing into our
|
|
// discarded buffer.
|
|
t.Cleanup(hook.SetStderrForTesting(&bytes.Buffer{})) // discard
|
|
|
|
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
|
|
if err := leaf.RunE(leaf, nil); err != nil {
|
|
t.Fatalf("RunE should still succeed when an Observer panicked, got %v", err)
|
|
}
|
|
if !runECalled {
|
|
t.Errorf("RunE must execute despite Observer panic")
|
|
}
|
|
}
|
|
|
|
// A Wrapper returning AbortError surfaces as a typed
|
|
// *errs.ValidationError (failed_precondition, exit 2) so cmd/root.go's
|
|
// envelope writer can serialise it. The original AbortError is preserved
|
|
// as the Cause so errors.As consumers still reach HookName / Reason.
|
|
func TestInstall_abortErrorBecomesExitError(t *testing.T) {
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
leaf := makeLeaf("+x")
|
|
root.AddCommand(leaf)
|
|
|
|
reg := hook.NewRegistry()
|
|
reg.AddWrapper(hook.WrapperEntry{
|
|
Name: "rejecter", Selector: platform.All(),
|
|
Fn: func(_ platform.Handler) platform.Handler {
|
|
return func(context.Context, platform.Invocation) error {
|
|
return &platform.AbortError{
|
|
HookName: "rejecter",
|
|
Reason: "policy says no",
|
|
}
|
|
}
|
|
},
|
|
})
|
|
|
|
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
|
|
|
|
err := leaf.RunE(leaf, nil)
|
|
if err == nil {
|
|
t.Fatalf("Wrap aborted; expected error")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("AbortError must convert to *errs.ValidationError, got %T %+v", err, err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
|
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
|
|
}
|
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
|
|
}
|
|
// The hook name must be discoverable in the user-facing hint.
|
|
if !strings.Contains(ve.Hint, "rejecter") {
|
|
t.Errorf("hint must carry hook name rejecter, got %q", ve.Hint)
|
|
}
|
|
// The original AbortError must still be reachable via errors.As, with
|
|
// its attribution intact.
|
|
var ab *platform.AbortError
|
|
if !errors.As(err, &ab) {
|
|
t.Fatalf("error chain should expose *platform.AbortError")
|
|
}
|
|
if ab.HookName != "rejecter" || ab.Reason != "policy says no" {
|
|
t.Errorf("AbortError = %+v, want HookName=rejecter Reason=%q", ab, "policy says no")
|
|
}
|
|
}
|
|
|
|
// namespacedWrap must not mutate a shared *AbortError. A plugin author
|
|
// might construct a sentinel at package scope and return it from
|
|
// multiple Wrap invocations; mutating it would let attribution leak
|
|
// across concurrent command runs and would also race.
|
|
//
|
|
// Production path test: drive a real cobra.Command through Install
|
|
// so namespacedWrap inside install.go is exercised. The plugin returns
|
|
// the same sentinel pointer twice. Both observed envelopes must have
|
|
// the framework-namespaced HookName, but the sentinel's own HookName
|
|
// must remain whatever the plugin originally set.
|
|
func TestInstall_namespacedWrap_doesNotMutateSentinel(t *testing.T) {
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
leafA := makeLeaf("+a")
|
|
leafB := makeLeaf("+b")
|
|
root.AddCommand(leafA)
|
|
root.AddCommand(leafB)
|
|
|
|
sentinel := &platform.AbortError{HookName: "sentinel-original", Reason: "no"}
|
|
|
|
reg := hook.NewRegistry()
|
|
// Two Wrappers, different namespaced names, return the SAME
|
|
// sentinel.
|
|
reg.AddWrapper(hook.WrapperEntry{
|
|
Name: "plugin-a.wrap",
|
|
Selector: platform.ByCommandPath("+a"),
|
|
Fn: func(platform.Handler) platform.Handler {
|
|
return func(context.Context, platform.Invocation) error { return sentinel }
|
|
},
|
|
})
|
|
reg.AddWrapper(hook.WrapperEntry{
|
|
Name: "plugin-b.wrap",
|
|
Selector: platform.ByCommandPath("+b"),
|
|
Fn: func(platform.Handler) platform.Handler {
|
|
return func(context.Context, platform.Invocation) error { return sentinel }
|
|
},
|
|
})
|
|
|
|
hook.Install(root, reg, fakeViewSourceByPath{})
|
|
|
|
// Invoke both leaves.
|
|
errA := leafA.RunE(leafA, nil)
|
|
errB := leafB.RunE(leafB, nil)
|
|
|
|
// Sentinel must remain untouched: the framework must copy before
|
|
// rewriting HookName.
|
|
if sentinel.HookName != "sentinel-original" {
|
|
t.Errorf("sentinel AbortError was mutated: HookName = %q", sentinel.HookName)
|
|
}
|
|
|
|
// Each invocation's envelope must carry the correct namespace --
|
|
// proving the framework DID set the right name on its own copy.
|
|
checkHookName(t, errA, "plugin-a.wrap")
|
|
checkHookName(t, errB, "plugin-b.wrap")
|
|
}
|
|
|
|
// fakeViewSourceByPath returns a CommandView whose Path matches the
|
|
// leaf's Use field (so ByCommandPath selectors discriminate).
|
|
type fakeViewSourceByPath struct{}
|
|
|
|
func (fakeViewSourceByPath) View(c *cobra.Command) platform.CommandView {
|
|
return fakeView{path: c.Use}
|
|
}
|
|
|
|
func checkHookName(t *testing.T, err error, want string) {
|
|
t.Helper()
|
|
// The abort surfaces as a typed *errs.ValidationError; the original
|
|
// (namespaced copy of the) AbortError is preserved as its Cause, so
|
|
// errors.As reaches the attribution the framework wrote.
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
|
}
|
|
var ab *platform.AbortError
|
|
if !errors.As(err, &ab) {
|
|
t.Fatalf("error chain should expose *platform.AbortError, got %T", err)
|
|
}
|
|
if ab.HookName != want {
|
|
t.Errorf("hook_name = %v, want %v", ab.HookName, want)
|
|
}
|
|
}
|
|
|
|
// A Before observer mutating inv.Args() must not affect what the
|
|
// original RunE sees: pins the slice-level read-only contract.
|
|
func TestInstall_argsNotMutableByObserver(t *testing.T) {
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
|
|
var seenByRunE []string
|
|
leaf := &cobra.Command{
|
|
Use: "+echo",
|
|
RunE: func(_ *cobra.Command, args []string) error {
|
|
seenByRunE = append([]string(nil), args...)
|
|
return nil
|
|
},
|
|
}
|
|
root.AddCommand(leaf)
|
|
|
|
reg := hook.NewRegistry()
|
|
reg.AddObserver(hook.ObserverEntry{
|
|
Name: "tamper", When: platform.Before, Selector: platform.All(),
|
|
Fn: func(_ context.Context, inv platform.Invocation) {
|
|
got := inv.Args()
|
|
if len(got) > 0 {
|
|
got[0] = "HIJACKED"
|
|
}
|
|
},
|
|
})
|
|
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+echo"}})
|
|
|
|
originalArgs := []string{"hello", "world"}
|
|
if err := leaf.RunE(leaf, originalArgs); err != nil {
|
|
t.Fatalf("RunE returned %v", err)
|
|
}
|
|
if !equalStrings(seenByRunE, originalArgs) {
|
|
t.Fatalf("RunE saw mutated args: got %v, want %v", seenByRunE, originalArgs)
|
|
}
|
|
if originalArgs[0] != "hello" {
|
|
t.Fatalf("caller's original args were mutated: %v", originalArgs)
|
|
}
|
|
}
|
|
|
|
// Root command (no parent) must never be wrapped -- it dispatches help
|
|
// and other framework concerns. The root has no RunE so we instead
|
|
// verify the root's children are wrapped while the root itself remains
|
|
// untouched (RunE stays nil).
|
|
func TestInstall_rootStaysUntouched(t *testing.T) {
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
leaf := makeLeaf("+x")
|
|
root.AddCommand(leaf)
|
|
reg := hook.NewRegistry()
|
|
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
|
|
if root.RunE != nil {
|
|
t.Fatalf("root.RunE should remain nil after Install")
|
|
}
|
|
if leaf.RunE == nil {
|
|
t.Fatalf("child leaf.RunE must remain non-nil (wrapped)")
|
|
}
|
|
}
|
|
|
|
func equalStrings(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|