mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
341 lines
11 KiB
Go
341 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package platform_test
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/extension/platform"
|
|
)
|
|
|
|
// recorder Registrar captures everything a builder schedules so the
|
|
// test can assert what Install produced without involving the host.
|
|
type recorder struct {
|
|
observers int
|
|
wrappers int
|
|
lifecycles int
|
|
rule *platform.Rule // last rule (existing single-rule assertions)
|
|
rules []*platform.Rule // every rule, in Restrict order
|
|
skillsOverlay *platform.SkillsOverlay
|
|
skillCalls int
|
|
}
|
|
|
|
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
|
|
r.observers++
|
|
}
|
|
func (r *recorder) Wrap(string, platform.Selector, platform.Wrapper) { r.wrappers++ }
|
|
func (r *recorder) On(platform.LifecycleEvent, string, platform.LifecycleHandler) { r.lifecycles++ }
|
|
func (r *recorder) Restrict(rule *platform.Rule) {
|
|
r.rule = rule
|
|
r.rules = append(r.rules, rule)
|
|
}
|
|
func (r *recorder) EmbeddedSkills(spec *platform.SkillsOverlay) {
|
|
r.skillsOverlay = spec
|
|
r.skillCalls++
|
|
}
|
|
|
|
// Restrict must snapshot each rule: a caller that reuses and mutates the
|
|
// same *Rule object across two Restrict calls must still get two distinct
|
|
// rules at Install time, not two pointers to the last mutation.
|
|
func TestBuilder_restrictClonesEachRule(t *testing.T) {
|
|
shared := &platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: platform.RiskRead}
|
|
b := platform.NewPlugin("p", "0").Restrict(shared)
|
|
// Reuse and mutate the same object, then register it again.
|
|
shared.Name = "im-rw"
|
|
shared.Allow[0] = "im/**"
|
|
shared.MaxRisk = platform.RiskWrite
|
|
p, err := b.Restrict(shared).Build()
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
r := &recorder{}
|
|
if err := p.Install(r); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
if len(r.rules) != 2 {
|
|
t.Fatalf("got %d rules, want 2", len(r.rules))
|
|
}
|
|
if r.rules[0].Name != "docs-ro" || r.rules[0].Allow[0] != "docs/**" || r.rules[0].MaxRisk != platform.RiskRead {
|
|
t.Errorf("rule[0] leaked later mutation: %+v", r.rules[0])
|
|
}
|
|
if r.rules[1].Name != "im-rw" || r.rules[1].Allow[0] != "im/**" {
|
|
t.Errorf("rule[1] = %+v, want im-rw / im/**", r.rules[1])
|
|
}
|
|
}
|
|
|
|
func TestBuilder_basicAssembly(t *testing.T) {
|
|
p, err := platform.NewPlugin("audit", "0.1.0").
|
|
Observer(platform.Before, "pre", platform.All(),
|
|
func(context.Context, platform.Invocation) {}).
|
|
Observer(platform.After, "post", platform.All(),
|
|
func(context.Context, platform.Invocation) {}).
|
|
Wrap("policy", platform.All(),
|
|
func(next platform.Handler) platform.Handler { return next }).
|
|
On(platform.Startup, "boot",
|
|
func(context.Context, *platform.LifecycleContext) error { return nil }).
|
|
FailOpen().
|
|
Build()
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
if p.Name() != "audit" || p.Version() != "0.1.0" {
|
|
t.Errorf("metadata = %q/%q", p.Name(), p.Version())
|
|
}
|
|
if p.Capabilities().FailurePolicy != platform.FailOpen {
|
|
t.Errorf("FailurePolicy = %v, want FailOpen", p.Capabilities().FailurePolicy)
|
|
}
|
|
|
|
r := &recorder{}
|
|
if err := p.Install(r); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
if r.observers != 2 || r.wrappers != 1 || r.lifecycles != 1 {
|
|
t.Errorf("Install dispatch = observers=%d wrappers=%d lifecycles=%d",
|
|
r.observers, r.wrappers, r.lifecycles)
|
|
}
|
|
}
|
|
|
|
// Restrict() flips Restricts=true and FailClosed automatically — a
|
|
// policy plugin can't accidentally ship under FailOpen.
|
|
func TestBuilder_restrictForcesFailClosed(t *testing.T) {
|
|
p, err := platform.NewPlugin("policy-plugin", "0.1.0").
|
|
Restrict(&platform.Rule{Name: "read-only", MaxRisk: platform.RiskRead}).
|
|
Build()
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
caps := p.Capabilities()
|
|
if !caps.Restricts {
|
|
t.Errorf("Restricts = false, want true (Restrict() should flip it)")
|
|
}
|
|
if caps.FailurePolicy != platform.FailClosed {
|
|
t.Errorf("FailurePolicy = %v, want FailClosed (Restrict() implies it)", caps.FailurePolicy)
|
|
}
|
|
|
|
r := &recorder{}
|
|
if err := p.Install(r); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
if r.rule == nil || r.rule.Name != "read-only" {
|
|
t.Errorf("Install did not propagate Rule: %+v", r.rule)
|
|
}
|
|
}
|
|
|
|
// Invalid name surfaces at Build time, not at NewPlugin.
|
|
func TestBuilder_invalidPluginName(t *testing.T) {
|
|
_, err := platform.NewPlugin("Has_Underscore_And_Caps", "0.1").Build()
|
|
if err == nil {
|
|
t.Fatalf("Build must reject malformed plugin name")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid plugin name") {
|
|
t.Errorf("error should mention plugin name, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// Duplicate hookName within the same builder is rejected.
|
|
func TestBuilder_duplicateHookName(t *testing.T) {
|
|
noopObs := func(context.Context, platform.Invocation) {}
|
|
_, err := platform.NewPlugin("dup", "0").
|
|
Observer(platform.Before, "h", platform.All(), noopObs).
|
|
Observer(platform.After, "h", platform.All(), noopObs).
|
|
Build()
|
|
if err == nil {
|
|
t.Fatalf("Build must reject duplicate hookName")
|
|
}
|
|
if !strings.Contains(err.Error(), "already used") {
|
|
t.Errorf("error should mention duplicate hookName, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuilder_invalidHookName(t *testing.T) {
|
|
_, err := platform.NewPlugin("p", "0").
|
|
Observer(platform.Before, "Bad.Name", platform.All(),
|
|
func(context.Context, platform.Invocation) {}).
|
|
Build()
|
|
if err == nil {
|
|
t.Fatalf("Build must reject hookName with dot")
|
|
}
|
|
}
|
|
|
|
// MustBuild panics on builder error.
|
|
func TestBuilder_mustBuildPanicsOnError(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Fatalf("MustBuild must panic when Build would fail")
|
|
}
|
|
}()
|
|
_ = platform.NewPlugin("BadName", "0").MustBuild()
|
|
}
|
|
|
|
func TestBuilder_restrictNilRejected(t *testing.T) {
|
|
_, err := platform.NewPlugin("p", "0").Restrict(nil).Build()
|
|
if err == nil {
|
|
t.Fatalf("Restrict(nil) must produce error")
|
|
}
|
|
}
|
|
|
|
func TestBuilder_capabilitiesSetters(t *testing.T) {
|
|
p, err := platform.NewPlugin("p", "0.1").
|
|
RequireCLI(">=1.0.0").
|
|
FailClosed().
|
|
Build()
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
caps := p.Capabilities()
|
|
if caps.RequiredCLIVersion != ">=1.0.0" {
|
|
t.Errorf("RequiredCLIVersion = %q, want >=1.0.0", caps.RequiredCLIVersion)
|
|
}
|
|
if caps.FailurePolicy != platform.FailClosed {
|
|
t.Errorf("FailurePolicy = %v, want FailClosed", caps.FailurePolicy)
|
|
}
|
|
}
|
|
|
|
func TestBuilder_restrictThenFailOpenRejected(t *testing.T) {
|
|
rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead}
|
|
_, err := platform.NewPlugin("p", "0").Restrict(rule).FailOpen().Build()
|
|
if err == nil {
|
|
t.Fatalf("Build must reject Restrict()+FailOpen() mismatch")
|
|
}
|
|
if !strings.Contains(err.Error(), "FailClosed") {
|
|
t.Errorf("error should mention FailClosed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// Restrict() flips FailurePolicy to FailClosed; the previous FailOpen()
|
|
// is overridden. Pin it so the Build-time validation does not over-reject.
|
|
func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
|
|
rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead}
|
|
p, err := platform.NewPlugin("p", "0").FailOpen().Restrict(rule).Build()
|
|
if err != nil {
|
|
t.Fatalf("FailOpen()+Restrict() must succeed (Restrict flips to FailClosed): %v", err)
|
|
}
|
|
if p.Capabilities().FailurePolicy != platform.FailClosed {
|
|
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
|
|
}
|
|
}
|
|
|
|
// EmbeddedSkills() must snapshot selection and remap slices: a caller that
|
|
// mutates the same backing arrays after the call must still get the values
|
|
// staged at call time.
|
|
func TestBuilder_skillsInstalledAndCloned(t *testing.T) {
|
|
remove := []string{"lark-shared"}
|
|
remaps := []platform.SkillRefRemap{
|
|
platform.RemapSkillRef("lark-doc", "acme-docx"),
|
|
}
|
|
spec := &platform.SkillsOverlay{Remove: remove, ReferenceRemaps: remaps}
|
|
b := platform.NewPlugin("p", "0").EmbeddedSkills(spec)
|
|
remove[0] = "mutated"
|
|
remaps[0] = platform.RemapSkillRef("lark-doc", "mutated")
|
|
spec.ReferenceRemaps = nil
|
|
|
|
p, err := b.Build()
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
r := &recorder{}
|
|
if err := p.Install(r); err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
if r.skillCalls != 1 {
|
|
t.Fatalf("Skills calls = %d, want 1", r.skillCalls)
|
|
}
|
|
if r.skillsOverlay == nil || len(r.skillsOverlay.Remove) != 1 || r.skillsOverlay.Remove[0] != "lark-shared" {
|
|
t.Errorf("staged Remove leaked later mutation: %+v", r.skillsOverlay)
|
|
}
|
|
if len(r.skillsOverlay.ReferenceRemaps) != 1 {
|
|
t.Fatalf("staged ReferenceRemaps = %+v, want one mapping", r.skillsOverlay.ReferenceRemaps)
|
|
}
|
|
remap := r.skillsOverlay.ReferenceRemaps[0]
|
|
if remap.From() != "lark-doc" || remap.To() != "acme-docx" {
|
|
t.Errorf("staged remap = %q -> %q, want lark-doc -> acme-docx", remap.From(), remap.To())
|
|
}
|
|
}
|
|
|
|
func TestBuilder_skillsNilRejected(t *testing.T) {
|
|
_, err := platform.NewPlugin("p", "0").EmbeddedSkills(nil).Build()
|
|
if err == nil {
|
|
t.Fatal("EmbeddedSkills(nil) must produce error")
|
|
}
|
|
}
|
|
|
|
func TestBuilder_skillsTwiceRejected(t *testing.T) {
|
|
_, err := platform.NewPlugin("p", "0").
|
|
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).
|
|
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}}).
|
|
Build()
|
|
if err == nil {
|
|
t.Fatal("calling EmbeddedSkills() twice must produce error")
|
|
}
|
|
}
|
|
|
|
// EmbeddedSkills is a distribution build-integrity commitment: skipping it
|
|
// could silently restore content that the distribution intended to remove.
|
|
func TestBuilder_skillsForcesFailClosed(t *testing.T) {
|
|
p, err := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).Build()
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
caps := p.Capabilities()
|
|
if caps.Restricts {
|
|
t.Error("EmbeddedSkills() must not set Restricts")
|
|
}
|
|
if caps.FailurePolicy != platform.FailClosed {
|
|
t.Errorf("FailurePolicy = %v, want FailClosed", caps.FailurePolicy)
|
|
}
|
|
}
|
|
|
|
func TestBuilder_skillsFailurePolicyOrder(t *testing.T) {
|
|
spec := func() *platform.SkillsOverlay {
|
|
return &platform.SkillsOverlay{Remove: []string{"lark-a"}}
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
build func() *platform.Builder
|
|
wantError bool
|
|
}{
|
|
{
|
|
name: "FailOpen before EmbeddedSkills is overridden",
|
|
build: func() *platform.Builder {
|
|
return platform.NewPlugin("p", "0").FailOpen().EmbeddedSkills(spec())
|
|
},
|
|
},
|
|
{
|
|
name: "FailOpen after EmbeddedSkills is invalid",
|
|
build: func() *platform.Builder {
|
|
return platform.NewPlugin("p", "0").EmbeddedSkills(spec()).FailOpen()
|
|
},
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "later FailClosed restores a valid final state",
|
|
build: func() *platform.Builder {
|
|
return platform.NewPlugin("p", "0").EmbeddedSkills(spec()).FailOpen().FailClosed()
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
p, err := tc.build().Build()
|
|
if tc.wantError {
|
|
if err == nil {
|
|
t.Fatal("Build succeeded, want FailOpen+EmbeddedSkills rejection")
|
|
}
|
|
if !strings.Contains(err.Error(), "EmbeddedSkills() requires FailClosed") {
|
|
t.Fatalf("error = %v, want EmbeddedSkills FailClosed guidance", err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
if got := p.Capabilities().FailurePolicy; got != platform.FailClosed {
|
|
t.Fatalf("FailurePolicy = %v, want FailClosed", got)
|
|
}
|
|
})
|
|
}
|
|
}
|