chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,723 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// These integration tests exercise the Hook framework's plumbing
|
||||
// (Plugin -> InstallAll -> Registry -> wireHooks -> RunE wrapper)
|
||||
// against a SYNTHETIC command tree, not the real lark-cli shortcut
|
||||
// tree. The synthetic tree keeps the test hermetic -- invoking real
|
||||
// shortcuts requires a fully-populated Factory (HTTP, credentials,
|
||||
// etc.) which is out of scope for a hook plumbing test.
|
||||
//
|
||||
// The e2e tests that go through Build() are kept thin (see
|
||||
// TestBuildInternal_appliesPolicyToRealTree in policy_test.go); they
|
||||
// assert plumbing existence (Hidden flag, etc.) without invoking
|
||||
// shortcuts.
|
||||
|
||||
type fakeIntegrationPlugin struct {
|
||||
name string
|
||||
caps platform.Capabilities
|
||||
rule *platform.Rule
|
||||
beforeCount int64
|
||||
afterCount int64
|
||||
wrapCount int64
|
||||
wrapDeniesWrite bool // when true, Wrap returns AbortError for risk=write
|
||||
shutdownCalled int64
|
||||
}
|
||||
|
||||
func (p *fakeIntegrationPlugin) Name() string { return p.name }
|
||||
func (p *fakeIntegrationPlugin) Version() string { return "0.0.1" }
|
||||
func (p *fakeIntegrationPlugin) Capabilities() platform.Capabilities { return p.caps }
|
||||
|
||||
func (p *fakeIntegrationPlugin) Install(r platform.Registrar) error {
|
||||
if p.caps.Restricts && p.rule != nil {
|
||||
r.Restrict(p.rule)
|
||||
}
|
||||
r.Observe(platform.Before, "audit-pre", platform.All(),
|
||||
func(context.Context, platform.Invocation) {
|
||||
atomic.AddInt64(&p.beforeCount, 1)
|
||||
})
|
||||
r.Observe(platform.After, "audit-post", platform.All(),
|
||||
func(context.Context, platform.Invocation) {
|
||||
atomic.AddInt64(&p.afterCount, 1)
|
||||
})
|
||||
r.Wrap("policy", platform.ByWrite(),
|
||||
func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
atomic.AddInt64(&p.wrapCount, 1)
|
||||
if p.wrapDeniesWrite {
|
||||
return &platform.AbortError{
|
||||
HookName: "policy",
|
||||
Reason: "writes blocked by integration test plugin",
|
||||
}
|
||||
}
|
||||
return next(ctx, inv)
|
||||
}
|
||||
})
|
||||
r.On(platform.Shutdown, "flush",
|
||||
func(context.Context, *platform.LifecycleContext) error {
|
||||
atomic.AddInt64(&p.shutdownCalled, 1)
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// syntheticTree builds a small command tree we own end-to-end. The leaf
|
||||
// has risk=write so the Wrap's ByWrite() selector matches.
|
||||
func syntheticTree() (*cobra.Command, *cobra.Command) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(group)
|
||||
leaf := &cobra.Command{
|
||||
Use: "+write",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
cmdutil.SetRisk(leaf, "write")
|
||||
group.AddCommand(leaf)
|
||||
return root, leaf
|
||||
}
|
||||
|
||||
// End-to-end through the public install pipeline: register a plugin,
|
||||
// run internalplatform.InstallAll (the same function buildInternal calls),
|
||||
// wire hooks onto a synthetic tree, invoke the leaf, and confirm
|
||||
// observers fired.
|
||||
func TestPluginPipeline_observersWired(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "audit-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
_ = leaf.RunE(leaf, nil)
|
||||
|
||||
if got := atomic.LoadInt64(&plugin.beforeCount); got != 1 {
|
||||
t.Errorf("Before observer fired %d times, want 1", got)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.afterCount); got != 1 {
|
||||
t.Errorf("After observer fired %d times, want 1", got)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.wrapCount); got != 1 {
|
||||
t.Errorf("Wrap fired %d times (ByWrite matches risk=write), want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A Wrapper returning AbortError on a write command must surface as
|
||||
// type="hook" in the envelope so the caller can parse the structured
|
||||
// rejection.
|
||||
func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "policy-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
wrapDeniesWrite: true,
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// The namespaced hook name and the abort semantics are preserved in the
|
||||
// message so a caller can identify which plugin hook rejected the call.
|
||||
if !strings.Contains(verr.Message, "policy-plugin.policy") {
|
||||
t.Errorf("message should name the aborting hook policy-plugin.policy, got %q", verr.Message)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "aborted") {
|
||||
t.Errorf("message should describe the abort, got %q", verr.Message)
|
||||
}
|
||||
|
||||
// errors.As must still reach the original AbortError so consumers
|
||||
// can inspect the typed cause.
|
||||
var ab *platform.AbortError
|
||||
if !errors.As(err, &ab) {
|
||||
t.Errorf("error chain should expose *platform.AbortError")
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin.Restrict() contribution must reach the pruning resolver and
|
||||
// take precedence over a yaml file (single-rule, plugin wins). This
|
||||
// goes through the REAL Build() pipeline so the wiring between
|
||||
// installPluginsAndHooks -> applyUserPolicyPruning -> cmdpolicy.Resolve
|
||||
// is covered.
|
||||
func TestPluginPipeline_restrictBeatsYaml(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
// yaml says allow everything; plugin says deny everything. Plugin
|
||||
// should win and a command should be denied.
|
||||
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"),
|
||||
[]byte("name: yaml-allow\nallow: [\"**\"]\n"), 0o644); err != nil {
|
||||
t.Fatalf("write yaml: %v", err)
|
||||
}
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "restricter",
|
||||
caps: platform.Capabilities{
|
||||
Restricts: true,
|
||||
FailurePolicy: platform.FailClosed,
|
||||
},
|
||||
rule: &platform.Rule{Name: "deny-all", Deny: []string{"**"}},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
root := Build(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
// At least one runnable command must end up Hidden because of the
|
||||
// plugin Restrict (yaml had been allow-all and would have left
|
||||
// everything visible).
|
||||
var foundHidden bool
|
||||
walk(root, func(c *cobra.Command) {
|
||||
if c.HasParent() && c.Runnable() && c.Hidden {
|
||||
foundHidden = true
|
||||
}
|
||||
})
|
||||
if !foundHidden {
|
||||
t.Fatalf("plugin Restrict should have denied at least one command despite yaml allow-all")
|
||||
}
|
||||
}
|
||||
|
||||
// Denial-guard end-to-end: register a plugin with a Wrap that would
|
||||
// SILENTLY suppress denial (return nil without calling next). After
|
||||
// installing pruning (which marks a command as denied) and wiring
|
||||
// hooks, calling the denied command must STILL produce the denial
|
||||
// error -- the Wrap must never run on the denied path.
|
||||
func TestPluginPipeline_denialGuardIntegrated(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
wrapCalled := false
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "policy-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
wrapDeniesWrite: false, // wrap would normally allow
|
||||
}
|
||||
// Override Wrap with a malicious behavior: return nil (silence the
|
||||
// denial). We do this by wrapping the install: register a
|
||||
// second Wrap that suppresses errors.
|
||||
platform.Register(plugin)
|
||||
|
||||
// Add another plugin with a malicious wrap.
|
||||
malicious := &mockMaliciousPlugin{
|
||||
name: "malicious",
|
||||
invokedFlag: &wrapCalled,
|
||||
}
|
||||
platform.Register(malicious)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
// Simulate cmdpolicy.Apply marking leaf as denied.
|
||||
leaf.Hidden = true
|
||||
leaf.DisableFlagParsing = true
|
||||
if leaf.Annotations == nil {
|
||||
leaf.Annotations = map[string]string{}
|
||||
}
|
||||
leaf.Annotations["lark:policy_denied_layer"] = "policy"
|
||||
leaf.Annotations["lark:policy_denied_source"] = "plugin:other"
|
||||
denyStubCalled := false
|
||||
leaf.RunE = func(*cobra.Command, []string) error {
|
||||
denyStubCalled = true
|
||||
return errors.New("CommandPruned (denyStub)")
|
||||
}
|
||||
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
if wrapCalled {
|
||||
t.Errorf("denial guard violated: malicious Wrap ran on a denied command")
|
||||
}
|
||||
if !denyStubCalled {
|
||||
t.Errorf("denyStub should run on the denial path even when a Wrap is registered")
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("denial error must propagate, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// mockMaliciousPlugin registers a Wrap that returns nil unconditionally
|
||||
// -- exactly the kind of plugin the denial guard defends against.
|
||||
type mockMaliciousPlugin struct {
|
||||
name string
|
||||
invokedFlag *bool
|
||||
}
|
||||
|
||||
func (p *mockMaliciousPlugin) Name() string { return p.name }
|
||||
func (p *mockMaliciousPlugin) Version() string { return "0.0.1" }
|
||||
func (p *mockMaliciousPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailOpen}
|
||||
}
|
||||
func (p *mockMaliciousPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("hijack", platform.All(),
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error {
|
||||
if p.invokedFlag != nil {
|
||||
*p.invokedFlag = true
|
||||
}
|
||||
return nil // silence everything
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verifies buildInternal returns a non-nil *hook.Registry when a plugin
|
||||
// is registered and Emit(Shutdown) on that registry fires the plugin's
|
||||
// On(Shutdown) handler. This is the contract Execute relies on to fire
|
||||
// Shutdown after rootCmd.Execute returns.
|
||||
func TestBuildInternal_returnsRegistryForShutdownEmit(t *testing.T) {
|
||||
tmpHome(t)
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "shutdown-test",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg == nil {
|
||||
t.Fatalf("buildInternal returned nil registry; plugin's Shutdown handler is unreachable")
|
||||
}
|
||||
|
||||
if err := hook.Emit(context.Background(), reg, platform.Shutdown, nil); err != nil {
|
||||
t.Fatalf("Emit(Shutdown): %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.shutdownCalled); got != 1 {
|
||||
t.Errorf("On(Shutdown) handler fired %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// When plugin install fails (FailClosed), buildInternal returns nil
|
||||
// registry. Execute must nil-check before calling Emit so we don't fault
|
||||
// on the FailClosed bypass-guard path.
|
||||
func TestBuildInternal_failClosedYieldsNilRegistry(t *testing.T) {
|
||||
tmpHome(t)
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
// A plugin that fails install and is FailClosed -> InstallAll
|
||||
// returns an error, buildInternal installs the guard and returns
|
||||
// early with nil registry.
|
||||
plugin := &failingPlugin{
|
||||
name: "fail-closed",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailClosed},
|
||||
err: errors.New("install failure simulated"),
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("buildInternal returned non-nil registry on FailClosed install error")
|
||||
}
|
||||
}
|
||||
|
||||
type failingPlugin struct {
|
||||
name string
|
||||
caps platform.Capabilities
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *failingPlugin) Name() string { return p.name }
|
||||
func (p *failingPlugin) Version() string { return "0.0.1" }
|
||||
func (p *failingPlugin) Capabilities() platform.Capabilities { return p.caps }
|
||||
func (p *failingPlugin) Install(platform.Registrar) error { return p.err }
|
||||
|
||||
// === Plugin Restrict conflict guard ===
|
||||
//
|
||||
// Two plugins both calling r.Restrict must surface as a structured
|
||||
// plugin_conflict envelope (reason_code multiple_restrict_plugins) at
|
||||
// dispatch time, NOT as a silent stderr warning. Otherwise a
|
||||
// safety-sensitive operator could miss that their policy never took
|
||||
// effect.
|
||||
func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
rule := &platform.Rule{Name: "any", Allow: []string{"**"}}
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "plugin-a",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: rule,
|
||||
})
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "plugin-b",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: rule,
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("conflict guard path should yield nil registry")
|
||||
}
|
||||
|
||||
// Pick any leaf and verify it returns the structured envelope.
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// reason_code multiple_restrict_plugins is folded into the hint so the
|
||||
// operator can distinguish a multi-Restrict conflict from a bad rule.
|
||||
if !strings.Contains(verr.Hint, "multiple_restrict_plugins") {
|
||||
t.Errorf("hint should surface reason_code multiple_restrict_plugins, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Single plugin with an invalid Rule must surface as plugin_install /
|
||||
// invalid_rule envelope (distinct error.type from multi-Restrict).
|
||||
func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
// MaxRisk "nukem" is rejected by ValidateRule -> Resolve returns
|
||||
// an error that is NOT ErrMultipleRestricts.
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "bad",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: &platform.Rule{Name: "bad", MaxRisk: "nukem"},
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("conflict guard path should yield nil registry")
|
||||
}
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// reason_code invalid_rule is folded into the hint, distinct from the
|
||||
// multiple_restrict_plugins conflict path.
|
||||
if !strings.Contains(verr.Hint, "invalid_rule") {
|
||||
t.Errorf("hint should surface reason_code invalid_rule, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// === Startup lifecycle guard ===
|
||||
//
|
||||
// Plugin On(Startup) handler returning error must abort startup with
|
||||
// a plugin_lifecycle envelope (reason_code lifecycle_failed). Silently
|
||||
// continuing would leave the plugin's invariants violated while the
|
||||
// rest of its hooks still fire.
|
||||
func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
platform.Register(&startupFailingPlugin{
|
||||
name: "lc",
|
||||
failErr: errors.New("backend unreachable"),
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("lifecycle guard path should yield nil registry")
|
||||
}
|
||||
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// reason_code lifecycle_failed (vs lifecycle_panic) and the failing
|
||||
// hook name are folded into the hint so audit / on-call can tell the
|
||||
// failure mode and which hook failed.
|
||||
if !strings.Contains(verr.Hint, "lifecycle_failed") {
|
||||
t.Errorf("hint should surface reason_code lifecycle_failed, got %q", verr.Hint)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "lc.start") {
|
||||
t.Errorf("hint should name the failing hook lc.start, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Same path but the handler panics -> reason_code lifecycle_panic.
|
||||
func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
platform.Register(&startupFailingPlugin{
|
||||
name: "lc",
|
||||
doPanic: true,
|
||||
panicMsg: "kaboom",
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("lifecycle guard path should yield nil registry")
|
||||
}
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// A panicking startup hook is distinguished from a returned error by
|
||||
// reason_code lifecycle_panic in the hint.
|
||||
if !strings.Contains(verr.Hint, "lifecycle_panic") {
|
||||
t.Errorf("hint should surface reason_code lifecycle_panic, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
type startupFailingPlugin struct {
|
||||
name string
|
||||
failErr error // when set, handler returns this
|
||||
doPanic bool // when true, handler panics with panicMsg
|
||||
panicMsg string
|
||||
}
|
||||
|
||||
func (p *startupFailingPlugin) Name() string { return p.name }
|
||||
func (p *startupFailingPlugin) Version() string { return "0.0.1" }
|
||||
func (p *startupFailingPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (p *startupFailingPlugin) Install(r platform.Registrar) error {
|
||||
r.On(platform.Startup, "start", func(context.Context, *platform.LifecycleContext) error {
|
||||
if p.doPanic {
|
||||
panic(p.panicMsg)
|
||||
}
|
||||
return p.failErr
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// === Wrapper panic recovery ===
|
||||
//
|
||||
// A Wrapper that panics must NOT crash the process. The framework
|
||||
// recovers and converts to a structured envelope:
|
||||
//
|
||||
// type="hook", reason_code="panic", hook_name=<namespaced>
|
||||
func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(&panickingWrapPlugin{name: "p"})
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Wrapper panic must be recovered, but it escaped: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// The recovered panic surfaces as a structured error naming the
|
||||
// namespaced hook (p.boom) and describing the panic, so the process
|
||||
// never crashes and the caller can attribute the failure.
|
||||
if !strings.Contains(verr.Message, "p.boom") {
|
||||
t.Errorf("message should name the namespaced hook p.boom, got %q", verr.Message)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "panic") {
|
||||
t.Errorf("message should describe the panic, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
type panickingWrapPlugin struct{ name string }
|
||||
|
||||
func (p *panickingWrapPlugin) Name() string { return p.name }
|
||||
func (p *panickingWrapPlugin) Version() string { return "0.0.1" }
|
||||
func (p *panickingWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
|
||||
func (p *panickingWrapPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("boom", platform.All(),
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error {
|
||||
panic("intentional panic for test")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// findRunnableLeaf walks the tree and returns the first command with a
|
||||
// RunE so tests can synthesize a dispatch without going through cobra.
|
||||
func findRunnableLeaf(c *cobra.Command) *cobra.Command {
|
||||
if c.RunE != nil && c.HasParent() {
|
||||
return c
|
||||
}
|
||||
for _, child := range c.Commands() {
|
||||
if l := findRunnableLeaf(child); l != nil {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// B2 regression: a plugin Wrapper whose FACTORY function (the
|
||||
// `func(next Handler) Handler` itself) panics must not crash the
|
||||
// process. The framework recovers and returns the same panic envelope
|
||||
// it produces for runtime panics inside the inner Handler.
|
||||
//
|
||||
// Pre-fix code path: recoverWrap had `inner := w(next)` outside the
|
||||
// deferred recover, so a factory panic escaped.
|
||||
func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(&factoryPanicWrapPlugin{name: "fac"})
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("factory panic must be recovered, but it escaped: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// A panic in the wrapper FACTORY (not just the inner handler) is
|
||||
// recovered into the same structured panic error, naming the
|
||||
// namespaced hook fac.bad-factory.
|
||||
if !strings.Contains(verr.Message, "fac.bad-factory") {
|
||||
t.Errorf("message should name the namespaced hook fac.bad-factory, got %q", verr.Message)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "panic") {
|
||||
t.Errorf("message should describe the panic, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
type factoryPanicWrapPlugin struct{ name string }
|
||||
|
||||
func (p *factoryPanicWrapPlugin) Name() string { return p.name }
|
||||
func (p *factoryPanicWrapPlugin) Version() string { return "0.0.1" }
|
||||
func (p *factoryPanicWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
|
||||
func (p *factoryPanicWrapPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("bad-factory", platform.All(),
|
||||
// The factory itself panics; the returned Handler is never reached.
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
panic("factory blew up")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user