Files
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

187 lines
6.2 KiB
Go

package paneldecl
import (
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件是字段层与表格元素层(列 / 行内动作)的拒绝用例。
// rejectCasesFieldCore 是字段的必填、形状与选项来源。
func rejectCasesFieldCore() []rejectCase {
return []rejectCase{
{
name: "字段 key 缺失",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[0].Key = "" })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].fields[0].key",
},
{
name: "字段 key 形状非法(中划线)",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[0].Key = "api-key" })
},
want: ErrInvalidFieldKey, wantPath: "adminPanels[0].sections[0].fields[0].key",
},
{
name: "字段类型不在闭集",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[0].Type = "chart" })
},
want: ErrUnknownFieldType, wantPath: "adminPanels[0].sections[0].fields[0].type",
},
{
name: "字段 key 在区块内重复",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[1].Key = "provider" })
},
want: ErrDuplicateID, wantPath: "adminPanels[0].sections[0].fields[1].key",
},
{
name: "optionsFrom 塞了任意 URL",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[0].OptionsFrom = "https://api.example.com/list"
})
},
want: ErrInvalidOptionsFrom, wantPath: "adminPanels[0].sections[0].fields[0].optionsFrom",
},
{
name: "optionsFrom 配在 text 字段上",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[2].OptionsFrom = "plugin:providers"
})
},
want: ErrInvalidOptionsFrom, wantPath: "adminPanels[0].sections[0].fields[2].optionsFrom",
},
{
name: "字段文案含控制字符",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[2].Label = "接口\n地址"
})
},
want: ErrInvalidText, wantPath: "adminPanels[0].sections[0].fields[2].label",
},
}
}
// rejectCasesFieldBoundsAndDefaults 是 secret 默认值、数值边界与默认值类型。
func rejectCasesFieldBoundsAndDefaults() []rejectCase {
return []rejectCase{
{
name: "secret 字段声明默认值",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[1].Default = "sk-leaked"
})
},
want: ErrSecretDefault, wantPath: "adminPanels[0].sections[0].fields[1].default",
},
{
name: "min/max 配在 text 字段上",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[2].Min = floatPtr(1)
})
},
want: ErrInvalidBounds, wantPath: "adminPanels[0].sections[0].fields[2].min",
},
{
name: "min 大于 max",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
f := &p.Sections[0].Fields[2]
f.Type = sdk.FieldNumber
f.Min = floatPtr(10)
f.Max = floatPtr(1)
})
},
want: ErrInvalidBounds, wantPath: "adminPanels[0].sections[0].fields[2].min",
},
{
name: "默认值与字段类型不匹配(下拉字段给了数字)",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[0].Default = 42
})
},
want: ErrInvalidDefault, wantPath: "adminPanels[0].sections[0].fields[0].default",
},
}
}
// rejectCasesTableItems 是列与行内动作的拒绝用例。
func rejectCasesTableItems() []rejectCase {
return []rejectCase{
{
name: "列 key 缺失",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns[0].Key = "" })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[1].columns[0].key",
},
{
name: "列 key 重复",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns[1].Key = "provider" })
},
want: ErrDuplicateID, wantPath: "adminPanels[0].sections[1].columns[1].key",
},
{
name: "列 label 含控制字符",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns[0].Label = "状\t态" })
},
want: ErrInvalidText, wantPath: "adminPanels[0].sections[1].columns[0].label",
},
{
name: "行内动作 id 含大写",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].RowActions[0].ID = "Test" })
},
want: ErrInvalidRowActionID, wantPath: "adminPanels[0].sections[1].rowActions[0].id",
},
{
name: "行内动作缺 button 文案",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].RowActions[0].Label = "" })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[1].rowActions[0].label",
},
{
name: "行内动作塞了任意 URL",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[1].RowActions[0].Action = "https://evil.example.com/x"
})
},
want: ErrInvalidAction, wantPath: "adminPanels[0].sections[1].rowActions[0].action",
},
{
name: "行内动作 id 重复",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].RowActions[1].ID = "test" })
},
want: ErrDuplicateID, wantPath: "adminPanels[0].sections[1].rowActions[1].id",
},
{
name: "submit 有动作键但没有动作名",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Submit = &sdk.Submit{} })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].submit.action",
},
{
name: "source 动作名含冒号(URL 形态)",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[1].Source = &sdk.Source{Action: "http://api.example.com/list"}
})
},
want: ErrInvalidAction, wantPath: "adminPanels[0].sections[1].source.action",
},
}
}