Files
Cyrene_v2/internal/capability/paneldecl/catalog_test.go
T
AskaEth c9144c1096
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
纯库、零接线:不碰 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」打不红,正是因为全序与算法无关;这不是测试虚护。)
2026-10-06 08:06:20 +08:00

372 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}
}