mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 17:20:00 +08:00
- hook/install: propagate wrapper-injected ctx to invokeOriginal so RunE/Run see context values added by upstream Wrappers - hook/testing: SetStderrForTesting returns a restore func; tests now defer it via t.Cleanup to avoid cross-test sink leakage - cmdpolicy/active: deep-copy ActivePolicy.Rule on SetActive/GetActive so callers can't mutate the stored global through shared slices - platform/inventory: deep-copy Inventory + nested Plugins / HookEntry / RuleView slices on SetActiveInventory / GetActiveInventory - platform/staging: Restrict clones the plugin-supplied Rule before retaining it so the plugin can't mutate it after Install returns - platform/version: reject RequiredCLIVersion with more than three numeric components instead of silently truncating 1.2.3.4 to 1.2.3 - cmd/platform_bootstrap: clear cmdpolicy.SetActive on yaml resolver error so config policy show doesn't surface a stale rule - cmd/platform_bootstrap_test: tmpHome pins LARKSUITE_CLI_CONFIG_DIR so host env can't bleed into the policy test fixtures - cmdpolicy/apply: installDenyStub returns bool; Apply count no longer over-reports when strict-mode short-circuits the install - cmdpolicy/engine: aggregateParents now returns the runnable hybrid's own denial status when all children are placeholder branches - cmdpolicy/resolver_test: use t.TempDir()-rooted missing path instead of hardcoded /nonexistent for hermetic missing-file assertion - cmd/config/plugins: empty-inventory branch emits total: 0 so the JSON schema stays stable across populated/empty cases - cmd/platform_guards_test: select leaf by RunE != nil (not Runnable) so the test doesn't nil-deref on Run-only commands - gofmt run on previously committed cmdpolicy/path*.go (CI fast-gate)
269 lines
8.1 KiB
Go
269 lines
8.1 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// tmpHome creates a tempdir, points $HOME at it, and returns the path to
|
|
// the ~/.lark-cli/ subdirectory (created). The HOME env var is restored
|
|
// when the test ends.
|
|
//
|
|
// LARKSUITE_CLI_CONFIG_DIR is force-set to the same path. Without that
|
|
// override, a developer running the tests with a personal
|
|
// LARKSUITE_CLI_CONFIG_DIR exported in their shell (or a CI runner with
|
|
// a baked-in value) would resolve userPolicyPath() to their real
|
|
// machine and bleed unrelated yaml into the test fixtures. With the
|
|
// override pinned here, the test is hermetic regardless of the host
|
|
// environment.
|
|
func tmpHome(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
t.Setenv("HOME", dir)
|
|
t.Setenv("USERPROFILE", dir) // Windows fallback for os.UserHomeDir
|
|
cfgDir := filepath.Join(dir, ".lark-cli")
|
|
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir)
|
|
return cfgDir
|
|
}
|
|
|
|
// writePolicy writes a policy.yml into the user config dir.
|
|
func writePolicy(t *testing.T, cfgDir string, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"), []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write policy: %v", err)
|
|
}
|
|
}
|
|
|
|
// fakeTree builds a minimal command tree with the same shape the real
|
|
// CLI exposes for these tests: lark-cli has a docs group with +fetch and
|
|
// +update, and an im group with +send. Each leaf has its risk_level set
|
|
// so MaxRisk filtering exercises a real path.
|
|
func fakeTree(t *testing.T) *cobra.Command {
|
|
t.Helper()
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
|
|
docs := &cobra.Command{Use: "docs"}
|
|
root.AddCommand(docs)
|
|
addLeaf(docs, "+fetch", "read")
|
|
addLeaf(docs, "+update", "write")
|
|
addLeaf(docs, "+delete-doc", "high-risk-write")
|
|
|
|
im := &cobra.Command{Use: "im"}
|
|
root.AddCommand(im)
|
|
addLeaf(im, "+send", "write")
|
|
|
|
return root
|
|
}
|
|
|
|
func addLeaf(parent *cobra.Command, use, risk string) {
|
|
leaf := &cobra.Command{
|
|
Use: use,
|
|
RunE: func(*cobra.Command, []string) error { return nil },
|
|
}
|
|
cmdutil.SetRisk(leaf, risk)
|
|
parent.AddCommand(leaf)
|
|
}
|
|
|
|
// findLeaf walks the tree by Use names.
|
|
func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
|
|
t.Helper()
|
|
cur := parent
|
|
for _, n := range names {
|
|
var next *cobra.Command
|
|
for _, c := range cur.Commands() {
|
|
if c.Use == n {
|
|
next = c
|
|
break
|
|
}
|
|
}
|
|
if next == nil {
|
|
t.Fatalf("child %q not found under %q", n, cur.Use)
|
|
}
|
|
cur = next
|
|
}
|
|
return cur
|
|
}
|
|
|
|
// Happy path: a valid policy.yml denies one specific command. The denied
|
|
// command's RunE returns a typed ExitError envelope; allowed commands are
|
|
// untouched.
|
|
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
|
|
cfgDir := tmpHome(t)
|
|
writePolicy(t, cfgDir, `
|
|
name: test-policy
|
|
allow: ["docs/**", "contact/**"]
|
|
deny: ["docs/+delete-doc"]
|
|
max_risk: write
|
|
`)
|
|
|
|
root := fakeTree(t)
|
|
if err := applyUserPolicyPruning(root, nil); err != nil {
|
|
t.Fatalf("apply policy: %v", err)
|
|
}
|
|
|
|
// docs/+delete-doc must be denied (Deny match).
|
|
deleteCmd := findLeaf(t, root, "docs", "+delete-doc")
|
|
if !deleteCmd.Hidden {
|
|
t.Errorf("+delete-doc should be hidden after pruning")
|
|
}
|
|
err := deleteCmd.RunE(deleteCmd, nil)
|
|
if err == nil {
|
|
t.Fatalf("+delete-doc RunE should return an error")
|
|
}
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
|
|
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
|
|
}
|
|
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
|
if !ok || detail["reason_code"] != "command_denylisted" {
|
|
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
|
|
}
|
|
|
|
// im/+send must be denied (domain not in Allow).
|
|
send := findLeaf(t, root, "im", "+send")
|
|
if !send.Hidden {
|
|
t.Errorf("im/+send should be hidden (not in Allow)")
|
|
}
|
|
|
|
// docs/+update must stay alive (domain matches, risk within max).
|
|
update := findLeaf(t, root, "docs", "+update")
|
|
if update.Hidden {
|
|
t.Errorf("docs/+update should remain visible")
|
|
}
|
|
if err := update.RunE(update, nil); err != nil {
|
|
t.Errorf("docs/+update RunE should succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// Missing file means no pruning -- the CLI runs unrestricted with the
|
|
// full command surface. This is the default case for users who haven't
|
|
// opted into pruning.
|
|
func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
|
|
tmpHome(t) // home set but no policy.yml written
|
|
|
|
root := fakeTree(t)
|
|
if err := applyUserPolicyPruning(root, nil); err != nil {
|
|
t.Fatalf("missing policy should not error, got %v", err)
|
|
}
|
|
|
|
// Every leaf must remain non-Hidden.
|
|
for _, sub := range []string{"+fetch", "+update", "+delete-doc"} {
|
|
cmd := findLeaf(t, root, "docs", sub)
|
|
if cmd.Hidden {
|
|
t.Errorf("%s should not be Hidden when no policy file exists", sub)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Invalid yaml content (parse error) surfaces as an error from the
|
|
// wiring. The build path then decides whether to fail-open or
|
|
// fail-closed; the wiring itself stays neutral.
|
|
func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
|
cfgDir := tmpHome(t)
|
|
writePolicy(t, cfgDir, "::: not yaml :::")
|
|
|
|
root := fakeTree(t)
|
|
err := applyUserPolicyPruning(root, nil)
|
|
if err == nil {
|
|
t.Fatalf("malformed yaml should produce an error")
|
|
}
|
|
}
|
|
|
|
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
|
|
// Resolve and produces an error. This is the safety contract: a typo in
|
|
// the rule must not silently lower the pruning bar.
|
|
func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
|
|
cfgDir := tmpHome(t)
|
|
writePolicy(t, cfgDir, "max_risk: nukem\n")
|
|
|
|
root := fakeTree(t)
|
|
err := applyUserPolicyPruning(root, nil)
|
|
if err == nil {
|
|
t.Fatalf("invalid MaxRisk should produce an error")
|
|
}
|
|
}
|
|
|
|
// warnPolicyError emits to the supplied writer when err is non-nil and
|
|
// stays silent for nil. Verifies the build.go fail-open behaviour can be
|
|
// observed by users.
|
|
func TestWarnPolicyError(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
warnPolicyError(&buf, nil)
|
|
if buf.Len() != 0 {
|
|
t.Fatalf("warnPolicyError with nil err should write nothing, got %q", buf.String())
|
|
}
|
|
|
|
buf.Reset()
|
|
warnPolicyError(&buf, errors.New("boom"))
|
|
if buf.String() != "warning: user policy not applied: boom\n" {
|
|
t.Fatalf("warnPolicyError output = %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// End-to-end through buildInternal: when a valid policy.yml exists in
|
|
// HOME, building the real command tree applies pruning to it. This is
|
|
// the "actually integrated" test -- it exercises the wiring point in
|
|
// build.go itself, not just the helper.
|
|
func TestBuildInternal_appliesPolicyToRealTree(t *testing.T) {
|
|
cfgDir := tmpHome(t)
|
|
// Deny one specific shortcut path that we know exists in the real
|
|
// service tree -- we cannot enumerate it from a unit test, so we
|
|
// use an Allow-list that matches nothing to deny everything except
|
|
// the root, and then verify ANY non-root command was hidden.
|
|
writePolicy(t, cfgDir, `
|
|
name: deny-everything
|
|
deny: ["**"]
|
|
`)
|
|
|
|
root := Build(context.Background(), buildInvocationForTest(t))
|
|
|
|
// Find any leaf and verify it was hidden.
|
|
var foundHidden bool
|
|
walk(root, func(c *cobra.Command) {
|
|
if c.HasParent() && c.Runnable() && c.Hidden {
|
|
foundHidden = true
|
|
}
|
|
})
|
|
if !foundHidden {
|
|
t.Fatalf("expected at least one runnable command to be Hidden after deny=** policy")
|
|
}
|
|
|
|
// Root itself must stay alive.
|
|
if root.Hidden {
|
|
t.Errorf("root command must not be Hidden even under deny-everything policy")
|
|
}
|
|
}
|
|
|
|
func walk(cmd *cobra.Command, fn func(*cobra.Command)) {
|
|
if cmd == nil {
|
|
return
|
|
}
|
|
fn(cmd)
|
|
for _, c := range cmd.Commands() {
|
|
walk(c, fn)
|
|
}
|
|
}
|
|
|
|
// buildInvocationForTest returns a minimal cmdutil.InvocationContext so
|
|
// build.go's pure-assembly path can construct a tree without touching
|
|
// real config / credentials. Profile name is the empty default.
|
|
func buildInvocationForTest(t *testing.T) cmdutil.InvocationContext {
|
|
t.Helper()
|
|
return cmdutil.InvocationContext{}
|
|
}
|