From 71c4c1fda363045f65856d693191112596db135f Mon Sep 17 00:00:00 2001 From: "zhaojunlin.0405" Date: Wed, 8 Jul 2026 11:27:59 +0800 Subject: [PATCH] test: cover restrict denial, allow-path and diagnostics in plugin_e2e --- tests/plugin_e2e/diagnostics_test.go | 39 +++++++++++++ tests/plugin_e2e/restrict_test.go | 84 ++++++++++++++++++++++++++++ tests/plugin_e2e/smoke_test.go | 1 + 3 files changed, 124 insertions(+) create mode 100644 tests/plugin_e2e/diagnostics_test.go create mode 100644 tests/plugin_e2e/restrict_test.go diff --git a/tests/plugin_e2e/diagnostics_test.go b/tests/plugin_e2e/diagnostics_test.go new file mode 100644 index 000000000..a0cf25d57 --- /dev/null +++ b/tests/plugin_e2e/diagnostics_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package plugin_e2e + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +// TestDiagnostics asserts the VERIFIED stdout shapes of the two policy/plugin +// diagnostic commands on a fork carrying the readonly Restrict rule: +// - `config policy show`: source_name == the plugin name that installed the +// active rule. +// - `config plugins show`: {"plugins":[{"name","version","capabilities",..., +// "hooks":{...}}],"total":N} with the readonly plugin present. +func TestDiagnostics(t *testing.T) { + bin := buildFork(t, "readonly", readonlyPlugin) + + pol := run(t, bin, "config", "policy", "show") + if pol.exit != 0 || !gjson.Valid(pol.stdout) { + t.Fatalf("policy show exit=%d stdout=%s stderr=%s", pol.exit, pol.stdout, pol.stderr) + } + if src := gjson.Get(pol.stdout, "source_name").String(); src != "readonly" { + t.Errorf("policy source_name=%q want readonly (stdout=%s)", src, pol.stdout) + } + + plug := run(t, bin, "config", "plugins", "show") + if plug.exit != 0 || !gjson.Valid(plug.stdout) { + t.Fatalf("plugins show exit=%d stdout=%s", plug.exit, plug.stdout) + } + if total := gjson.Get(plug.stdout, "total").Int(); total < 1 { + t.Errorf("plugins total=%d want >=1 (stdout=%s)", total, plug.stdout) + } + if name := gjson.Get(plug.stdout, "plugins.0.name").String(); name != "readonly" { + t.Errorf("plugins.0.name=%q want readonly", name) + } +} diff --git a/tests/plugin_e2e/restrict_test.go b/tests/plugin_e2e/restrict_test.go new file mode 100644 index 000000000..a70b7ec72 --- /dev/null +++ b/tests/plugin_e2e/restrict_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package plugin_e2e + +import ( + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +// readonlyPlugin registers a Restrict rule that only allows read-risk +// commands under the docs/** and im/** domains. It mirrors the official +// example readonly-policy configuration. +const readonlyPlugin = `// Code generated by plugin_e2e; DO NOT EDIT. +package plugin + +import "github.com/larksuite/cli/extension/platform" + +func init() { + platform.Register( + platform.NewPlugin("readonly", "0.1.0"). + Restrict(&platform.Rule{ + Name: "agent-readonly", + Allow: []string{"docs/**", "im/**"}, + MaxRisk: platform.RiskRead, + }). + MustBuild()) +} +` + +// TestReadonlyDenial asserts the VERIFIED denial envelope shape: stderr is +// valid JSON, error.type=="validation", error.subtype=="failed_precondition", +// error.hint contains the literal "reason_code " substring, and the +// process exits 2. reason_code lives only in the hint string, not a +// structured field. +func TestReadonlyDenial(t *testing.T) { + bin := buildFork(t, "readonly", readonlyPlugin) + cases := []struct { + name string + args []string + reasonCode string + }{ + {"write in allowed domain", []string{"docs", "+update", "--doc-token", "x", "--content", "y"}, "write_not_allowed"}, + {"leaf out of allow list", []string{"schema"}, "domain_not_allowed"}, + {"parent group all children denied", []string{"sheets"}, "mixed_children_policy"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := run(t, bin, tc.args...) + 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 "+tc.reasonCode) { + t.Errorf("hint=%q want to contain reason_code %s", hint, tc.reasonCode) + } + }) + } +} + +// TestReadonlyAllows asserts the allow-path: a read command inside an +// allowed domain must NOT be denied by the policy gate. It may still fail +// downstream (e.g. api/auth error), but that failure must not carry the +// denial envelope shape and must not exit 2. +func TestReadonlyAllows(t *testing.T) { + bin := buildFork(t, "readonly", readonlyPlugin) + res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent") + 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" { + t.Errorf("read command produced a denial envelope; stderr=%s", res.stderr) + } +} diff --git a/tests/plugin_e2e/smoke_test.go b/tests/plugin_e2e/smoke_test.go index 3c8a108e2..01c9c6f64 100644 --- a/tests/plugin_e2e/smoke_test.go +++ b/tests/plugin_e2e/smoke_test.go @@ -13,6 +13,7 @@ import ( // any fork build runs. It lives here (not in harness.go) because `go test` // only discovers TestMain in a _test.go file — a TestMain defined in a plain // .go file is silently never invoked. +// NOTE: exactly one TestMain is allowed per package — do not add another in other _test.go files here. func TestMain(m *testing.M) { root, err := repoRoot() if err != nil {