feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

纯库、零接线:不碰 gateway/frontend/Docs/.env*(本片刻意不接线,避免与在途切片争用文件面)。

- pkg/plugins/sdk:面板声明类型(JSON tag 与 design/09 §5.2 示例逐字同形);
  字段类型与 section 类型都是**闭集**;When 三态 + **严格 UnmarshalJSON**(未知条件键报错,
  不静默丢)
- internal/capability/paneldecl:闭集校验(47 条拒绝用例,断言的是**分类哨兵 + JSON 路径**,
  不是错误字符串)· catalog(并发安全、返回拷贝、按 (group, order, 登记序号) 的**全序**稳定排序)·
  when 求值(上下文由 Resolver 注入,本包不读环境变量/文件)· 角色过滤(viewer 只留
  status/log/table 且摘掉 rowActions,纯写面板整体隐藏;未知角色 fail-closed)
- 拒绝粒度:插件 id 非法 ⇒ 整插件拒绝;单个面板非法 ⇒ 只拒该面板 + 聚合显式报错(理由见报告)

【我的验收】隔离 worktree(19 文件)build/vet/linux/darwin 全过、69 包全绿;
我亲手打红变异:把同组同序的回退比较  反成  ⇒ TestCatalog_StableOrder FAIL。
(记录一条观察:比较器已靠 seq 构成全序,故 sort.SliceStable 的稳定性是冗余保护 ——
我第一次用「SliceStable→Slice」打不红,正是因为全序与算法无关;这不是测试虚护。)
This commit is contained in:
AskaEth committed 2026-10-06 08:06:20 +08:00
1 parent 6e28b38b08
commit c9144c1096
19 files changed
+3053

No files matched your search

