mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
206 lines
7.8 KiB
Go
206 lines
7.8 KiB
Go
// 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 <X>" 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)
|
|
// Note: reason_code mixed_children_policy is intentionally NOT covered here.
|
|
// It requires a parent command whose *enumerated children* have mixed
|
|
// allow/deny outcomes, which needs the full command tree from API metadata.
|
|
// This L4 harness builds a bare-module fork (embedded stub only), so offline
|
|
// a parent like "sheets" has no known children and collapses to
|
|
// domain_not_allowed -- identical to the "leaf out of allow list" case and
|
|
// not a distinct reason_code. Covered instead by the in-process cmdpolicy
|
|
// unit tests, which construct a mixed-children tree directly.
|
|
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"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assertReasonCodeEnvelope(t, run(t, bin, tc.args...), 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.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())
|
|
}
|
|
`
|
|
|
|
// assertReasonCodeEnvelope asserts the VERIFIED envelope shape shared by every
|
|
// reason_code across this package -- both policy denials (this file) and
|
|
// install-time failures (install_test.go): exit 2, valid JSON on stderr,
|
|
// error.type=="validation", error.subtype=="failed_precondition", and
|
|
// error.hint containing "reason_code <wantReasonCode>". Both paths render
|
|
// through the SAME cmd/platform_guards.go WithHint(...) family, embedding
|
|
// reason_code in the hint STRING, not a structured error.detail.reason_code
|
|
// field (contradicting internal/platform/error.go:34's comment).
|
|
func assertReasonCodeEnvelope(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)
|
|
assertReasonCodeEnvelope(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)
|
|
assertReasonCodeEnvelope(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)
|
|
assertReasonCodeEnvelope(t, res, "no_matching_rule")
|
|
}
|