From 9ef612bc4a2a909c44129686efc5cc18014e7a97 Mon Sep 17 00:00:00 2001 From: "zhaojunlin.0405" Date: Wed, 8 Jul 2026 13:04:03 +0800 Subject: [PATCH] test: cover identity/denylist/multi-rule denial, observe and wrap in plugin_e2e --- tests/plugin_e2e/observe_wrap_test.go | 297 ++++++++++++++++++++++++++ tests/plugin_e2e/restrict_test.go | 127 ++++++++++- 2 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 tests/plugin_e2e/observe_wrap_test.go diff --git a/tests/plugin_e2e/observe_wrap_test.go b/tests/plugin_e2e/observe_wrap_test.go new file mode 100644 index 000000000..46386fb8b --- /dev/null +++ b/tests/plugin_e2e/observe_wrap_test.go @@ -0,0 +1,297 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package plugin_e2e + +import ( + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +// auditPlugin registers a single After observer matching every command that +// logs "[audit] " to stderr. Mirrors the shipped +// extension/platform/examples/audit-observer example. +const auditPlugin = `// Code generated by plugin_e2e; DO NOT EDIT. +package plugin + +import ( + "context" + "fmt" + "os" + + "github.com/larksuite/cli/extension/platform" +) + +func init() { + platform.Register( + platform.NewPlugin("audit", "0.1.0"). + Observer(platform.After, "log", platform.All(), + func(_ context.Context, inv platform.Invocation) { + fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path()) + }). + FailOpen(). + MustBuild()) +} +` + +// TestObservePin pins the audit observer's stderr line format. Observed +// real output (docs +fetch --doc nonexistent, a real read-risk command that +// fails downstream with an API error unrelated to the plugin): +// +// exit=1 +// stderr=[audit] docs/+fetch +// {"ok":false,"identity":"user","error":{"type":"api","subtype":"unknown",...}} +// +// The observer line always leads, on its own line, before whatever the +// command itself writes to stderr. +func TestObservePin(t *testing.T) { + bin := buildFork(t, "audit", auditPlugin) + res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent") + if !strings.Contains(res.stderr, "[audit] docs/+fetch\n") { + t.Fatalf("stderr missing audit line; stderr=%s", res.stderr) + } +} + +// auditRestrictPlugin combines an After observer with a Restrict rule in one +// plugin, so a denied command's stderr carries both the observer's +// side-effect and the denial envelope: the framework's contract is that +// After observers fire even for denied commands (see +// extension/platform/invocation.go's DeniedByPolicy doc). +const auditRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT. +package plugin + +import ( + "context" + "fmt" + "os" + + "github.com/larksuite/cli/extension/platform" +) + +func init() { + platform.Register( + platform.NewPlugin("audit-restrict", "0.1.0"). + Observer(platform.After, "log", platform.All(), + func(_ context.Context, inv platform.Invocation) { + fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path()) + }). + Restrict(&platform.Rule{ + Name: "agent-readonly", + Allow: []string{"docs/**", "im/**"}, + MaxRisk: platform.RiskRead, + }). + MustBuild()) +} +` + +// TestObserveOnDeniedPin pins the audit-contract case: a denied command's +// stderr carries BOTH the observer's audit line AND the denial envelope, +// concatenated in a single stream, audit line first. Observed real output +// (docs +update --doc-token x --content y, denied write_not_allowed): +// +// exit=2 +// stderr=[audit] docs/+update +// {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}} +// +// The leading "[audit] ..." line means gjson.Valid on the raw stderr is +// false; the JSON envelope must be sliced out from the first '{' before +// parsing it as JSON. +func TestObserveOnDeniedPin(t *testing.T) { + bin := buildFork(t, "audit-restrict", auditRestrictPlugin) + res := run(t, bin, "docs", "+update", "--doc-token", "x", "--content", "y") + if res.exit != 2 { + t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr) + } + if !strings.Contains(res.stderr, "[audit] docs/+update\n") { + t.Fatalf("stderr missing audit line on a denied command; stderr=%s", res.stderr) + } + i := strings.Index(res.stderr, "{") + if i < 0 { + t.Fatalf("stderr has no JSON envelope after the audit line; stderr=%s", res.stderr) + } + envelope := res.stderr[i:] + if !gjson.Valid(envelope) { + t.Fatalf("sliced envelope not JSON: %s", envelope) + } + if got := gjson.Get(envelope, "error.type").String(); got != "validation" { + t.Errorf("error.type=%q want validation", got) + } + if got := gjson.Get(envelope, "error.subtype").String(); got != "failed_precondition" { + t.Errorf("error.subtype=%q want failed_precondition", got) + } + if hint := gjson.Get(envelope, "error.hint").String(); !strings.Contains(hint, "reason_code write_not_allowed") { + t.Errorf("hint=%q want to contain reason_code write_not_allowed", hint) + } +} + +// observerPanicPlugin's After observer panics unconditionally. runObserverSafe +// (internal/hook/install.go) must isolate the panic so command dispatch +// still completes normally. +const observerPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT. +package plugin + +import ( + "context" + + "github.com/larksuite/cli/extension/platform" +) + +func init() { + platform.Register( + platform.NewPlugin("observer-panic", "0.1.0"). + Observer(platform.After, "log", platform.All(), + func(_ context.Context, _ platform.Invocation) { + panic("boom") + }). + FailOpen(). + MustBuild()) +} +` + +// TestObserverPanicIsolationPin pins panic isolation: an After observer that +// always panics must not affect the command's own outcome. Observed real +// output for `schema` (a local, network-free, read-risk command) under the +// panicking-observer fork vs. the noop-observer baseline fork: +// +// panicking: exit=0 stderr=warning: hook "observer-panic.log" panicked: boom +// baseline: exit=0 stderr=(empty) +// +// Both exit 0 identically; the panic is fully swallowed by +// runObserverSafe (internal/hook/install.go), surfacing only as a stderr +// warning line, never as a non-zero exit or crash. +func TestObserverPanicIsolationPin(t *testing.T) { + bin := buildFork(t, "observer-panic", observerPanicPlugin) + res := run(t, bin, "schema") + + baselineBin := buildFork(t, "smoke", noopPlugin) + baseline := run(t, baselineBin, "schema") + + if res.exit != baseline.exit { + t.Fatalf("panicking-observer exit=%d differs from baseline exit=%d; stderr=%s", res.exit, baseline.exit, res.stderr) + } + if !strings.Contains(res.stderr, `warning: hook "observer-panic.log" panicked: boom`) { + t.Errorf("stderr missing panic-isolation warning; stderr=%s", res.stderr) + } +} + +// wrapAbortPlugin's Wrapper short-circuits every command with an AbortError +// instead of calling next. +const wrapAbortPlugin = `// Code generated by plugin_e2e; DO NOT EDIT. +package plugin + +import ( + "context" + + "github.com/larksuite/cli/extension/platform" +) + +func init() { + platform.Register( + platform.NewPlugin("wrap-abort", "0.1.0"). + Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler { + return func(ctx context.Context, inv platform.Invocation) error { + return &platform.AbortError{ + HookName: "guard", + Reason: "blocked for test", + } + } + }). + FailOpen(). + MustBuild()) +} +` + +// TestWrapAbortPin pins the wrap-abort envelope shape. An *AbortError +// returned by a Wrapper is converted by wrapAbortError +// (internal/hook/install.go) into the SAME envelope shape as a Restrict +// denial -- error.type=="validation", error.subtype=="failed_precondition" +// -- NOT a distinct "hook" error type. Observed real output (docs +fetch +// --doc nonexistent, wrapper aborts unconditionally before calling next): +// +// exit=2 +// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition", +// "message":"hook \"wrap-abort.guard\" aborted: blocked for test", +// "hint":"plugin hook \"wrap-abort.guard\" aborted this command; adjust the +// request to satisfy the hook's policy, or remove the plugin"}} +// +// HookName is namespaced to "." ("wrap-abort.guard") +// regardless of the HookName the plugin set on the AbortError itself +// (namespacedWrap overwrites it) -- see internal/hook/install.go. +func TestWrapAbortPin(t *testing.T) { + bin := buildFork(t, "wrap-abort", wrapAbortPlugin) + res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent") + if res.exit != 2 { + t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr) + } + if !gjson.Valid(res.stderr) { + t.Fatalf("stderr not JSON: %s", res.stderr) + } + if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" { + t.Errorf("error.type=%q want validation", got) + } + if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" { + t.Errorf("error.subtype=%q want failed_precondition", got) + } + if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-abort.guard" aborted: blocked for test`) { + t.Errorf("error.message=%q want to contain the namespaced hook name and Reason", msg) + } + if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-abort.guard" aborted this command`) { + t.Errorf("error.hint=%q want to contain the abort hint", hint) + } +} + +// wrapPanicPlugin's Wrapper factory panics on every invocation (the factory +// closure itself, not the returned Handler). +const wrapPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT. +package plugin + +import ( + "github.com/larksuite/cli/extension/platform" +) + +func init() { + platform.Register( + platform.NewPlugin("wrap-panic", "0.1.0"). + Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler { + panic("wrap boom") + }). + FailOpen(). + MustBuild()) +} +` + +// TestWrapPanicPin pins the wrap-panic envelope shape: a panicking Wrapper +// factory does not crash the process. recoverWrap (internal/hook/install.go) +// converts the panic into the same validation/failed_precondition shape as +// wrap-abort, with a distinct message/hint pair. Observed real output (docs +// +fetch --doc nonexistent, wrapper factory panics unconditionally): +// +// exit=2 +// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition", +// "message":"hook \"wrap-panic.guard\" panicked: wrap boom", +// "hint":"plugin hook \"wrap-panic.guard\" crashed while handling this +// command; report the panic to the plugin author or remove the plugin"}} +func TestWrapPanicPin(t *testing.T) { + bin := buildFork(t, "wrap-panic", wrapPanicPlugin) + res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent") + if res.exit != 2 { + t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr) + } + if !gjson.Valid(res.stderr) { + t.Fatalf("stderr not JSON: %s", res.stderr) + } + if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" { + t.Errorf("error.type=%q want validation", got) + } + if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" { + t.Errorf("error.subtype=%q want failed_precondition", got) + } + if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-panic.guard" panicked: wrap boom`) { + t.Errorf("error.message=%q want to contain the namespaced hook name and panic value", msg) + } + if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-panic.guard" crashed while handling this command`) { + t.Errorf("error.hint=%q want to contain the panic hint", hint) + } +} diff --git a/tests/plugin_e2e/restrict_test.go b/tests/plugin_e2e/restrict_test.go index a70b7ec72..d1897d54b 100644 --- a/tests/plugin_e2e/restrict_test.go +++ b/tests/plugin_e2e/restrict_test.go @@ -78,7 +78,132 @@ func TestReadonlyAllows(t *testing.T) { if res.exit == 2 { t.Fatalf("read command was denied (exit=2); stderr=%s", res.stderr) } - if gjson.Get(res.stderr, "error.subtype").String() == "failed_precondition" { + if gjson.Valid(res.stderr) && gjson.Get(res.stderr, "error.subtype").String() == "failed_precondition" { t.Errorf("read command produced a denial envelope; stderr=%s", res.stderr) } } + +// identityPlugin registers a Restrict rule scoped to bot identities only. +// im +messages-search declares AuthTypes:["user"] (see +// shortcuts/im/im_messages_search.go), so it has no intersection with the +// rule's bot-only whitelist regardless of which --as value the caller +// passes: platform.Rule.Identities is checked against the command's own +// static supported-identities annotation, not the runtime --as flag. +const identityPlugin = `// Code generated by plugin_e2e; DO NOT EDIT. +package plugin + +import "github.com/larksuite/cli/extension/platform" + +func init() { + platform.Register( + platform.NewPlugin("identity-restrict", "0.1.0"). + Restrict(&platform.Rule{ + Name: "bot-only", + Allow: []string{"im/**"}, + MaxRisk: platform.RiskRead, + Identities: []platform.Identity{platform.IdentityBot}, + }). + MustBuild()) +} +` + +// denylistPlugin registers a Restrict rule that allows the docs/** domain +// but explicitly denies docs/+search (a real read-risk leaf, see +// shortcuts/doc/docs_search.go). Deny has priority over Allow, so the +// command is rejected before MaxRisk is even consulted. +const denylistPlugin = `// Code generated by plugin_e2e; DO NOT EDIT. +package plugin + +import "github.com/larksuite/cli/extension/platform" + +func init() { + platform.Register( + platform.NewPlugin("denylist-restrict", "0.1.0"). + Restrict(&platform.Rule{ + Name: "deny-search", + Allow: []string{"docs/**"}, + Deny: []string{"docs/+search"}, + MaxRisk: platform.RiskRead, + }). + MustBuild()) +} +` + +// multiRulePlugin registers two scope-exclusive Restrict rules (im-only, +// docs-only). A command outside both domains (e.g. the top-level "schema" +// command, itself read-risk and already proven to hit domain_not_allowed +// under a single Allow:["docs/**","im/**"] rule in TestReadonlyDenial) is +// rejected by both rules, so cmdpolicy's OR-engine collapses the two +// per-rule denials into the aggregate reason_code "no_matching_rule". +const multiRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT. +package plugin + +import "github.com/larksuite/cli/extension/platform" + +func init() { + platform.Register( + platform.NewPlugin("multi-rule-restrict", "0.1.0"). + Restrict(&platform.Rule{ + Name: "im-only", + Allow: []string{"im/**"}, + MaxRisk: platform.RiskRead, + }). + Restrict(&platform.Rule{ + Name: "docs-only", + Allow: []string{"docs/**"}, + MaxRisk: platform.RiskRead, + }). + MustBuild()) +} +` + +// assertDenialEnvelope asserts the VERIFIED denial envelope shape shared by +// every reason_code in this file: exit 2, valid JSON on stderr, +// error.type=="validation", error.subtype=="failed_precondition", and +// error.hint containing "reason_code ". +func assertDenialEnvelope(t *testing.T, res result, wantReasonCode string) { + t.Helper() + if res.exit != 2 { + t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr) + } + if !gjson.Valid(res.stderr) { + t.Fatalf("stderr not JSON: %s", res.stderr) + } + if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" { + t.Errorf("error.type=%q want validation", got) + } + if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" { + t.Errorf("error.subtype=%q want failed_precondition", got) + } + if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "reason_code "+wantReasonCode) { + t.Errorf("hint=%q want to contain reason_code %s", hint, wantReasonCode) + } +} + +// TestIdentityMismatchDenial pins reason_code=identity_mismatch: a bot-only +// rule rejects a command whose declared AuthTypes don't include "bot". +func TestIdentityMismatchDenial(t *testing.T) { + bin := buildFork(t, "identity", identityPlugin) + res := run(t, bin, "im", "+messages-search", "--as", "user") + t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr) + assertDenialEnvelope(t, res, "identity_mismatch") +} + +// TestDenylistDenial pins reason_code=command_denylisted: a Deny glob hit +// rejects the command even though it also matches Allow. +func TestDenylistDenial(t *testing.T) { + bin := buildFork(t, "denylist", denylistPlugin) + res := run(t, bin, "docs", "+search") + t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr) + assertDenialEnvelope(t, res, "command_denylisted") +} + +// TestMultiRuleDenial pins reason_code=no_matching_rule: a command rejected +// by every rule in a multi-Restrict() plugin gets the aggregate reason_code, +// not either rule's own per-rule reason_code. +func TestMultiRuleDenial(t *testing.T) { + bin := buildFork(t, "multirule", multiRulePlugin) + res := run(t, bin, "schema") + t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr) + assertDenialEnvelope(t, res, "no_matching_rule") +}