纯库、零接线:不碰 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」打不红,正是因为全序与算法无关;这不是测试虚护。)
372 lines
12 KiB
Go
372 lines
12 KiB
Go
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))
|
||
}
|
||
}
|