mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
544 lines
21 KiB
Go
544 lines
21 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package plugin_e2e
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestExistingRestrictPluginKeepsLegacyEnvelopeWithoutHostOptIn(t *testing.T) {
|
|
bin := buildFork(t, "readonly", readonlyPlugin)
|
|
res := run(t, bin, "schema")
|
|
if res.exit != 2 || !gjson.Valid(res.stderr) {
|
|
t.Fatalf("legacy Restrict exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
}
|
|
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
|
|
t.Errorf("legacy subtype=%q want failed_precondition; stderr=%s", got, res.stderr)
|
|
}
|
|
hint := gjson.Get(res.stderr, "error.hint").String()
|
|
for _, want := range []string{"source plugin:readonly", "reason_code"} {
|
|
if !strings.Contains(hint, want) {
|
|
t.Errorf("legacy Restrict hint missing %q: %q", want, hint)
|
|
}
|
|
}
|
|
if strings.Contains(res.stderr, "command not included in this build") {
|
|
t.Errorf("legacy Restrict was implicitly concealed: %s", res.stderr)
|
|
}
|
|
}
|
|
|
|
func TestLegacyRestrictReasonCodesRemainVisible(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
fork string
|
|
plugin string
|
|
args []string
|
|
reasonCode string
|
|
}{
|
|
{"allow list", "readonly", readonlyPlugin, []string{"schema"}, "domain_not_allowed"},
|
|
{"identity", "identity", identityPlugin, []string{"im", "+flag-list", "--as", "user"}, "identity_mismatch"},
|
|
{"deny list", "denylist", denylistPlugin, []string{"docs", "+search"}, "command_denylisted"},
|
|
{"multiple rules", "multirule", multiRulePlugin, []string{"schema"}, "no_matching_rule"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
res := run(t, buildFork(t, "legacy-"+tt.fork, tt.plugin), tt.args...)
|
|
if res.exit != 2 || !gjson.Valid(res.stderr) {
|
|
t.Fatalf("legacy denial exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
}
|
|
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
|
|
t.Fatalf("subtype=%q want failed_precondition; stderr=%s", got, res.stderr)
|
|
}
|
|
hint := gjson.Get(res.stderr, "error.hint").String()
|
|
if !strings.Contains(hint, "reason_code "+tt.reasonCode) {
|
|
t.Errorf("hint missing reason_code %s: %q", tt.reasonCode, hint)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 public plugin-restriction envelope: stderr is
|
|
// valid JSON, error.type=="validation", error.subtype=="command_unavailable",
|
|
// the default integrator message is present, no policy diagnostics leak, and
|
|
// the process exits 2.
|
|
func TestReadonlyDenial(t *testing.T) {
|
|
bin := buildConcealedFork(t, "concealed-readonly", readonlyPlugin)
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{"write in allowed domain", []string{"docs", "+update", "--doc-token", "x", "--content", "y"}},
|
|
{"leaf out of allow list", []string{"schema"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assertUnavailableEnvelope(t, run(t, bin, tc.args...))
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 := buildConcealedFork(t, "concealed-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() == "command_unavailable" {
|
|
t.Errorf("read command produced a denial envelope; stderr=%s", res.stderr)
|
|
}
|
|
}
|
|
|
|
func TestConcealedForkProjectsFrameworkOwnedRootHelp(t *testing.T) {
|
|
bin := buildConcealedFork(t, "concealed-readonly", readonlyPlugin)
|
|
res := run(t, bin, "--help")
|
|
if res.exit != 0 {
|
|
t.Fatalf("--help exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
}
|
|
for _, dead := range []string{
|
|
"lark-cli api ",
|
|
"lark-cli schema ",
|
|
"lark-cli calendar +agenda",
|
|
"lark-cli mail user_mailbox.messages list",
|
|
} {
|
|
if strings.Contains(res.stdout, dead) {
|
|
t.Errorf("concealed fork root help retained %q:\n%s", dead, res.stdout)
|
|
}
|
|
}
|
|
if !strings.Contains(res.stdout, "Browse commands:") {
|
|
t.Fatalf("target-independent root guidance disappeared:\n%s", res.stdout)
|
|
}
|
|
if strings.Contains(res.stdout, "EXAMPLES (one per command style") {
|
|
t.Fatalf("empty root examples section survived projection:\n%s", res.stdout)
|
|
}
|
|
}
|
|
|
|
func TestConcealedForkProjectsSchemaFromGeneratedMethodHelp(t *testing.T) {
|
|
bin := buildConcealedFork(t, "concealed-readonly", readonlyPlugin)
|
|
assertUnavailableEnvelope(t, run(t, bin, "schema"))
|
|
rootHelp := run(t, bin, "--help")
|
|
if rootHelp.exit != 0 || strings.Contains(rootHelp.stdout, "lark-cli schema ") {
|
|
t.Fatalf("root help did not project schema: exit=%d stdout=%s stderr=%s",
|
|
rootHelp.exit, rootHelp.stdout, rootHelp.stderr)
|
|
}
|
|
res := run(t, bin, "im", "chats", "get", "--help")
|
|
if res.exit != 0 {
|
|
t.Fatalf("generated method --help exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
}
|
|
if strings.Contains(res.stdout, "lark-cli schema") ||
|
|
strings.Contains(res.stdout, "Full parameter schema:") {
|
|
t.Fatalf("concealed schema left a generated-method dead pointer:\n%s", res.stdout)
|
|
}
|
|
for _, want := range []string{"Obtain group information", "--chat-id"} {
|
|
if !strings.Contains(res.stdout, want) {
|
|
t.Errorf("schema projection removed generated-method help %q:\n%s", want, res.stdout)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestConcealedForkProjectsRetainedSchemaCatalog pins the cross-surface case
|
|
// that a schema command retained by the distribution must not enumerate a
|
|
// generated service subtree concealed by the same build. This differs from
|
|
// TestConcealedForkProjectsSchemaFromGeneratedMethodHelp: that test conceals
|
|
// schema itself, while this one keeps schema executable and conceals only
|
|
// mail/**.
|
|
func TestConcealedForkProjectsRetainedSchemaCatalog(t *testing.T) {
|
|
bin := buildConcealedFork(t, "concealed-schema-mail", schemaMailConcealPlugin)
|
|
|
|
hidden := run(t, bin, "schema", "mail.user_mailbox.messages.get")
|
|
if hidden.exit != 2 || !gjson.Valid(hidden.stderr) {
|
|
t.Fatalf("concealed schema lookup exit=%d stdout=%s stderr=%s", hidden.exit, hidden.stdout, hidden.stderr)
|
|
}
|
|
if got := gjson.Get(hidden.stderr, "error.subtype").String(); got != "invalid_argument" {
|
|
t.Errorf("concealed schema subtype=%q want invalid_argument; stderr=%s", got, hidden.stderr)
|
|
}
|
|
if strings.Contains(hidden.stdout+hidden.stderr, "Get Email Details") {
|
|
t.Errorf("concealed exact lookup exposed method metadata: stdout=%s stderr=%s", hidden.stdout, hidden.stderr)
|
|
}
|
|
|
|
broad := run(t, bin, "schema")
|
|
if broad.exit != 0 || !gjson.Valid(broad.stdout) {
|
|
t.Fatalf("broad schema exit=%d stdout=%s stderr=%s", broad.exit, broad.stdout, broad.stderr)
|
|
}
|
|
if strings.Contains(broad.stdout, "mail user_mailbox.messages get") || strings.Contains(broad.stdout, "Get Email Details") {
|
|
t.Errorf("broad schema exposed concealed mail method: %s", broad.stdout)
|
|
}
|
|
if !strings.Contains(broad.stdout, "im chats get") {
|
|
t.Errorf("broad schema lost visible im method: %s", broad.stdout)
|
|
}
|
|
|
|
visible := run(t, bin, "schema", "im.chats.get")
|
|
if visible.exit != 0 || !strings.Contains(visible.stdout, "im chats get") {
|
|
t.Fatalf("visible schema lookup exit=%d stdout=%s stderr=%s", visible.exit, visible.stdout, visible.stderr)
|
|
}
|
|
|
|
completionCases := []struct {
|
|
name string
|
|
args []string
|
|
concealed string
|
|
visible string
|
|
}{
|
|
{
|
|
name: "dotted service",
|
|
args: []string{"__complete", "schema", ""},
|
|
concealed: "mail.",
|
|
visible: "im.",
|
|
},
|
|
{
|
|
name: "dotted descendant",
|
|
args: []string{"__complete", "schema", "mail."},
|
|
concealed: "mail.user_mailbox.messages.",
|
|
},
|
|
{
|
|
name: "space descendant",
|
|
args: []string{"__complete", "schema", "mail", ""},
|
|
concealed: "user_mailbox.messages",
|
|
},
|
|
{
|
|
name: "visible dotted descendant",
|
|
args: []string{"__complete", "schema", "im."},
|
|
visible: "im.chats.",
|
|
},
|
|
{
|
|
name: "visible space descendant",
|
|
args: []string{"__complete", "schema", "im", ""},
|
|
visible: "chats",
|
|
},
|
|
}
|
|
for _, tc := range completionCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
res := run(t, bin, tc.args...)
|
|
if res.exit != 0 {
|
|
t.Fatalf("completion exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
}
|
|
if tc.concealed != "" && strings.Contains(res.stdout, tc.concealed) {
|
|
t.Errorf("completion exposed concealed candidate %q: %s", tc.concealed, res.stdout)
|
|
}
|
|
if tc.visible != "" && !strings.Contains(res.stdout, tc.visible) {
|
|
t.Errorf("completion lost visible candidate %q: %s", tc.visible, res.stdout)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConcealedForkHelpRejectsDescendantOfConcealedParent(t *testing.T) {
|
|
bin := buildConcealedFork(t, "concealed-readonly", readonlyPlugin)
|
|
assertUnavailableEnvelope(t, run(t, bin, "help", "auth", "login"))
|
|
}
|
|
|
|
func TestConcealedForkUsesTargetFreeAuthorizationFallback(t *testing.T) {
|
|
bin := buildConcealedFork(t, "concealed-drive", driveOnlyPlugin)
|
|
configDir := t.TempDir()
|
|
writeFile(t, filepath.Join(configDir, "config.json"),
|
|
`{"apps":[{"appId":"cli_plugin_e2e","appSecret":"secret","brand":"feishu","users":[]}]}`)
|
|
env := append(baseEnv(),
|
|
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
|
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
|
"LARKSUITE_CLI_CONFIG_DIR="+configDir,
|
|
)
|
|
res := runWithEnv(t, bin, env, "drive", "+search", "--as", "user")
|
|
if res.exit != 3 || !gjson.Valid(res.stderr) {
|
|
t.Fatalf("drive +search --as user exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
}
|
|
hint := gjson.Get(res.stderr, "error.hint").String()
|
|
if strings.Contains(hint, "auth login") ||
|
|
!strings.Contains(hint, "supported authorization flow") {
|
|
t.Fatalf("concealed authorization recovery = %q, want target-free fallback", hint)
|
|
}
|
|
if !strings.Contains(hint, "current command requires scope(s): search:docs:read") {
|
|
t.Fatalf("concealed authorization recovery lost command scope context: %q", hint)
|
|
}
|
|
}
|
|
|
|
func TestConcealedForkProjectsAuthorizationCommandOutOfValidationMessage(t *testing.T) {
|
|
bin := buildConcealedFork(t, "concealed-drive", driveOnlyPlugin)
|
|
configDir := t.TempDir()
|
|
writeFile(t, filepath.Join(configDir, "config.json"),
|
|
`{"apps":[{"appId":"cli_plugin_e2e","appSecret":"secret","brand":"feishu","users":[]}]}`)
|
|
env := append(baseEnv(),
|
|
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
|
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
|
"LARKSUITE_CLI_CONFIG_DIR="+configDir,
|
|
)
|
|
res := runWithEnv(t, bin, env, "drive", "+search", "--mine")
|
|
if res.exit != 2 || !gjson.Valid(res.stderr) {
|
|
t.Fatalf("drive +search --mine exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
}
|
|
message := gjson.Get(res.stderr, "error.message").String()
|
|
if strings.Contains(message, "auth login") ||
|
|
!strings.Contains(message, "set user open_id in config") {
|
|
t.Fatalf("concealed validation message = %q, want retained target-free recovery", message)
|
|
}
|
|
}
|
|
|
|
func TestConcealedForkProjectsRetainedFrameworkRecovery(t *testing.T) {
|
|
bin := buildConcealedFork(t, "concealed-recovery-targets", recoveryTargetsPlugin)
|
|
configDir := t.TempDir()
|
|
writeFile(t, filepath.Join(configDir, "config.json"),
|
|
`{"currentApp":"only","apps":[{"name":"only","appId":"cli_plugin_e2e","appSecret":"secret","brand":"feishu","users":[]}]}`)
|
|
env := append(baseEnv(),
|
|
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
|
|
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
|
|
"LARKSUITE_CLI_CONFIG_DIR="+configDir,
|
|
)
|
|
|
|
assertUnavailableEnvelope(t, runWithEnv(t, bin, env, "profile", "add", "--help"))
|
|
profileRemove := runWithEnv(t, bin, env, "profile", "remove", "only")
|
|
if profileRemove.exit != 2 || !gjson.Valid(profileRemove.stderr) {
|
|
t.Fatalf("profile remove exit=%d stdout=%s stderr=%s", profileRemove.exit, profileRemove.stdout, profileRemove.stderr)
|
|
}
|
|
if got := gjson.Get(profileRemove.stderr, "error.subtype").String(); got != "failed_precondition" {
|
|
t.Fatalf("profile remove subtype=%q, want failed_precondition; stderr=%s", got, profileRemove.stderr)
|
|
}
|
|
profileHint := gjson.Get(profileRemove.stderr, "error.hint").String()
|
|
if strings.Contains(profileHint, "profile add") ||
|
|
profileHint != "configure another profile through this distribution before removing the only profile" {
|
|
t.Fatalf("concealed profile recovery = %q, want target-free fallback", profileHint)
|
|
}
|
|
|
|
assertUnavailableEnvelope(t, runWithEnv(t, bin, env, "config", "bind", "--help"))
|
|
configInitHelp := runWithEnv(t, bin, env, "config", "init", "--help")
|
|
if configInitHelp.exit != 0 || configInitHelp.stderr != "" {
|
|
t.Fatalf("config init --help exit=%d stdout=%s stderr=%s",
|
|
configInitHelp.exit, configInitHelp.stdout, configInitHelp.stderr)
|
|
}
|
|
if strings.Contains(configInitHelp.stdout, "config bind") {
|
|
t.Fatalf("config init --help retained concealed config bind pointer:\n%s", configInitHelp.stdout)
|
|
}
|
|
for _, want := range []string{"--force-init", "supported setup flow"} {
|
|
if !strings.Contains(configInitHelp.stdout, want) {
|
|
t.Fatalf("config init --help missing %q:\n%s", want, configInitHelp.stdout)
|
|
}
|
|
}
|
|
agentEnv := append(append([]string(nil), env...), "OPENCLAW_HOME="+t.TempDir())
|
|
configInit := runWithEnv(t, bin, agentEnv, "config", "init", "--new")
|
|
if configInit.exit != 3 || !gjson.Valid(configInit.stderr) {
|
|
t.Fatalf("config init exit=%d stdout=%s stderr=%s", configInit.exit, configInit.stdout, configInit.stderr)
|
|
}
|
|
if got := gjson.Get(configInit.stderr, "error.subtype").String(); got != "not_configured" {
|
|
t.Fatalf("config init subtype=%q, want not_configured; stderr=%s", got, configInit.stderr)
|
|
}
|
|
configHint := gjson.Get(configInit.stderr, "error.hint").String()
|
|
if strings.Contains(configHint, "config bind") ||
|
|
configHint != "Pass --force-init only if the user explicitly wants a separate app in this workspace." {
|
|
t.Fatalf("concealed config recovery = %q, want retained --force-init path", configHint)
|
|
}
|
|
}
|
|
|
|
const recoveryTargetsPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
|
package plugin
|
|
|
|
import "github.com/larksuite/cli/extension/platform"
|
|
|
|
func init() {
|
|
platform.Register(
|
|
platform.NewPlugin("recovery-targets", "0.1.0").
|
|
Restrict(&platform.Rule{
|
|
Name: "retain-error-producers",
|
|
Allow: []string{"profile/remove", "config/init"},
|
|
MaxRisk: platform.RiskWrite,
|
|
}).
|
|
MustBuild())
|
|
}
|
|
`
|
|
|
|
const driveOnlyPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
|
package plugin
|
|
|
|
import "github.com/larksuite/cli/extension/platform"
|
|
|
|
func init() {
|
|
platform.Register(
|
|
platform.NewPlugin("drive-only", "0.1.0").
|
|
Restrict(&platform.Rule{
|
|
Name: "drive-readonly",
|
|
Allow: []string{"drive/**"},
|
|
MaxRisk: platform.RiskRead,
|
|
}).
|
|
MustBuild())
|
|
}
|
|
`
|
|
|
|
// identityPlugin registers a Restrict rule scoped to bot identities only.
|
|
// im +flag-list declares AuthTypes:["user"] (see
|
|
// shortcuts/im/im_flag_list.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())
|
|
}
|
|
`
|
|
|
|
// schemaMailConcealPlugin deliberately keeps the schema tool while concealing
|
|
// only the generated mail subtree. It is the minimal distribution shape that
|
|
// catches schema escaping the build-local command surface.
|
|
const schemaMailConcealPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
|
|
package plugin
|
|
|
|
import "github.com/larksuite/cli/extension/platform"
|
|
|
|
func init() {
|
|
platform.Register(
|
|
platform.NewPlugin("schema-mail-conceal", "0.1.0").
|
|
Restrict(&platform.Rule{
|
|
Name: "retain-schema-hide-mail",
|
|
Allow: []string{"schema", "mail", "mail/**", "im", "im/**"},
|
|
Deny: []string{"mail/**"},
|
|
MaxRisk: platform.RiskHighRiskWrite,
|
|
AllowUnannotated: true,
|
|
}).
|
|
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())
|
|
}
|
|
`
|
|
|
|
// assertUnavailableEnvelope pins the process-level contract for a command
|
|
// removed from an integrator build by Plugin.Restrict.
|
|
func assertUnavailableEnvelope(t *testing.T, res result) {
|
|
t.Helper()
|
|
if res.exit != 2 {
|
|
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
}
|
|
assertUnavailableJSON(t, res.stderr)
|
|
}
|
|
|
|
func assertUnavailableJSON(t *testing.T, envelope string) {
|
|
t.Helper()
|
|
if !gjson.Valid(envelope) {
|
|
t.Fatalf("stderr 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 != "command_unavailable" {
|
|
t.Errorf("error.subtype=%q want command_unavailable", got)
|
|
}
|
|
if got := gjson.Get(envelope, "error.message").String(); got != "command not included in this build" {
|
|
t.Errorf("error.message=%q want default unavailable message", got)
|
|
}
|
|
if gjson.Get(envelope, "error.hint").Exists() {
|
|
t.Errorf("error.hint must be absent for an unavailable command: %s", envelope)
|
|
}
|
|
if gjson.Get(envelope, "error.detail").Exists() {
|
|
t.Errorf("error.detail must be absent for an unavailable command: %s", envelope)
|
|
}
|
|
}
|
|
|
|
// TestIdentityMismatchDenial pins the uniform unavailable presentation when a
|
|
// bot-only rule rejects a command whose declared AuthTypes don't include bot.
|
|
func TestIdentityMismatchDenial(t *testing.T) {
|
|
bin := buildConcealedFork(t, "concealed-identity", identityPlugin)
|
|
res := run(t, bin, "im", "+flag-list", "--as", "user")
|
|
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
assertUnavailableEnvelope(t, res)
|
|
}
|
|
|
|
// TestDenylistDenial pins the uniform unavailable presentation when a Deny
|
|
// glob rejects a command even though it also matches Allow.
|
|
func TestDenylistDenial(t *testing.T) {
|
|
bin := buildConcealedFork(t, "concealed-denylist", denylistPlugin)
|
|
res := run(t, bin, "docs", "+search")
|
|
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
assertUnavailableEnvelope(t, res)
|
|
}
|
|
|
|
// TestMultiRuleDenial pins the uniform unavailable presentation when every
|
|
// rule in a multi-Restrict plugin rejects the command.
|
|
func TestMultiRuleDenial(t *testing.T) {
|
|
bin := buildConcealedFork(t, "concealed-multirule", multiRulePlugin)
|
|
res := run(t, bin, "schema")
|
|
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
|
assertUnavailableEnvelope(t, res)
|
|
}
|