@@ -0,0 +1,371 @@
package paneldecl
import (
"errors"
"fmt"
"reflect"
"slices"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件锁定目录的四条不变量:生命周期重算、稳定序、按面板拒绝、返回值是拷贝。
// 并发那条交给 `go test -race` 去证。
// newTestCatalog 造一个不报错的目录(夹具里构造失败说明夹具坏了,直接 Fatal)。
func newTestCatalog(t *testing.T, resolve Resolver) *Catalog {
t.Helper()
cat, err := NewCatalog(resolve)
if err != nil {
t.Fatalf("NewCatalog: %v", err)
}
return cat
}
// mustRegister 注册并断言全部面板都被接受。
func mustRegister(t *testing.T, cat *Catalog, pluginID string, panels ...sdk.Panel) {
t.Helper()
accepted, err := cat.Register(pluginID, panels)
if err != nil {
t.Fatalf("Register(%s): %v", pluginID, err)
}
if len(accepted) != len(panels) {
t.Fatalf("Register(%s) 接受 %d 个面板,期望 %d", pluginID, len(accepted), len(panels))
}
}
// TestNewCatalog_RejectsNilResolver 锁定「事实来源必须显式接入」:给 nil resolver 一个
// 「全都显示」的默认值会让漏接线表现为看起来正常,所以构造函数直接拒。
func TestNewCatalog_RejectsNilResolver(t *testing.T) {
t.Parallel()
if _, err := NewCatalog(nil); !errors.Is(err, ErrNilResolver) {
t.Fatalf("NewCatalog(nil) err = %v,期望 ErrNilResolver", err)
}
}
// TestCatalog_WhenGatesEntrance 锁定 §5.2.1 nav.item 与 §5.6.1:when 不满足 ⇒ 入口不出现。
func TestCatalog_WhenGatesEntrance(t *testing.T) {
t.Parallel()
enabled := map[string]bool{"web-search": true}
cat := newTestCatalog(t, func(id string) Context { return Context{Enabled: enabled[id]} })
always := samplePanel()
always.ID = "always"
always.Order = 1
always.When = nil
onWhenEnabled := samplePanel()
onWhenEnabled.ID = "on"
onWhenEnabled.Order = 2
onWhenEnabled.When = &sdk.When{Enabled: boolPtr(true)}
whenDisabled := samplePanel()
whenDisabled.ID = "off"
whenDisabled.Order = 3
whenDisabled.When = &sdk.When{Enabled: boolPtr(false)}
mustRegister(t, cat, "web-search", always, onWhenEnabled, whenDisabled)
entries, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"always", "on"}) {
t.Fatalf("插件启用时的入口 = %v,期望 [always on]", got)
}
enabled["web-search"] = false
entries, err = cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"always", "off"}) {
t.Fatalf("插件停用时的入口 = %v,期望 [always off]", got)
}
}
// TestCatalog_StableOrder 锁定稳定序:乱序注册也得到固定输出,且反复查询逐字相同。
//
// 「逐字相同」不是洁癖:List 内部要用 map 收集各插件的面板,如果排序键不含登记序号,
// 同 group 同 order 的条目顺序就会随 map 迭代抖动 —— 前端导航跟着抖。
func TestCatalog_StableOrder(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
late := samplePanel()
late.ID = "z-late"
late.Order = 30
first := samplePanel()
first.ID = "z-first"
first.Order = 5
// 同一个插件内**先声明 order 大的**,锁定「同 order 时按声明顺序」与「order 优先」两件事。
mustRegister(t, cat, "zz", late, first)
same := samplePanel()
same.ID = "a-same-order"
same.Order = 5
mustRegister(t, cat, "aa", same)
want := []string{"z-first", "a-same-order", "z-late"}
for i := 0; i < 100; i++ {
entries, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("第 %d 次 List: %v", i, err)
}
if got := mustPanelIDs(t, entries); !slices.Equal(got, want) {
t.Fatalf("第 %d 次 List = %v,期望 %v(排序必须稳定)", i, got, want)
}
}
}
// TestCatalog_RegisterReplacesAndRemoveRecalculates 锁定 §5.2.1 的生命周期口径:
// 重装是**整体替换**,卸载后集合立即重算。
func TestCatalog_RegisterReplacesAndRemoveRecalculates(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
mustRegister(t, cat, "web-search", samplePanel())
// 重装只带一个新的面板 ⇒ 旧面板必须消失(不是叠加)。
replacement := statusPanel()
replacement.ID = "chain"
mustRegister(t, cat, "web-search", replacement)
entries, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"chain"}) {
t.Fatalf("重装后的入口 = %v,期望 [chain]", got)
}
cat.Remove("web-search")
entries, err = cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(entries) != 0 {
t.Fatalf("卸载后仍有 %d 条入口", len(entries))
}
// 重复卸载是幂等空操作。
cat.Remove("web-search")
if ids := cat.PluginIDs(); len(ids) != 0 {
t.Fatalf("卸载后仍登记着 %v", ids)
}
}
// TestCatalog_RegisterRejectsPanelOnly 锁定拒绝粒度(§5.2.1「该面板不注册 + 显式报错」):
// 坏面板被拒、坏面板所在插件的好面板照常注册,且错误可分类。
func TestCatalog_RegisterRejectsPanelOnly(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
good := samplePanel()
bad := statusPanel()
bad.ID = "broken"
// 形状错误:只读事实面不该有 source。
bad.Sections[0].Source = &sdk.Source{Action: "plugin.chain"}
accepted, err := cat.Register("web-search", []sdk.Panel{good, bad})
if !errors.Is(err, ErrSectionShape) {
t.Fatalf("err = %v,期望命中 ErrSectionShape", err)
}
if !slices.Equal(accepted, []string{"providers"}) {
t.Fatalf("accepted = %v,期望 [providers](坏面板不注册)", accepted)
}
entries, lerr := cat.List(auth.RoleOwner)
if lerr != nil {
t.Fatalf("List: %v", lerr)
}
if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"providers"}) {
t.Fatalf("目录内容 = %v,期望 [providers]", got)
}
// 整份声明都坏 ⇒ 该插件被登记成「零面板」(重算语义),而不是保留旧集合。
accepted, err = cat.Register("web-search", []sdk.Panel{bad})
if !errors.Is(err, ErrSectionShape) {
t.Fatalf("err = %v,期望命中 ErrSectionShape", err)
}
if len(accepted) != 0 {
t.Fatalf("accepted = %v,期望空", accepted)
}
entries, lerr = cat.List(auth.RoleOwner)
if lerr != nil {
t.Fatalf("List: %v", lerr)
}
if len(entries) != 0 {
t.Fatalf("全坏声明之后仍有 %d 条入口", len(entries))
}
// 「已装、未声明任何页面」仍要能被插件列表页查到(§5.2.1 红线③)。
if ids := cat.PluginIDs(); !slices.Equal(ids, []string{"web-search"}) {
t.Fatalf("PluginIDs = %v,期望 [web-search]", ids)
}
}
// TestCatalog_RegisterRejectsBadPluginID 锁定另一种拒绝口径:插件 id 非法 ⇒ 整插件拒绝
// (目录按插件 id 分命名空间,没有合法命名空间就无从挂载)。
func TestCatalog_RegisterRejectsBadPluginID(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
accepted, err := cat.Register("Web Search", []sdk.Panel{samplePanel()})
if !errors.Is(err, ErrInvalidPluginID) {
t.Fatalf("err = %v,期望命中 ErrInvalidPluginID", err)
}
if accepted != nil {
t.Fatalf("accepted = %v,期望 nil", accepted)
}
entries, lerr := cat.List(auth.RoleOwner)
if lerr != nil {
t.Fatalf("List: %v", lerr)
}
if len(entries) != 0 || len(cat.PluginIDs()) != 0 {
t.Fatalf("非法插件 id 不该留下任何痕迹:entries=%v ids=%v", entries, cat.PluginIDs())
}
}
// TestCatalog_ListReturnsCopy 锁定「返回值必须是拷贝」的两个方向:调用方改结果改不动目录,
// 注册之后改自己那份声明也改不动目录(后者同样重要 —— 插件管理器常常复用一份声明)。
func TestCatalog_ListReturnsCopy(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
mine := samplePanel()
mustRegister(t, cat, "web-search", mine)
entries, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if !reflect.DeepEqual(entries[0].Panel, samplePanel()) {
t.Fatalf("目录里的面板与声明不一致:%+v", entries[0].Panel)
}
// 方向一:改 List 的返回值。
entries[0].Panel.Title = "被我改了"
entries[0].Panel.Sections[0].Title = "被我改了"
entries[0].Panel.Sections[0].Fields[0].Key = "hacked"
entries[0].Panel.Sections[0].Fields[0].Min = floatPtr(1)
entries[0].Panel.Sections[1].Columns[0].Label = "被我改了"
entries[0].Panel.Sections[1].RowActions[0].Label = "被我改了"
entries[0].PluginID = "hacked"
*entries[0].Panel.When.Enabled = false
entries[0].Panel.Sections = append(entries[0].Panel.Sections, sdk.Section{ID: "extra", Type: sdk.SectionStatus})
// 方向二:改注册时用的那份声明。
mine.Title = "也改了"
mine.Sections[1].Columns[1].Label = "也改了"
again, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if got := mustPanelIDs(t, again); !slices.Equal(got, []string{"providers"}) {
t.Fatalf("入口 id 被外部改动影响:%v", got)
}
if again[0].PluginID != "web-search" {
t.Fatalf("PluginID 被改坏:%q", again[0].PluginID)
}
if !reflect.DeepEqual(again[0].Panel, samplePanel()) {
t.Fatalf("目录内部状态被调用方改坏了:%+v", again[0].Panel)
}
}
// TestCatalog_ListRejectsUnknownRole 锁定角色门是 fail-closed:认不出的角色不给任何面板,
// 也不猜一个默认角色放行。
func TestCatalog_ListRejectsUnknownRole(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
mustRegister(t, cat, "web-search", samplePanel())
if _, err := cat.List(auth.Role("superuser")); !errors.Is(err, ErrUnknownRole) {
t.Fatalf("err = %v,期望 ErrUnknownRole", err)
}
if _, err := cat.Groups(auth.Role("")); !errors.Is(err, ErrUnknownRole) {
t.Fatalf("Groups err = %v,期望 ErrUnknownRole", err)
}
}
// TestCatalog_Groups 锁定分组视图与 List 的一致性(§5.2.1 nav.group 一级)。
//
// 本期分组是闭集(只有 plugins),所以这里能验的只有「同一分组内条目连续且顺序与 List 一致」;
// 跨分组排序按最小 order 的规则要等分组闭集扩容后才能端到端测(见 PLG1-report.md)。
func TestCatalog_Groups(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
later := samplePanel()
later.ID = "later"
later.Order = 30
earlier := statusPanel()
earlier.ID = "earlier"
earlier.Order = 10
mustRegister(t, cat, "web-search", later, earlier)
groups, err := cat.Groups(auth.RoleOwner)
if err != nil {
t.Fatalf("Groups: %v", err)
}
if len(groups) != 1 {
t.Fatalf("分组数 = %d,期望 1(本期闭集只有 plugins)", len(groups))
}
if groups[0].ID != sdk.GroupPlugins {
t.Fatalf("分组 id = %q,期望 %q", groups[0].ID, sdk.GroupPlugins)
}
if got := mustPanelIDs(t, groups[0].Panels); !slices.Equal(got, []string{"earlier", "later"}) {
t.Fatalf("分组内顺序 = %v,期望 [earlier later]", got)
}
}
// TestCatalog_ConcurrentRegisterList 配合 `go test -race` 证明目录在并发
// 注册 / 查询 / 卸载 下无数据竞争、无 panic。
//
// 负载刻意**限核限时**(8 个 goroutine × 40 次,毫秒级):按仓库压测纪律,验证竞态不需要
// 堆进程风暴,注入受控并发足够。
func TestCatalog_ConcurrentRegisterList(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
const workers = 8
const rounds = 40
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
pluginID := fmt.Sprintf("plugin-%d", w)
for r := 0; r < rounds; r++ {
p := samplePanel()
p.ID = fmt.Sprintf("panel-%d-%d", w, r%3)
p.Order = r % 5
if _, err := cat.Register(pluginID, []sdk.Panel{p}); err != nil {
t.Errorf("Register(%s): %v", pluginID, err)
return
}
entries, err := cat.List(auth.RoleViewer)
if err != nil {
t.Errorf("List: %v", err)
return
}
for _, e := range entries {
if e.PluginID == "" || e.Panel.ID == "" {
t.Errorf("快照里出现空标识:%+v", e)
return
}
}
if _, err := cat.Groups(auth.RoleAdmin); err != nil {
t.Errorf("Groups: %v", err)
return
}
_ = cat.PluginIDs()
if r%11 == 0 {
cat.Remove(pluginID)
}
}
cat.Remove(pluginID)
}(w)
}
wg.Wait()
entries, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("收尾 List: %v", err)
}
if len(entries) != 0 {
t.Fatalf("全部卸载后仍有 %d 条入口", len(entries))
}
}