mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
292 lines
10 KiB
Go
292 lines
10 KiB
Go
// 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] <path>" to stderr. Based on (a simplified form of) 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":"command_unavailable",
|
|
// "message":"command not included in this build"}}
|
|
//
|
|
// 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 := buildConcealedFork(t, "concealed-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)
|
|
}
|
|
assertUnavailableJSON(t, envelope)
|
|
}
|
|
|
|
// 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. The assertion is
|
|
// baseline-relative -- the panicking-observer fork's exit code must equal the
|
|
// noop-observer baseline fork's for the same `schema` command (a local,
|
|
// network-free, read-risk command), whatever that shared exit code is.
|
|
// Observed real output at pin time:
|
|
//
|
|
// panicking: exit=0 stderr=warning: hook "observer-panic.log" panicked: boom
|
|
// baseline: exit=0 stderr=(empty)
|
|
//
|
|
// 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 "<plugin-name>.<hookName>" ("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)
|
|
}
|
|
}
|