Files
2026-09-10 13:29:26 +08:00

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)
}