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」打不红,正是因为全序与算法无关;这不是测试虚护。)
This commit is contained in:
1 parent
6e28b38b08
commit
c9144c1096
19 files changed
+3053
No files matched your search
@@ -0,0 +1,246 @@
|
||||
package paneldecl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件是「清单目录」(§5.2.1 B「插件管理器 = 唯一中枢」里的 catalog 一项):
|
||||
// 收集各插件**显式声明**的 adminPanels,编译期内置插件与热装条目进同一张目录 ——
|
||||
// 没声明就是没页面,不做任何启发式猜测(§5.2.1 红线③)。
|
||||
//
|
||||
// 目录**不进** capability registry:那里是「可调用能力」,这里是「UI 声明」,混在一起会让
|
||||
// 两侧的语义互相污染(§5.7 B 的落点说明)。
|
||||
|
||||
// Entry 是目录里的一条面板:声明本身 + 它的属主插件。
|
||||
//
|
||||
// 刻意不带 JSON tag:下发给面板的线形状属于 §5.2.1 的「唯一契约面」
|
||||
// (GET /api/v1/admin/panels + panels_changed),那份契约由接线切片按「契约先行」登记进
|
||||
// Docs/api-client.md —— 本库不预占字段名,免得线上形状被一个内部类型钉死。
|
||||
type Entry struct {
|
||||
// PluginID 是声明该面板的插件 id。
|
||||
PluginID string
|
||||
// Panel 是该面板声明(目录内部状态的**拷贝**)。
|
||||
Panel sdk.Panel
|
||||
}
|
||||
|
||||
// Group 是侧边栏折叠分组及其条目(§5.2.1 的 nav.group / nav.item 两级)。
|
||||
//
|
||||
// 分组的**标题**不在声明里:§5.2 的 `group` 只是分组 id,而 §5.2.1 的 nav.group 一行又写
|
||||
// 「消费 group / title / order」—— 那句里的 title 只能是宿主给的本地化分组名(面板自己的
|
||||
// title 是页面标题)。这个歧义点记在 PLG1-report.md,本库按「分组标题由宿主提供」实现。
|
||||
type Group struct {
|
||||
// ID 是分组 id(本期闭集只有 sdk.GroupPlugins)。
|
||||
ID sdk.GroupID
|
||||
// Panels 是该分组下的条目,已按 order 稳定排序。
|
||||
Panels []Entry
|
||||
}
|
||||
|
||||
// storedPanel 是目录内部的一条记录:声明 + 属主 + 登记序号。
|
||||
//
|
||||
// 序号是**稳定序**的最后一根稻草:同 group、同 order 的条目按登记顺序排,从而保证
|
||||
// 多插件并存时导航不会因为 map 迭代顺序而抖动(前端导航抖动是真实体验问题)。
|
||||
type storedPanel struct {
|
||||
panel sdk.Panel
|
||||
pluginID string
|
||||
seq uint64
|
||||
}
|
||||
|
||||
// Catalog 是并发安全的面板声明目录。零值可用(空目录),但要用 when 求值必须经
|
||||
// NewCatalog 注入 Resolver。
|
||||
type Catalog struct {
|
||||
resolve Resolver
|
||||
|
||||
mu sync.RWMutex
|
||||
seq uint64
|
||||
panels map[string][]storedPanel // pluginID → 该插件当前的面板集合
|
||||
}
|
||||
|
||||
// NewCatalog 构造目录。resolve 必须非 nil(返回 ErrNilResolver)——
|
||||
//
|
||||
// 为什么不给 nil 一个「全都显示」的默认值:那会让「忘了接线启用状态」这种集成错误表现为
|
||||
// **看起来正常**(所有面板都在),而不是一个立刻能被发现的返回错误;而给「全都隐藏」的
|
||||
// 默认值又会变成静默丢页面。宁可让调用方在这里就把事实来源接上。
|
||||
func NewCatalog(resolve Resolver) (*Catalog, error) {
|
||||
if resolve == nil {
|
||||
return nil, ErrNilResolver
|
||||
}
|
||||
return &Catalog{resolve: resolve, panels: make(map[string][]storedPanel)}, nil
|
||||
}
|
||||
|
||||
// Register 用 panels **整体替换** pluginID 已有的声明集,并返回被接受的面板 id。
|
||||
//
|
||||
// 整体替换对应 §5.2.1 的生命周期口径:install / enable / disable / uninstall(含 MCP 的
|
||||
// tools/list_changed)**都重算面板集合** —— 调用方永远给「这个插件现在的完整声明」,
|
||||
// 不需要 diff。
|
||||
//
|
||||
// 拒绝口径(对齐 §5.2.1 B「非法声明 ⇒ **该面板不注册** + 显式报错」):
|
||||
//
|
||||
// - 插件 id 非法 ⇒ **整插件拒绝**:一个都不进目录。目录是按插件 id 分命名空间的
|
||||
// (路由 /admin/plugin/<pluginId>/<panelId>、端点 /api/v1/admin/plugins/{id}/actions/{action}),
|
||||
// 没有合法命名空间就没有可挂的东西;
|
||||
// - 某个面板声明非法 ⇒ **只拒该面板**,同插件其余合法面板照常注册,返回的 err 里是
|
||||
// 逐条可分类的违规(errors.Is 可判类),accepted 列出真正生效的面板 id。
|
||||
//
|
||||
// 为什么不做「一个面板坏了就整个插件都拒」:§5.2.1 明写的是「该面板不注册」,且整插件拒绝会
|
||||
// 让用户端从「少一个页面」变成「这个插件的页面全没了」,更难排查。代价是同一次安装会得到
|
||||
// 「部分生效 + 一条错误」这种双重结果,接入方必须**同时**读 err 与 accepted(见报告)。
|
||||
//
|
||||
// 存进来的声明是**规范化并深拷**过的(空 group 回落唯一分组、调用方的后续改动不影响目录)。
|
||||
func (c *Catalog) Register(pluginID string, panels []sdk.Panel) (accepted []string, err error) {
|
||||
if c == nil {
|
||||
return nil, ErrNilCatalog
|
||||
}
|
||||
if vs := checkID(pluginID, "pluginID", "插件 id", ErrInvalidPluginID); len(vs) > 0 {
|
||||
return nil, violationsToError(vs)
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.panels == nil {
|
||||
c.panels = make(map[string][]storedPanel)
|
||||
}
|
||||
var vs []Violation
|
||||
seen := make(map[string]bool, len(panels))
|
||||
accepted = make([]string, 0, len(panels))
|
||||
stored := make([]storedPanel, 0, len(panels))
|
||||
for i, p := range panels {
|
||||
path := panelPath(i)
|
||||
pvs := validatePanelAt(p, path)
|
||||
if p.ID != "" && seen[p.ID] {
|
||||
pvs = append(pvs, dupViolation(path+".id", "本插件", "面板 id", p.ID))
|
||||
}
|
||||
if len(pvs) > 0 {
|
||||
vs = append(vs, pvs...)
|
||||
continue
|
||||
}
|
||||
seen[p.ID] = true
|
||||
normalized := normalizePanel(p)
|
||||
c.seq++
|
||||
stored = append(stored, storedPanel{
|
||||
panel: clonePanel(normalized),
|
||||
pluginID: pluginID,
|
||||
seq: c.seq,
|
||||
})
|
||||
accepted = append(accepted, normalized.ID)
|
||||
}
|
||||
c.panels[pluginID] = stored
|
||||
return accepted, violationsToError(vs)
|
||||
}
|
||||
|
||||
// Remove 移除一个插件的全部面板声明。未知插件是幂等空操作(重复卸载不报错)。
|
||||
func (c *Catalog) Remove(pluginID string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.panels, pluginID)
|
||||
}
|
||||
|
||||
// List 返回按角色过滤后的面板快照,顺序为 (分组, order, 登记序号) 的**稳定序**。
|
||||
//
|
||||
// 两件过滤都在这里发生:`when` 不满足的入口整个不出现(§5.2.1 nav.item),角色看不到的区块
|
||||
// 被裁掉(§5.4.1)。返回值是深拷,调用方随便改(改不动目录内部状态)。
|
||||
//
|
||||
// 角色不在 owner/admin/viewer 之内 ⇒ ErrUnknownRole(**fail-closed**:宁可报错,也不猜一个
|
||||
// 默认角色去放行)。
|
||||
func (c *Catalog) List(role auth.Role) ([]Entry, error) {
|
||||
if !role.Valid() {
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownRole, role)
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
stored := c.sortedLocked()
|
||||
out := make([]Entry, 0, len(stored))
|
||||
for _, sp := range stored {
|
||||
if !Eval(sp.panel.When, c.contextFor(sp.pluginID)) {
|
||||
continue
|
||||
}
|
||||
filtered, visible := filterPanel(role, sp.panel)
|
||||
if !visible {
|
||||
continue
|
||||
}
|
||||
out = append(out, Entry{PluginID: sp.pluginID, Panel: clonePanel(filtered)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Groups 返回按分组组织的同一份快照(§5.2.1 的 nav.group 一级)。
|
||||
//
|
||||
// 分组之间按组内**最小的 order** 排(§5.2.1 说 nav.group 消费 order,那只能来自组内条目),
|
||||
// 同值时按 List 的稳定序,因此结果是确定的。本期只有 plugins 一个分组,这一段是给后续
|
||||
// 分组接入留的正确形状,不额外加接口。
|
||||
func (c *Catalog) Groups(role auth.Role) ([]Group, error) {
|
||||
entries, err := c.List(role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// List 的排序键第一位就是分组 ⇒ 同组条目在结果里一定连续,顺序扫描即可。
|
||||
var groups []Group
|
||||
for _, e := range entries {
|
||||
if n := len(groups); n > 0 && groups[n-1].ID == e.Panel.Group {
|
||||
groups[n-1].Panels = append(groups[n-1].Panels, e)
|
||||
continue
|
||||
}
|
||||
groups = append(groups, Group{ID: e.Panel.Group, Panels: []Entry{e}})
|
||||
}
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
return groups[i].Panels[0].Panel.Order < groups[j].Panels[0].Panel.Order
|
||||
})
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// PluginIDs 返回当前已登记**声明**的插件 id(已排序)。注意它不等于「已安装的插件」——
|
||||
// 目录只认识声明过面板的插件。
|
||||
//
|
||||
// 它的用途是 §5.2.1 红线③:一个「已装、未声明任何页面」的插件要在「插件与能力」页**如实
|
||||
// 显示「未声明管理页面」**,而不是被藏掉。插件列表页需要能拿这份 id 与已装列表对照。
|
||||
func (c *Catalog) PluginIDs() []string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]string, 0, len(c.panels))
|
||||
for id := range c.panels {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedLocked 返回 (分组, order, 登记序号) 稳定序的全部记录。调用方必须持有读锁。
|
||||
func (c *Catalog) sortedLocked() []storedPanel {
|
||||
out := make([]storedPanel, 0, len(c.panels))
|
||||
for _, list := range c.panels {
|
||||
out = append(out, list...)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].panel.Group != out[j].panel.Group {
|
||||
return out[i].panel.Group < out[j].panel.Group
|
||||
}
|
||||
if out[i].panel.Order != out[j].panel.Order {
|
||||
return out[i].panel.Order < out[j].panel.Order
|
||||
}
|
||||
return out[i].seq < out[j].seq
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// contextFor 取某插件的求值上下文。
|
||||
//
|
||||
// resolve 为 nil 只可能出现在零值 Catalog 上(NewCatalog 已拒 nil)或调用方显式构造 ——
|
||||
// 一律按「未启用」判,fail-closed:宁可不显示入口,也不给一个可能已停用的插件开入口。
|
||||
func (c *Catalog) contextFor(pluginID string) Context {
|
||||
if c.resolve == nil {
|
||||
return Context{}
|
||||
}
|
||||
return c.resolve(pluginID)
|
||||
}
|
||||
|
||||
// normalizePanel 把面板规范化成存进目录的形态:目前只有「空分组回落到唯一分组」。
|
||||
func normalizePanel(p sdk.Panel) sdk.Panel {
|
||||
p.Group = normalizeGroup(p.Group)
|
||||
return p
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package paneldecl
|
||||
|
||||
import "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
|
||||
// 本文件是目录的**拷贝语义**:存进来时拷一次、发出去时再拷一次。
|
||||
//
|
||||
// 为什么要两次:§5.2.1 的不变量是「`List` 返回的必须是拷贝」——调用方(gateway / 插件管理器)
|
||||
// 拿着结果做过滤或补字段时,绝不能改到目录内部状态;反向同理,插件管理器注册后继续改自己
|
||||
// 那份声明,也不能影响目录里的快照。声明是嵌套结构(sections → fields → 指针字段),
|
||||
// 浅拷贝会把内部切片暴露出去,所以这里逐层深拷。
|
||||
|
||||
// clonePanel 深拷一份面板声明。
|
||||
func clonePanel(p sdk.Panel) sdk.Panel {
|
||||
out := p
|
||||
out.When = cloneWhen(p.When)
|
||||
if p.Sections != nil {
|
||||
out.Sections = make([]sdk.Section, len(p.Sections))
|
||||
for i, s := range p.Sections {
|
||||
out.Sections[i] = cloneSection(s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cloneSection 深拷一个区块(切片与指针字段都要换新)。
|
||||
func cloneSection(s sdk.Section) sdk.Section {
|
||||
out := s
|
||||
if s.Fields != nil {
|
||||
out.Fields = make([]sdk.Field, len(s.Fields))
|
||||
for i, f := range s.Fields {
|
||||
out.Fields[i] = cloneField(f)
|
||||
}
|
||||
}
|
||||
if s.Columns != nil {
|
||||
out.Columns = make([]sdk.Column, len(s.Columns))
|
||||
copy(out.Columns, s.Columns)
|
||||
}
|
||||
if s.RowActions != nil {
|
||||
out.RowActions = make([]sdk.RowAction, len(s.RowActions))
|
||||
copy(out.RowActions, s.RowActions)
|
||||
}
|
||||
if s.Submit != nil {
|
||||
submit := *s.Submit
|
||||
out.Submit = &submit
|
||||
}
|
||||
if s.Source != nil {
|
||||
source := *s.Source
|
||||
out.Source = &source
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cloneField 深拷一个字段(Default 是 any,可能装着 map/slice)。
|
||||
func cloneField(f sdk.Field) sdk.Field {
|
||||
out := f
|
||||
out.Default = cloneValue(f.Default)
|
||||
if f.Min != nil {
|
||||
min := *f.Min
|
||||
out.Min = &min
|
||||
}
|
||||
if f.Max != nil {
|
||||
max := *f.Max
|
||||
out.Max = &max
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cloneWhen 深拷 when(Enabled 是指针,共享指针等于共享可变状态)。
|
||||
func cloneWhen(w *sdk.When) *sdk.When {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
out := &sdk.When{}
|
||||
if w.Enabled != nil {
|
||||
enabled := *w.Enabled
|
||||
out.Enabled = &enabled
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cloneValue 递归拷贝默认值里的容器。标量(字符串/数字/布尔)本身不可变,直接返回。
|
||||
//
|
||||
// 未覆盖的类型(声明由 Go 代码直接构造时可能塞进任意类型)按原值返回并在文档里写明:
|
||||
// 本包只承诺「结构声明不可被调用方改坏」,不承诺替调用方深拷它自己塞进来的自定义类型。
|
||||
func cloneValue(v any) any {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(val))
|
||||
for k, item := range val {
|
||||
out[k] = cloneValue(item)
|
||||
}
|
||||
return out
|
||||
case map[string]string:
|
||||
out := make(map[string]string, len(val))
|
||||
for k, item := range val {
|
||||
out[k] = item
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(val))
|
||||
for i, item := range val {
|
||||
out[i] = cloneValue(item)
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]string, len(val))
|
||||
copy(out, val)
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package paneldecl 是 design/09-插件生态 §5 的「插件管理面板声明」在宿主侧的库:
|
||||
// 校验(validate)、清单目录(catalog)、when 求值、按角色过滤。
|
||||
//
|
||||
// 职责边界(§5.2.1 B「插件管理器 = 唯一中枢」里的前三项):
|
||||
//
|
||||
// - 声明校验:闭集 + 形状 + 命名,逐条给出**可分类**的错误;非法声明绝不静默丢弃
|
||||
// (口径见 Catalog.Register 与 PLG1-report.md)。
|
||||
// - 清单目录:收集各插件显式声明的 adminPanels,编译期内置插件与热装条目进**同一张**
|
||||
// 目录;生命周期(install/enable/disable/uninstall)重算面板集合。
|
||||
// - 按角色过滤 + when 求值:owner/admin/viewer 与「是否启用」决定下发给面板的快照。
|
||||
//
|
||||
// 刻意**不做**的事(这一片是纯库,接线留给后续切片):
|
||||
//
|
||||
// - 不注册任何 HTTP 端点、不认识 gateway、不认识前端(§5.2.1 的「唯一契约面」
|
||||
// GET /api/v1/admin/panels 由后续切片按契约先行登记进 Docs/api-client.md);
|
||||
// - 不读环境变量、不读文件、不读 config:when 的求值上下文由调用方(插件管理器)
|
||||
// 注入(见 Resolver)—— 单一事实来源,也让本包可以在没有宿主的情况下单测;
|
||||
// - 不执行动作、不落审计:本包只声明「动作名」,分发与审计在 §5.2.1 的后半段。
|
||||
package paneldecl
|
||||
@@ -0,0 +1,138 @@
|
||||
package paneldecl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是校验错误的**可分类**表示。
|
||||
//
|
||||
// 为什么不用一句 `invalid`:§5.2.1 要求「非法声明 ⇒ 该面板不注册 + 显式报错」,而错误
|
||||
// 要同时喂给三种消费者 —— 插件管理器(决定拒哪个面板)、启动期日志(排障)、以及未来
|
||||
// 的安装响应(把错误码与文案分离,照 Home Assistant 的「错误码与文案分离」,前端可本地化)。
|
||||
// 所以错误必须是结构化的:哨兵做分类,Path 做定位,Detail 做人读说明。
|
||||
|
||||
// 校验失败分类哨兵。调用方用 errors.Is 判类,不靠字符串匹配。
|
||||
var (
|
||||
// ErrInvalidPluginID 报告插件 id 不合法(无法作为目录命名空间 / 路由段)。
|
||||
ErrInvalidPluginID = errors.New("paneldecl: invalid plugin id")
|
||||
// ErrInvalidPanelID 报告面板 id 不合法。
|
||||
ErrInvalidPanelID = errors.New("paneldecl: invalid panel id")
|
||||
// ErrInvalidSectionID 报告区块 id 不合法。
|
||||
ErrInvalidSectionID = errors.New("paneldecl: invalid section id")
|
||||
// ErrInvalidRowActionID 报告行内动作 id 不合法。
|
||||
ErrInvalidRowActionID = errors.New("paneldecl: invalid row action id")
|
||||
// ErrInvalidFieldKey 报告字段 key 不合法(字段 key 是配置键,字符集比 id 宽,见 shape.go)。
|
||||
ErrInvalidFieldKey = errors.New("paneldecl: invalid field key")
|
||||
// ErrMissingField 报告必填项缺失(id / title / section 的 type / field 的 key、type 等)。
|
||||
ErrMissingField = errors.New("paneldecl: required field missing")
|
||||
// ErrInvalidText 报告文案非法:空白、含控制字符、或超出长度上限。
|
||||
ErrInvalidText = errors.New("paneldecl: invalid text")
|
||||
// ErrUnknownGroup 报告分组不在闭集内(本期只有 plugins)。
|
||||
ErrUnknownGroup = errors.New("paneldecl: unknown panel group")
|
||||
// ErrUnknownSectionType 报告 section 类型不在闭集内。
|
||||
ErrUnknownSectionType = errors.New("paneldecl: unknown section type")
|
||||
// ErrUnknownFieldType 报告字段类型不在闭集内。
|
||||
ErrUnknownFieldType = errors.New("paneldecl: unknown field type")
|
||||
// ErrSectionShape 报告区块形状错误:该类型不该出现的键出现了(如 table 上写 fields)。
|
||||
ErrSectionShape = errors.New("paneldecl: invalid section shape")
|
||||
// ErrReadOnlySection 报告只读区块上出现了写面(status/log 上的 submit / rowActions)。
|
||||
ErrReadOnlySection = errors.New("paneldecl: read-only section declares a write surface")
|
||||
// ErrInvalidOptionsFrom 报告 optionsFrom 非法:只允许 `plugin:*`,且只允许出现在下拉字段上。
|
||||
ErrInvalidOptionsFrom = errors.New("paneldecl: invalid optionsFrom")
|
||||
// ErrInvalidAction 报告动作名非法(允许点分名,禁止 / : 等 URL 成分)。
|
||||
ErrInvalidAction = errors.New("paneldecl: invalid action name")
|
||||
// ErrInvalidOrder 报告 order 为负。
|
||||
ErrInvalidOrder = errors.New("paneldecl: invalid order")
|
||||
// ErrInvalidBounds 报告 min/max 非法(配在非 number 字段上、NaN、或 min > max)。
|
||||
ErrInvalidBounds = errors.New("paneldecl: invalid numeric bounds")
|
||||
// ErrInvalidDefault 报告默认值与字段类型不匹配。
|
||||
ErrInvalidDefault = errors.New("paneldecl: default value does not match the field type")
|
||||
// ErrSecretDefault 报告 secret 字段声明了默认值(§5.4.2 读侧无明文)。
|
||||
ErrSecretDefault = errors.New("paneldecl: secret field must not declare a default")
|
||||
// ErrInvalidLimit 报告 log 区块的 limit 越界。
|
||||
ErrInvalidLimit = errors.New("paneldecl: invalid log limit")
|
||||
// ErrInvalidIcon 报告图标名非法。
|
||||
ErrInvalidIcon = errors.New("paneldecl: invalid icon name")
|
||||
// ErrDuplicateID 报告同一作用域内 id / key 重复。
|
||||
ErrDuplicateID = errors.New("paneldecl: duplicate id")
|
||||
// ErrUnknownRole 报告角色不在 owner/admin/viewer 之内(查询目录时使用)。
|
||||
ErrUnknownRole = errors.New("paneldecl: unknown role")
|
||||
// ErrNilResolver 报告构造目录时没有注入 when 求值上下文来源。
|
||||
ErrNilResolver = errors.New("paneldecl: nil panel context resolver")
|
||||
// ErrNilCatalog 报告在 nil *Catalog 上调用了 Register。
|
||||
ErrNilCatalog = errors.New("paneldecl: nil catalog")
|
||||
)
|
||||
|
||||
// maxReportedViolations 限制聚合错误在 Error() 里铺开的条数:一个坏清单可能有几百条
|
||||
// 违规,全铺进一行日志会把有用的信息挤掉。结构化的完整列表仍在 Violations 里,不丢。
|
||||
const maxReportedViolations = 5
|
||||
|
||||
// Violation 是一条**可分类**的校验失败。
|
||||
type Violation struct {
|
||||
// Kind 是分类哨兵(本文件的 Err*),errors.Is 沿着它判定。
|
||||
Kind error
|
||||
// Path 是出问题的位置,索引式 JSON 路径,如
|
||||
// `adminPanels[0].sections[1].fields[2].type` —— 索引而不是 id,因为出错的可能正是 id。
|
||||
Path string
|
||||
// Detail 是人读说明,只讲这一条问题本身,不含他人信息。
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Error 实现 error。格式固定为 `<分类>: <路径>: <说明>`,便于日志切片与测试断言。
|
||||
func (v Violation) Error() string {
|
||||
return fmt.Sprintf("%s: %s: %s", v.Kind, v.Path, v.Detail)
|
||||
}
|
||||
|
||||
// Unwrap 让 errors.Is(err, ErrXxx) 能穿过这条违规。
|
||||
func (v Violation) Unwrap() error { return v.Kind }
|
||||
|
||||
// Errors 是逐条违规的聚合。它的存在是为了「一次把所有问题讲清楚」—— 逐个报错会让插件
|
||||
// 作者改一轮、跑一轮。
|
||||
type Errors struct {
|
||||
// Violations 按发现顺序排列(声明顺序,因此可复现)。
|
||||
Violations []Violation
|
||||
}
|
||||
|
||||
// Error 实现 error,最多铺开 maxReportedViolations 条,其余只报条数。
|
||||
func (e *Errors) Error() string {
|
||||
if e == nil || len(e.Violations) == 0 {
|
||||
return "paneldecl: no violations"
|
||||
}
|
||||
parts := make([]string, 0, maxReportedViolations)
|
||||
for i, v := range e.Violations {
|
||||
if i == maxReportedViolations {
|
||||
parts = append(parts, fmt.Sprintf("...(+%d more)", len(e.Violations)-maxReportedViolations))
|
||||
break
|
||||
}
|
||||
parts = append(parts, v.Error())
|
||||
}
|
||||
return fmt.Sprintf("paneldecl: %d violation(s): %s", len(e.Violations), strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
// Unwrap 返回每一条违规,让 errors.Is / errors.As 能穿透聚合看分类。
|
||||
func (e *Errors) Unwrap() []error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]error, 0, len(e.Violations))
|
||||
for _, v := range e.Violations {
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// violation 构造一条违规。
|
||||
func violation(kind error, path, detail string) Violation {
|
||||
return Violation{Kind: kind, Path: path, Detail: detail}
|
||||
}
|
||||
|
||||
// violationsToError 把逐条违规收成一个 error:空切片返回 nil(Go 的 nil-error 约定,
|
||||
// 免得调用方写 `if len(vs) > 0 { return &Errors{vs} }`)。
|
||||
func violationsToError(vs []Violation) error {
|
||||
if len(vs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &Errors{Violations: vs}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package paneldecl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件是字段层校验(§5.2 的 fields 元素):key/type 必填、optionsFrom 只允许 plugin:*、
|
||||
// secret 不得有默认值、数值边界与默认值类型。
|
||||
|
||||
// validateFields 校验一组字段声明,并保证 key 在区块内唯一。
|
||||
func validateFields(fields []sdk.Field, prefix string) []Violation {
|
||||
var vs []Violation
|
||||
seen := make(map[string]bool, len(fields))
|
||||
for i, f := range fields {
|
||||
path := fmt.Sprintf("%s.fields[%d]", prefix, i)
|
||||
vs = append(vs, validateFieldAt(f, path)...)
|
||||
if f.Key == "" {
|
||||
continue
|
||||
}
|
||||
if seen[f.Key] {
|
||||
vs = append(vs, dupViolation(path+".key", "本区块", "字段 key", f.Key))
|
||||
continue
|
||||
}
|
||||
seen[f.Key] = true
|
||||
}
|
||||
return vs
|
||||
}
|
||||
|
||||
// validateFieldAt 校验单个字段。type 未知时只报类型一条并停止:其余规则都依赖类型
|
||||
// (optionsFrom / min-max / 默认值类型),继续推导只会产生噪音。
|
||||
func validateFieldAt(f sdk.Field, path string) []Violation {
|
||||
var vs []Violation
|
||||
if f.Key == "" {
|
||||
vs = append(vs, violation(ErrMissingField, path+".key",
|
||||
"字段 key 必填(它同时是配置键与提交键)"))
|
||||
} else if !validFieldKey(f.Key) {
|
||||
vs = append(vs, violation(ErrInvalidFieldKey, path+".key", fieldKeyDetail(f.Key)))
|
||||
}
|
||||
vs = append(vs, checkOptionalText(f.Label, path+".label", titleMaxRunes)...)
|
||||
vs = append(vs, checkOptionalText(f.Placeholder, path+".placeholder", hintMaxRunes)...)
|
||||
if !f.Type.Valid() {
|
||||
return append(vs, violation(ErrUnknownFieldType, path+".type", fmt.Sprintf(
|
||||
"未知字段类型 %q(闭集:text|secret|number|switch|select|multiSelect|date|kv)", f.Type)))
|
||||
}
|
||||
vs = append(vs, checkOptionsFrom(f, path)...)
|
||||
vs = append(vs, checkBounds(f, path)...)
|
||||
vs = append(vs, checkDefault(f, path)...)
|
||||
return vs
|
||||
}
|
||||
|
||||
// checkOptionsFrom 校验下拉选项来源。两条规则:
|
||||
// 1. 只允许 `plugin:<名字>` —— §5.2 只给了插件内这一种来源,其它一律拒(防「声明里塞
|
||||
// 任意 URL」:选项来源一旦可以是 URL,声明就变相获得了发请求的能力);
|
||||
// 2. 只允许出现在 select / multiSelect 上 —— 其它控件不渲染选项列表,声明了也不生效,
|
||||
// 静默忽略正是 §5.2.1 要禁的那种假成功。
|
||||
func checkOptionsFrom(f sdk.Field, path string) []Violation {
|
||||
if f.OptionsFrom == "" {
|
||||
return nil
|
||||
}
|
||||
if !f.Type.PullsOptions() {
|
||||
return []Violation{violation(ErrInvalidOptionsFrom, path+".optionsFrom",
|
||||
fmt.Sprintf("%s 不渲染下拉选项,optionsFrom 只对 select / multiSelect 有效", f.Type))}
|
||||
}
|
||||
if !optionsFromRe.MatchString(f.OptionsFrom) {
|
||||
return []Violation{violation(ErrInvalidOptionsFrom, path+".optionsFrom", fmt.Sprintf(
|
||||
"optionsFrom %q 非法:只允许 `plugin:<名字>`(如 plugin:providers),不接受任意 URL",
|
||||
f.OptionsFrom))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkBounds 校验 min/max:只有 number 可声明,取值为有限数,且 min <= max。
|
||||
func checkBounds(f sdk.Field, path string) []Violation {
|
||||
if f.Min == nil && f.Max == nil {
|
||||
return nil
|
||||
}
|
||||
if !f.Type.Numeric() {
|
||||
return []Violation{violation(ErrInvalidBounds, path+".min", fmt.Sprintf(
|
||||
"%s 不支持 min/max(闭集里只有 number 是数值控件;date 用数值边界表达日期范围只会歧义)",
|
||||
f.Type))}
|
||||
}
|
||||
// NaN/Inf 在标准 JSON 里写不出来,但声明也可以由 Go 代码直接构造,所以照样拦。
|
||||
if f.Min != nil && (math.IsNaN(*f.Min) || math.IsInf(*f.Min, 0)) {
|
||||
return []Violation{violation(ErrInvalidBounds, path+".min", "min 必须是有限数")}
|
||||
}
|
||||
if f.Max != nil && (math.IsNaN(*f.Max) || math.IsInf(*f.Max, 0)) {
|
||||
return []Violation{violation(ErrInvalidBounds, path+".max", "max 必须是有限数")}
|
||||
}
|
||||
if f.Min != nil && f.Max != nil && *f.Min > *f.Max {
|
||||
return []Violation{violation(ErrInvalidBounds, path+".min", fmt.Sprintf(
|
||||
"min (%v) 不得大于 max (%v)", *f.Min, *f.Max))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDefault 校验默认值。secret 字段**一律不许**有默认值(§5.4.2 读侧永不回显明文:
|
||||
// 声明里写死的凭据会被读侧原样吐回,等于绕过掩码);其余类型要求默认值的 JSON 类型与
|
||||
// 控件相容,否则到前端是「控件显示不出该值」的半渲染。
|
||||
func checkDefault(f sdk.Field, path string) []Violation {
|
||||
if f.Default == nil {
|
||||
return nil
|
||||
}
|
||||
if f.Type.Sensitive() {
|
||||
return []Violation{violation(ErrSecretDefault, path+".default",
|
||||
"secret 字段不得声明默认值(§5.4.2:读侧永不回显明文)")}
|
||||
}
|
||||
want := defaultShapeOf(f.Type)
|
||||
if !defaultMatches(f.Type, f.Default) {
|
||||
return []Violation{violation(ErrInvalidDefault, path+".default",
|
||||
fmt.Sprintf("默认值类型与 %s 不匹配(期望 %s)", f.Type, want))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultMatches 判定默认值是否与字段类型相容。不做类型转换:能被静默转换的值(如给
|
||||
// number 写字符串 "20")正是「看着配好了、其实没生效」的来源。
|
||||
func defaultMatches(t sdk.FieldType, v any) bool {
|
||||
switch t {
|
||||
case sdk.FieldText, sdk.FieldDate, sdk.FieldSelect:
|
||||
_, ok := v.(string)
|
||||
return ok
|
||||
case sdk.FieldNumber:
|
||||
return isNumeric(v)
|
||||
case sdk.FieldSwitch:
|
||||
_, ok := v.(bool)
|
||||
return ok
|
||||
case sdk.FieldMultiSelect:
|
||||
return isStringList(v)
|
||||
case sdk.FieldKV:
|
||||
return isStringMap(v)
|
||||
default:
|
||||
// secret 已在 checkDefault 里被拒,走到这里说明字段类型闭集新增了成员而
|
||||
// defaultMatches 没跟上 —— 拒绝而不是放行。
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// defaultShapeOf 给出期望形态的人类可读说法(错误信息里用)。
|
||||
func defaultShapeOf(t sdk.FieldType) string {
|
||||
switch t {
|
||||
case sdk.FieldText, sdk.FieldDate, sdk.FieldSelect:
|
||||
return "字符串"
|
||||
case sdk.FieldNumber:
|
||||
return "数字"
|
||||
case sdk.FieldSwitch:
|
||||
return "布尔"
|
||||
case sdk.FieldMultiSelect:
|
||||
return "字符串数组"
|
||||
case sdk.FieldKV:
|
||||
return "键值对对象"
|
||||
default:
|
||||
return "与字段类型相容的值"
|
||||
}
|
||||
}
|
||||
|
||||
// isNumeric 报告 v 是不是数值。覆盖 Go 侧直接构造声明时可能用的整型(JSON 解码只会给
|
||||
// float64,但 SDK 是给插件作者用的 Go 包,不该逼他们写 20.0)。
|
||||
func isNumeric(v any) bool {
|
||||
switch v.(type) {
|
||||
case float64, float32, int, int8, int16, int32, int64,
|
||||
uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isStringList 报告 v 是不是字符串列表(JSON → []any,Go 直构 → []string)。
|
||||
func isStringList(v any) bool {
|
||||
switch items := v.(type) {
|
||||
case []string:
|
||||
return true
|
||||
case []any:
|
||||
for _, it := range items {
|
||||
if _, ok := it.(string); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isStringMap 报告 v 是不是键值对(JSON → map[string]any,Go 直构 → map[string]string)。
|
||||
func isStringMap(v any) bool {
|
||||
switch m := v.(type) {
|
||||
case map[string]string:
|
||||
return true
|
||||
case map[string]any:
|
||||
for _, val := range m {
|
||||
if _, ok := val.(string); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// fieldKeyDetail 是字段 key 形状的统一说明。
|
||||
func fieldKeyDetail(key string) string {
|
||||
return fmt.Sprintf(
|
||||
"字段 key %q 非法:只允许字母开头、后接字母数字或下划线(如 provider / apiKey),因为它同时是配置键",
|
||||
key)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package paneldecl
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件是「按角色过滤面板」的口径落点。依据 design/09 §5.4.1 与 §5.2.1 B:
|
||||
//
|
||||
// - 面板可见性与**数据面**都按 owner / admin / viewer 分级;
|
||||
// - 只读事实面对 viewer 可见;写动作一律 owner/admin(沿用 design/11 的 writeAuth);
|
||||
// - 逐 section 判 owner / admin / viewer(§5.2.1 B 的「鉴权与敏感策略」一行)。
|
||||
//
|
||||
// 设计没写清的处置**一律只收紧不放宽**(每一条都在下面注明),取舍记在 PLG1-report.md。
|
||||
|
||||
// filterPanel 按角色裁剪一个面板。第二个返回值为 false 表示该面板对该角色整体不可见。
|
||||
//
|
||||
// owner/admin 原样下发(写面可见);viewer 只留下只读区块,并把表格的行内动作摘掉。
|
||||
//
|
||||
// 收紧点(设计未写、按「只收紧不放宽」处理):
|
||||
// - 行内动作(rowActions)对 viewer 一律摘除 —— §5.4.1 说「写动作一律 owner/admin」,
|
||||
// 而行内动作就是写动作(示例:测试连通 / 重配凭据),声明里没有「只读动作」这种标记,
|
||||
// 所以一律按写处理;
|
||||
// - 一个区块都不剩时**整条入口不出现** —— 让 viewer 点进一个空页面等于把「没有权限」
|
||||
// 表达成「页面坏了」;隐藏入口不泄露任何信息(数据面另有鉴权)。
|
||||
func filterPanel(role auth.Role, panel sdk.Panel) (sdk.Panel, bool) {
|
||||
if auth.HasRole(role, auth.RoleAdmin) {
|
||||
return panel, true
|
||||
}
|
||||
out := panel
|
||||
out.Sections = make([]sdk.Section, 0, len(panel.Sections))
|
||||
for _, s := range panel.Sections {
|
||||
fs, ok := filterSectionForViewer(s)
|
||||
if ok {
|
||||
out.Sections = append(out.Sections, fs)
|
||||
}
|
||||
}
|
||||
if len(out.Sections) == 0 {
|
||||
return sdk.Panel{}, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// filterSectionForViewer 裁剪一个区块给 viewer 看。
|
||||
//
|
||||
// 保留:status / log / table(§5.4.1「只读事实面对 viewer 可见」)。
|
||||
// 丢弃:form —— 它是**唯一自带写面**的类型(submit.action),且在 §5.2.1 里对应
|
||||
// `section.form` 的「写」侧,对 viewer 没有只读形态可言(本模型没有「只读表单」这种声明)。
|
||||
func filterSectionForViewer(s sdk.Section) (sdk.Section, bool) {
|
||||
if !s.Type.ReadOnly() {
|
||||
return sdk.Section{}, false
|
||||
}
|
||||
out := s
|
||||
// 摘掉行内动作而不是丢掉整张表:列(事实)对 viewer 有用,按钮(写)不该出现。
|
||||
out.RowActions = nil
|
||||
return out, true
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package paneldecl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件锁定角色过滤(§5.4.1 角色门 + §5.2.1「逐 section 判 owner/admin/viewer」):
|
||||
// owner/admin 全量、viewer 只见只读事实面且不见任何写面。
|
||||
|
||||
// mixedPanel 是一份同时含写面与只读面的面板:form(写)+ table(行内动作)+ status + log。
|
||||
// 它是对「viewer 到底还能看见什么」最严格的一份夹具。
|
||||
func mixedPanel() sdk.Panel {
|
||||
p := samplePanel()
|
||||
p.Sections = append(p.Sections, statusPanel().Sections[0], logPanel().Sections[0])
|
||||
return p
|
||||
}
|
||||
|
||||
// sectionIDs 抽取区块 id 序列(顺序敏感)。
|
||||
func sectionIDs(p sdk.Panel) []string {
|
||||
ids := make([]string, 0, len(p.Sections))
|
||||
for _, s := range p.Sections {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// anySecretField 报告面板里是否还残留 secret 字段声明。
|
||||
func anySecretField(p sdk.Panel) bool {
|
||||
for _, s := range p.Sections {
|
||||
for _, f := range s.Fields {
|
||||
if f.Type.Sensitive() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// panelByID 在快照里找某个面板。
|
||||
func panelByID(t *testing.T, entries []Entry, id string) sdk.Panel {
|
||||
t.Helper()
|
||||
for _, e := range entries {
|
||||
if e.Panel.ID == id {
|
||||
return e.Panel
|
||||
}
|
||||
}
|
||||
t.Fatalf("快照里没有面板 %q(实际 %v)", id, mustPanelIDs(t, entries))
|
||||
return sdk.Panel{}
|
||||
}
|
||||
|
||||
// TestList_OwnerAndAdminSeeEverything 锁定上半段:owner/admin 拿到完整声明(写面、secret
|
||||
// 字段、行内动作一个不少)—— 过滤只对 viewer 收紧,不能顺手把手握写权的人也削了。
|
||||
func TestList_OwnerAndAdminSeeEverything(t *testing.T) {
|
||||
t.Parallel()
|
||||
cat := newTestCatalog(t, alwaysEnabled)
|
||||
mustRegister(t, cat, "web-search", mixedPanel())
|
||||
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
|
||||
role := role
|
||||
t.Run(string(role), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
entries, err := cat.List(role)
|
||||
if err != nil {
|
||||
t.Fatalf("List(%s): %v", role, err)
|
||||
}
|
||||
got := panelByID(t, entries, "providers")
|
||||
if len(got.Sections) != 4 {
|
||||
t.Fatalf("%s 看到 %d 个区块,期望 4", role, len(got.Sections))
|
||||
}
|
||||
if !anySecretField(got) {
|
||||
t.Fatalf("%s 应当看得到 secret 字段声明(读侧掩码是数据面的事,不是声明面)", role)
|
||||
}
|
||||
if got.Sections[0].Submit == nil {
|
||||
t.Fatalf("%s 应当看得到 form 的 submit", role)
|
||||
}
|
||||
if len(got.Sections[1].RowActions) != 2 {
|
||||
t.Fatalf("%s 应当看得到 2 个行内动作,实际 %d", role, len(got.Sections[1].RowActions))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestList_ViewerLosesWriteSurfaces 锁定下半段:viewer 只见只读区块,form 整体消失
|
||||
// (连带 secret 写面),表格保留但行内动作被摘掉。
|
||||
func TestList_ViewerLosesWriteSurfaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
cat := newTestCatalog(t, alwaysEnabled)
|
||||
mustRegister(t, cat, "web-search", mixedPanel())
|
||||
|
||||
entries, err := cat.List(auth.RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("List(viewer): %v", err)
|
||||
}
|
||||
got := panelByID(t, entries, "providers")
|
||||
if ids := sectionIDs(got); len(ids) != 3 || ids[0] != "list" {
|
||||
t.Fatalf("viewer 的区块 = %v,期望 [list chain recent](form 整体消失)", ids)
|
||||
}
|
||||
for _, s := range got.Sections {
|
||||
if s.Type == sdk.SectionForm {
|
||||
t.Fatal("viewer 不该看到 form(它是唯一自带写面的区块类型)")
|
||||
}
|
||||
if s.Submit != nil || len(s.RowActions) != 0 {
|
||||
t.Fatalf("viewer 不该看到写面:submit=%v rowActions=%v", s.Submit, s.RowActions)
|
||||
}
|
||||
}
|
||||
if anySecretField(got) {
|
||||
t.Fatal("viewer 不该看到 secret 字段(它是写面字段)")
|
||||
}
|
||||
// 事实本身要留着:列还在(否则「只读事实面对 viewer 可见」就成了空话)。
|
||||
table := got.Sections[0]
|
||||
if len(table.Columns) != 2 || table.Source == nil {
|
||||
t.Fatalf("viewer 应当看得到表格的列与只读数据源:%+v", table)
|
||||
}
|
||||
}
|
||||
|
||||
// TestList_ViewerHidesEmptyEntrances 锁定收紧点:一个只读区块都不剩时,入口整个不出现 ——
|
||||
// 让 viewer 点进空页面等于把「没有权限」表达成「页面坏了」。
|
||||
func TestList_ViewerHidesEmptyEntrances(t *testing.T) {
|
||||
t.Parallel()
|
||||
cat := newTestCatalog(t, alwaysEnabled)
|
||||
writeOnly := samplePanel()
|
||||
// 只留 form 一段 ⇒ 对 viewer 一个只读区块都不剩。
|
||||
writeOnly.Sections = writeOnly.Sections[:1]
|
||||
mustRegister(t, cat, "web-search", writeOnly)
|
||||
|
||||
entries, err := cat.List(auth.RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("List(viewer): %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("纯写面板对 viewer 应当整条不出现,实际 %v", mustPanelIDs(t, entries))
|
||||
}
|
||||
|
||||
// 直接对 filterPanel 再锁一次返回值语义(避免只靠 List 的一层包装)。
|
||||
if _, visible := filterPanel(auth.RoleViewer, writeOnly); visible {
|
||||
t.Fatal("纯写面板对 viewer 应当不可见")
|
||||
}
|
||||
if got, visible := filterPanel(auth.RoleAdmin, writeOnly); !visible || len(got.Sections) != 1 {
|
||||
t.Fatalf("admin 应当可见且区块不丢:visible=%v sections=%d", visible, len(got.Sections))
|
||||
}
|
||||
}
|
||||
|
||||
// TestList_ViewerKeepsReadOnlyTables 锁定「表格保留、动作摘除」这一半:不能因为表里有写动作
|
||||
// 就把整张表藏掉(那样 viewer 连事实都看不到)。
|
||||
func TestList_ViewerKeepsReadOnlyTables(t *testing.T) {
|
||||
t.Parallel()
|
||||
cat := newTestCatalog(t, alwaysEnabled)
|
||||
table := samplePanel()
|
||||
table.Sections = table.Sections[1:]
|
||||
mustRegister(t, cat, "web-search", table)
|
||||
|
||||
entries, err := cat.List(auth.RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("List(viewer): %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("带行内动作的表格面板对 viewer 仍应可见,实际 %v", mustPanelIDs(t, entries))
|
||||
}
|
||||
got := entries[0].Panel
|
||||
if len(got.Sections) != 1 || len(got.Sections[0].Columns) != 2 {
|
||||
t.Fatalf("viewer 应当看到表格与列:%+v", got.Sections)
|
||||
}
|
||||
if got.Sections[0].RowActions != nil {
|
||||
t.Fatalf("viewer 的行内动作应当被摘成 nil,实际 %+v", got.Sections[0].RowActions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package paneldecl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件是测试夹具:一份**逐字对齐 design/09 §5.2 示例**的合法声明。
|
||||
//
|
||||
// 为什么所有非法用例都从它派生:只有这样才能保证「被拒的是那一处改动,而不是碰巧别处也不
|
||||
// 合法」—— 否则一个用例变红,无法区分是目标规则生效还是夹具本身有毛病。
|
||||
|
||||
// boolPtr 是 &true / &false 的简写(sdk.When.Enabled 是三态指针)。
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// floatPtr 是 &x 的简写(sdk.Field.Min / Max)。
|
||||
func floatPtr(v float64) *float64 { return &v }
|
||||
|
||||
// samplePanel 返回 §5.2 示例里的面板(web-search 的 providers 页):form + table 两段,
|
||||
// 覆盖 secret / select(optsFrom) / text 三种字段与两个行内动作。
|
||||
func samplePanel() sdk.Panel {
|
||||
return sdk.Panel{
|
||||
ID: "providers",
|
||||
Title: "搜索提供商",
|
||||
Group: sdk.GroupPlugins,
|
||||
Order: 20,
|
||||
Icon: "search",
|
||||
When: &sdk.When{Enabled: boolPtr(true)},
|
||||
Sections: []sdk.Section{
|
||||
{
|
||||
Type: sdk.SectionForm,
|
||||
ID: "add",
|
||||
Title: "添加提供商",
|
||||
Fields: []sdk.Field{
|
||||
{Key: "provider", Label: "提供商", Type: sdk.FieldSelect,
|
||||
OptionsFrom: "plugin:providers", Required: true},
|
||||
{Key: "apiKey", Label: "API Key", Type: sdk.FieldSecret, Required: true},
|
||||
{Key: "endpoint", Label: "接口地址", Type: sdk.FieldText, Placeholder: "https://..."},
|
||||
},
|
||||
Submit: &sdk.Submit{Action: "plugin.saveProvider"},
|
||||
},
|
||||
{
|
||||
Type: sdk.SectionTable,
|
||||
ID: "list",
|
||||
Title: "已配置的提供商",
|
||||
Source: &sdk.Source{Action: "plugin.listProviders"},
|
||||
Columns: []sdk.Column{
|
||||
{Key: "provider", Label: "提供商"},
|
||||
{Key: "state", Label: "状态"},
|
||||
},
|
||||
RowActions: []sdk.RowAction{
|
||||
{ID: "test", Label: "测试连通", Action: "plugin.testProvider"},
|
||||
{ID: "reauth", Label: "重配凭据", Action: "plugin.reauthProvider"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// statusPanel 是只读事实面夹具(§5.2.1 section.status)。
|
||||
func statusPanel() sdk.Panel {
|
||||
return sdk.Panel{
|
||||
ID: "chain",
|
||||
Title: "降级链",
|
||||
Group: sdk.GroupPlugins,
|
||||
Order: 10,
|
||||
Sections: []sdk.Section{{
|
||||
Type: sdk.SectionStatus,
|
||||
ID: "chain",
|
||||
Title: "当前生效的降级链",
|
||||
Fields: []sdk.Field{
|
||||
{Key: "active", Label: "实际应答者", Type: sdk.FieldText},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// logPanel 是只读日志卡夹具(§5.2.1 section.log)。
|
||||
func logPanel() sdk.Panel {
|
||||
return sdk.Panel{
|
||||
ID: "trace",
|
||||
Title: "最近调用",
|
||||
Group: sdk.GroupPlugins,
|
||||
Order: 30,
|
||||
Sections: []sdk.Section{{
|
||||
Type: sdk.SectionLog,
|
||||
ID: "recent",
|
||||
Title: "最近 N 条",
|
||||
Source: &sdk.Source{Action: "plugin.recentCalls"},
|
||||
Limit: 50,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// mustPanelIDs 抽取目录快照里的面板 id 序列(顺序敏感,用于稳定序断言)。
|
||||
func mustPanelIDs(t *testing.T, entries []Entry) []string {
|
||||
t.Helper()
|
||||
ids := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
ids = append(ids, e.Panel.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// alwaysEnabled / neverEnabled 是两种最简 Resolver 桩。
|
||||
func alwaysEnabled(string) Context { return Context{Enabled: true} }
|
||||
func neverEnabled(string) Context { return Context{} }
|
||||
@@ -0,0 +1,182 @@
|
||||
package paneldecl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件是「形状」层:字符集、长度上限、以及 §5.2.1 接入点表的键约束。
|
||||
//
|
||||
// 为什么要限定字符集而不是照单全收:面板 id 与插件 id 要拼成路由
|
||||
// /admin/plugin/<pluginId>/<panelId>(§5.7 C)与端点 /api/v1/admin/plugins/{id}/actions/{action}
|
||||
// (§5.2.1),「能直接当路径段用」是硬要求;图标名与动作名则要防「声明里塞 URL/SVG」
|
||||
// (§5.4.4 不给插件开 SSRF 面)。
|
||||
|
||||
// 长度上限。都是「撑坏 Miuix 布局」与「URL/日志行不可读」两类实际后果倒推出来的,不是随手取的数。
|
||||
const (
|
||||
// idMaxLen 是 id 长度上限。既有插件 id 是 32 位十六进制(runtimeconfig.NewID),
|
||||
// 面板/区块 id 是短名字,64 给前者留了一倍余量。
|
||||
idMaxLen = 64
|
||||
// titleMaxRunes 是标题/标签文案上限。侧边栏与页面壳的排版宽度决定它,超出即截断,
|
||||
// 200 个字符足够任何语言的正常标题。
|
||||
titleMaxRunes = 200
|
||||
// hintMaxRunes 是 placeholder / description 这类辅助文案上限,比标题宽一档
|
||||
// (接口地址、说明段落会更长)。
|
||||
hintMaxRunes = 500
|
||||
// iconMaxLen 是图标名上限。宿主内置图标名的量级是十几个字符,64 已极宽。
|
||||
iconMaxLen = 64
|
||||
// actionMaxLen 是动作名上限。示例 plugin.reauthProvider 是 21 字符,64 足够。
|
||||
actionMaxLen = 64
|
||||
// maxLogLimit 是 log 区块 `limit` 的上限。log 是「只读最近 N 条」的排障卡,
|
||||
// 一次拉上万条会把页面卡死;1000 与既有日志尾读的量级一致。
|
||||
maxLogLimit = 1000
|
||||
)
|
||||
|
||||
// 编译期正则。包级 var 存的是**不可变**的编译产物(与 internal/runtime/memory、
|
||||
// internal/runtime/tool 等既有文件同一手法),不是可变全局状态。
|
||||
var (
|
||||
// idRe 是 id(插件 / 面板 / 区块 / 行内动作)的形状:小写字母数字,可含中划线分段。
|
||||
// 依据 §5.2 示例 web-search / providers / add / list / test / reauth,并禁止大小写混用
|
||||
// (URL 路径段大小写敏感,混用会引入难查的 404)。
|
||||
idRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
// fieldKeyRe 是字段 key 的形状:§5.2 示例是 provider / apiKey / endpoint —— 小驼峰,
|
||||
// 因为它同时是**配置键**(写进插件配置段),比 id 宽一档:允许大写与下划线。
|
||||
fieldKeyRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
|
||||
// optionsFromRe 是下拉选项来源:只允许 `plugin:` 前缀的名字(§5.2 示例 plugin:providers)。
|
||||
// 禁止 `/`、第二个 `:` 与空白 ⇒ "http://…" 这类 URL 声明在校验期就被拒。
|
||||
optionsFromRe = regexp.MustCompile(`^plugin:[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$`)
|
||||
// actionRe 是动作名:§5.2 示例 plugin.saveProvider / plugin.listProviders / plugin.testProvider。
|
||||
// 允许点分名,**禁止** `/` 与 `:` —— 动作只是名字,不是 URL(§5.4.4)。
|
||||
actionRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*$`)
|
||||
// iconRe 是图标名:宿主内置图标表的名字,禁止任何标点(尤其 `<` / `{` / `;`),
|
||||
// 顺带堵死「把 SVG/CSS 片段塞进 icon」这条路。
|
||||
iconRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
|
||||
)
|
||||
|
||||
// requirement 是某个键在某种 section 类型上的必要性。
|
||||
type requirement uint8
|
||||
|
||||
const (
|
||||
// notAllowed:该键在这类区块上没有意义,出现即形状错误。
|
||||
notAllowed requirement = iota
|
||||
// optional:可以出现,可以不出现。
|
||||
optional
|
||||
// required:必须出现(缺 ⇒ 必填缺失)。
|
||||
required
|
||||
// writeForbidden:这是**写面**键,出现在只读区块上即越界(§5.4.1 写动作一律 owner/admin)。
|
||||
writeForbidden
|
||||
)
|
||||
|
||||
// sectionShape 是 §5.2.1 接入点表的可执行形式:每种区块消费哪些键、哪些是写面。
|
||||
//
|
||||
// 为什么要把这张表写成硬校验:宿主是「按声明泛化渲染」的,一个 form 没有 submit、
|
||||
// 一个 table 没有 columns,到前端就是**半渲染**(区块在、功能不在)—— 正是 §5.2.1
|
||||
// 点名要防的。所以形状错误在声明期整体拒掉该面板,并显式报错。
|
||||
type sectionShape struct {
|
||||
fields requirement
|
||||
submit requirement
|
||||
source requirement
|
||||
columns requirement
|
||||
rowActions requirement
|
||||
limit requirement
|
||||
}
|
||||
|
||||
// shapeOf 返回某类区块的键约束(§5.2.1 的 section.form / section.table / section.status /
|
||||
// section.log 四行)。写成函数而不是包级 map:本仓禁包级可变全局,且 switch 让「新增
|
||||
// section 类型忘了补形状」在 review 时一眼可见。
|
||||
func shapeOf(t sdk.SectionType) sectionShape {
|
||||
switch t {
|
||||
case sdk.SectionForm:
|
||||
return sectionShape{fields: required, submit: required}
|
||||
case sdk.SectionTable:
|
||||
return sectionShape{
|
||||
columns: required, source: required,
|
||||
rowActions: optional, submit: writeForbidden,
|
||||
}
|
||||
case sdk.SectionStatus:
|
||||
return sectionShape{
|
||||
fields: required, submit: writeForbidden, rowActions: writeForbidden,
|
||||
}
|
||||
case sdk.SectionLog:
|
||||
return sectionShape{
|
||||
source: required, limit: optional, submit: writeForbidden, rowActions: writeForbidden,
|
||||
}
|
||||
default:
|
||||
return sectionShape{}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeGroup 把空分组按 §5.2「本期固定 plugins」回落到唯一分组。
|
||||
//
|
||||
// 为什么把空值当「用默认」而不是「必填缺失」:分组本期只有闭集里的一个成员,缺省不存在
|
||||
// 歧义;而四处要求插件作者重复写同一个常量只会制造漏写。非空但不是闭集成员的仍然拒绝。
|
||||
func normalizeGroup(g sdk.GroupID) sdk.GroupID {
|
||||
if strings.TrimSpace(string(g)) == "" {
|
||||
return sdk.GroupPlugins
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// validID 报告 id 是否满足路径段形状。
|
||||
func validID(id string) bool {
|
||||
return len(id) > 0 && len(id) <= idMaxLen && idRe.MatchString(id)
|
||||
}
|
||||
|
||||
// validFieldKey 报告字段 key 是否满足「配置键」形状。
|
||||
func validFieldKey(key string) bool { return key != "" && fieldKeyRe.MatchString(key) }
|
||||
|
||||
// validAction 报告动作名是否合法。
|
||||
func validAction(action string) bool {
|
||||
return len(action) > 0 && len(action) <= actionMaxLen && actionRe.MatchString(action)
|
||||
}
|
||||
|
||||
// validIcon 报告图标名是否合法。
|
||||
func validIcon(icon string) bool {
|
||||
return len(icon) > 0 && len(icon) <= iconMaxLen && iconRe.MatchString(icon)
|
||||
}
|
||||
|
||||
// checkOptionalText 校验一段可选文案:允许缺省,但不允许「只有空白」、控制字符、
|
||||
// 或超过 maxRunes(长度超限会把侧边栏/页面壳排版撑坏,属实际后果而非洁癖)。
|
||||
func checkOptionalText(value, path string, maxRunes int) []Violation {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return []Violation{violation(ErrInvalidText, path, "文案不能只有空白字符")}
|
||||
}
|
||||
if n := utf8.RuneCountInString(trimmed); n > maxRunes {
|
||||
return []Violation{violation(ErrInvalidText, path,
|
||||
fmt.Sprintf("文案长度 %d 超过上限 %d", n, maxRunes))}
|
||||
}
|
||||
if r := firstControlRune(trimmed); r >= 0 {
|
||||
return []Violation{violation(ErrInvalidText, path,
|
||||
fmt.Sprintf("文案含控制字符 %q(前端渲染与日志都会被污染)", r))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRequiredText 校验一段必填文案:缺省即必填缺失,其余同 checkOptionalText。
|
||||
func checkRequiredText(value, path string, maxRunes int) []Violation {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return []Violation{violation(ErrMissingField, path, "必填文案缺失或全为空白")}
|
||||
}
|
||||
return checkOptionalText(value, path, maxRunes)
|
||||
}
|
||||
|
||||
// firstControlRune 返回第一个控制字符,没有则 -1。
|
||||
func firstControlRune(s string) rune {
|
||||
for _, r := range s {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// panelPath 是面板在声明里的索引式路径前缀(§5.2 的 adminPanels 数组)。
|
||||
func panelPath(i int) string { return fmt.Sprintf("adminPanels[%d]", i) }
|
||||
@@ -0,0 +1,252 @@
|
||||
package paneldecl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件是声明校验的入口与面板/区块层(§5.2.1 B「声明校验」)。字段层在 fieldcheck.go。
|
||||
|
||||
// Validate 校验一个插件的完整声明:插件 id 形状 + 逐面板校验 + 插件内 id 唯一性。
|
||||
//
|
||||
// 它**只读**:不修补、不改写传入的声明(要存进目录的值由 Register 负责 normalize)。
|
||||
// 返回 nil 或 *Errors(聚合了**逐条**可分类的违规)。插件 id 非法时仍继续逐面板校验,
|
||||
// 让调用方一次拿到全部问题 —— 逐轮报错会让插件作者改一轮跑一轮。
|
||||
func Validate(pluginID string, panels []sdk.Panel) error {
|
||||
var vs []Violation
|
||||
vs = append(vs, checkID(pluginID, "pluginID", "插件 id", ErrInvalidPluginID)...)
|
||||
seen := make(map[string]bool, len(panels))
|
||||
for i, p := range panels {
|
||||
path := panelPath(i)
|
||||
pvs := validatePanelAt(p, path)
|
||||
if p.ID != "" {
|
||||
if seen[p.ID] {
|
||||
pvs = append(pvs, dupViolation(path+".id", "本插件", "面板 id", p.ID))
|
||||
}
|
||||
seen[p.ID] = true
|
||||
}
|
||||
vs = append(vs, pvs...)
|
||||
}
|
||||
return violationsToError(vs)
|
||||
}
|
||||
|
||||
// ValidatePanel 校验单个面板,index 是它在插件声明数组里的下标(只用于错误路径)。
|
||||
//
|
||||
// 它存在的理由是 §5.2.1 的拒绝粒度:非法声明「该面板不注册」,所以插件管理器需要**按面板**
|
||||
// 判定,而不是只能整插件一起拒。
|
||||
func ValidatePanel(panel sdk.Panel, index int) error {
|
||||
return violationsToError(validatePanelAt(panel, panelPath(index)))
|
||||
}
|
||||
|
||||
// validatePanelAt 校验一个面板及其全部区块。path 是索引式前缀。
|
||||
func validatePanelAt(p sdk.Panel, path string) []Violation {
|
||||
var vs []Violation
|
||||
vs = append(vs, checkID(p.ID, path+".id", "面板 id", ErrInvalidPanelID)...)
|
||||
vs = append(vs, checkRequiredText(p.Title, path+".title", titleMaxRunes)...)
|
||||
vs = append(vs, checkOptionalText(p.Description, path+".description", hintMaxRunes)...)
|
||||
if g := normalizeGroup(p.Group); !g.Valid() {
|
||||
vs = append(vs, violation(ErrUnknownGroup, path+".group",
|
||||
fmt.Sprintf("未知分组 %q(本期分组闭集只有 %q,见 SDK 的 GroupID)", p.Group, sdk.GroupPlugins)))
|
||||
}
|
||||
if p.Order < 0 {
|
||||
vs = append(vs, violation(ErrInvalidOrder, path+".order",
|
||||
fmt.Sprintf("order 必须 >= 0(小在前),得到 %d", p.Order)))
|
||||
}
|
||||
if p.Icon != "" && !validIcon(p.Icon) {
|
||||
vs = append(vs, violation(ErrInvalidIcon, path+".icon",
|
||||
fmt.Sprintf("图标名 %q 非法:只允许宿主内置图标名(字母数字与中划线/下划线),不接受 URL 或 SVG 片段", p.Icon)))
|
||||
}
|
||||
vs = append(vs, validateSections(p.Sections, path)...)
|
||||
return vs
|
||||
}
|
||||
|
||||
// checkID 校验一个 id 类取值:空 ⇒ 必填缺失;形状不符 ⇒ 该 id 种类的分类错误。
|
||||
func checkID(id, path, label string, kind error) []Violation {
|
||||
if id == "" {
|
||||
return []Violation{violation(ErrMissingField, path, label+" 必填(空值无法构成路由段/端点段)")}
|
||||
}
|
||||
if !validID(id) {
|
||||
return []Violation{violation(kind, path, fmt.Sprintf(
|
||||
"%s %q 非法:只允许小写字母数字与中划线分段(如 providers / web-search),长度 ≤ %d",
|
||||
label, id, idMaxLen))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSections 校验面板的区块列表,并保证区块 id 在面板内唯一。
|
||||
func validateSections(sections []sdk.Section, panelPrefix string) []Violation {
|
||||
var vs []Violation
|
||||
seen := make(map[string]bool, len(sections))
|
||||
for i, s := range sections {
|
||||
path := fmt.Sprintf("%s.sections[%d]", panelPrefix, i)
|
||||
vs = append(vs, validateSectionAt(s, path)...)
|
||||
if s.ID == "" {
|
||||
continue
|
||||
}
|
||||
if seen[s.ID] {
|
||||
vs = append(vs, dupViolation(path+".id", "本面板", "区块 id", s.ID))
|
||||
continue
|
||||
}
|
||||
seen[s.ID] = true
|
||||
}
|
||||
return vs
|
||||
}
|
||||
|
||||
// validateSectionAt 校验一个区块:类型闭集 → id/标题 → 形状矩阵 → 载荷(字段/列/动作)。
|
||||
func validateSectionAt(s sdk.Section, path string) []Violation {
|
||||
if !s.Type.Valid() {
|
||||
// 类型不在闭集 ⇒ 形状矩阵无从谈起。只报这一条:继续按未知类型推导出的
|
||||
// 「缺 submit / 缺 columns」全是噪音,会把真正的病因埋掉。
|
||||
return []Violation{violation(ErrUnknownSectionType, path+".type",
|
||||
fmt.Sprintf("未知 section 类型 %q(闭集:form|table|status|log)", s.Type))}
|
||||
}
|
||||
var vs []Violation
|
||||
vs = append(vs, checkID(s.ID, path+".id", "区块 id", ErrInvalidSectionID)...)
|
||||
vs = append(vs, checkRequiredText(s.Title, path+".title", titleMaxRunes)...)
|
||||
vs = append(vs, checkSectionShape(s, path)...)
|
||||
vs = append(vs, validateSectionPayload(s, path)...)
|
||||
return vs
|
||||
}
|
||||
|
||||
// checkSectionShape 按 §5.2.1 的接入点表检查「哪些键必须出现 / 不许出现」。
|
||||
func checkSectionShape(s sdk.Section, path string) []Violation {
|
||||
sh := shapeOf(s.Type)
|
||||
// 六个键 × 四种必要性。写成表是为了让每个键的判定逐行可读、加键时只改一处。
|
||||
keys := []struct {
|
||||
key string
|
||||
req requirement
|
||||
has bool
|
||||
}{
|
||||
{"fields", sh.fields, len(s.Fields) > 0},
|
||||
{"submit", sh.submit, s.Submit != nil},
|
||||
{"source", sh.source, s.Source != nil},
|
||||
{"columns", sh.columns, len(s.Columns) > 0},
|
||||
{"rowActions", sh.rowActions, len(s.RowActions) > 0},
|
||||
{"limit", sh.limit, s.Limit != 0},
|
||||
}
|
||||
var vs []Violation
|
||||
for _, k := range keys {
|
||||
kp := path + "." + k.key
|
||||
switch k.req {
|
||||
case required:
|
||||
if !k.has {
|
||||
vs = append(vs, violation(ErrMissingField, kp,
|
||||
fmt.Sprintf("%s 区块必须声明 %s", s.Type, k.key)))
|
||||
}
|
||||
case notAllowed:
|
||||
if k.has {
|
||||
vs = append(vs, violation(ErrSectionShape, kp,
|
||||
fmt.Sprintf("%s 区块不消费 %s(形状见 §5.2.1 的接入点表)", s.Type, k.key)))
|
||||
}
|
||||
case writeForbidden:
|
||||
if k.has {
|
||||
vs = append(vs, violation(ErrReadOnlySection, kp,
|
||||
fmt.Sprintf("%s 是只读区块,不得声明写面 %s(§5.4.1:写动作一律 owner/admin)", s.Type, k.key)))
|
||||
}
|
||||
case optional:
|
||||
}
|
||||
}
|
||||
return vs
|
||||
}
|
||||
|
||||
// validateSectionPayload 校验区块内各元素的载荷(动作名、字段、列、行内动作、limit)。
|
||||
func validateSectionPayload(s sdk.Section, path string) []Violation {
|
||||
var vs []Violation
|
||||
switch s.Type {
|
||||
case sdk.SectionForm, sdk.SectionStatus:
|
||||
vs = append(vs, validateFields(s.Fields, path)...)
|
||||
}
|
||||
if s.Submit != nil {
|
||||
vs = append(vs, checkAction(s.Submit.Action, path+".submit.action")...)
|
||||
}
|
||||
if s.Source != nil {
|
||||
vs = append(vs, checkAction(s.Source.Action, path+".source.action")...)
|
||||
}
|
||||
if s.Type == sdk.SectionTable {
|
||||
vs = append(vs, validateColumns(s.Columns, path)...)
|
||||
vs = append(vs, validateRowActions(s.RowActions, path)...)
|
||||
}
|
||||
if s.Type == sdk.SectionLog {
|
||||
vs = append(vs, checkLimit(s.Limit, path+".limit")...)
|
||||
}
|
||||
return vs
|
||||
}
|
||||
|
||||
// checkAction 校验动作名。依据 §5.4.4:插件只给**动作名**、不给 URL,所以这里连
|
||||
// `:` 与 `/` 都不接受 —— 想塞请求地址的声明在此被拒。空动作名是必填缺失(有 submit /
|
||||
// source 却没动作,前端点下去什么都不会发生,即半渲染)。
|
||||
func checkAction(action, path string) []Violation {
|
||||
if action == "" {
|
||||
return []Violation{violation(ErrMissingField, path, "动作名必填(插件只给动作名,不给 URL)")}
|
||||
}
|
||||
if !validAction(action) {
|
||||
return []Violation{violation(ErrInvalidAction, path, fmt.Sprintf(
|
||||
"动作名 %q 非法:只允许点分标识(如 plugin.saveProvider),不得含 `/` 或 `:` 等 URL 成分",
|
||||
action))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkLimit 校验 log 区块的 limit。0 表示未声明(由宿主取默认),非 0 必须在 (0, maxLogLimit]。
|
||||
func checkLimit(limit int, path string) []Violation {
|
||||
if limit == 0 {
|
||||
return nil
|
||||
}
|
||||
if limit < 0 || limit > maxLogLimit {
|
||||
return []Violation{violation(ErrInvalidLimit, path,
|
||||
fmt.Sprintf("limit 必须在 (0, %d] 内(log 是排障用的只读尾读,超大值会把页面卡死),得到 %d",
|
||||
maxLogLimit, limit))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateColumns 校验表格列:key 形状与唯一性、label 文案。
|
||||
func validateColumns(cols []sdk.Column, prefix string) []Violation {
|
||||
var vs []Violation
|
||||
seen := make(map[string]bool, len(cols))
|
||||
for i, c := range cols {
|
||||
path := fmt.Sprintf("%s.columns[%d]", prefix, i)
|
||||
if c.Key == "" {
|
||||
vs = append(vs, violation(ErrMissingField, path+".key", "列 key 必填(它是行数据字段名与列的身份)"))
|
||||
} else if !validFieldKey(c.Key) {
|
||||
vs = append(vs, violation(ErrInvalidFieldKey, path+".key", fieldKeyDetail(c.Key)))
|
||||
} else if seen[c.Key] {
|
||||
vs = append(vs, dupViolation(path+".key", "本表格", "列 key", c.Key))
|
||||
} else {
|
||||
seen[c.Key] = true
|
||||
}
|
||||
vs = append(vs, checkOptionalText(c.Label, path+".label", titleMaxRunes)...)
|
||||
}
|
||||
return vs
|
||||
}
|
||||
|
||||
// validateRowActions 校验行内动作:id 形状与唯一性、label 必填、动作名合法。
|
||||
func validateRowActions(actions []sdk.RowAction, prefix string) []Violation {
|
||||
var vs []Violation
|
||||
seen := make(map[string]bool, len(actions))
|
||||
for i, a := range actions {
|
||||
path := fmt.Sprintf("%s.rowActions[%d]", prefix, i)
|
||||
vs = append(vs, checkID(a.ID, path+".id", "行内动作 id", ErrInvalidRowActionID)...)
|
||||
// label 必填且**没有可回落的东西**:不像列 label 能回落到 key,没有文字的行内按钮
|
||||
// 就是半渲染。
|
||||
vs = append(vs, checkRequiredText(a.Label, path+".label", titleMaxRunes)...)
|
||||
vs = append(vs, checkAction(a.Action, path+".action")...)
|
||||
if a.ID == "" {
|
||||
continue
|
||||
}
|
||||
if seen[a.ID] {
|
||||
vs = append(vs, dupViolation(path+".id", "本表格", "行内动作 id", a.ID))
|
||||
continue
|
||||
}
|
||||
seen[a.ID] = true
|
||||
}
|
||||
return vs
|
||||
}
|
||||
|
||||
// dupViolation 生成「同作用域内重复」的违规。三处调用共用同一措辞,避免同一类问题在
|
||||
// 不同位置报出不同说法。
|
||||
func dupViolation(path, scope, kind, value string) Violation {
|
||||
return violation(ErrDuplicateID, path,
|
||||
fmt.Sprintf("%s %q 在%s内重复(重复会让入口/锚点/提交键无法区分)", kind, value, scope))
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package paneldecl
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件是区块层的拒绝用例:类型闭集、id/标题必填、以及 §5.2.1 接入点表的形状矩阵
|
||||
// (哪些键必须出现、哪些不许出现、哪些是只读区块上的写面)。
|
||||
|
||||
// rejectCasesSectionCommon 是区块的通用必填与唯一性。
|
||||
func rejectCasesSectionCommon() []rejectCase {
|
||||
return []rejectCase{
|
||||
{
|
||||
name: "区块类型不在闭集",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Type = "chart" })
|
||||
},
|
||||
want: ErrUnknownSectionType, wantPath: "adminPanels[0].sections[0].type",
|
||||
},
|
||||
{
|
||||
name: "区块 id 缺失",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].ID = "" })
|
||||
},
|
||||
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].id",
|
||||
},
|
||||
{
|
||||
name: "区块标题缺失",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Title = "" })
|
||||
},
|
||||
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].title",
|
||||
},
|
||||
{
|
||||
name: "区块 id 在面板内重复",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].ID = "add" })
|
||||
},
|
||||
want: ErrDuplicateID, wantPath: "adminPanels[0].sections[1].id",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// rejectCasesFormShape 是 form 区块的形状(§5.2.1 section.form 只消费 fields[] + submit.action)。
|
||||
func rejectCasesFormShape() []rejectCase {
|
||||
return []rejectCase{
|
||||
{
|
||||
name: "form 缺 submit",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Submit = nil })
|
||||
},
|
||||
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].submit",
|
||||
},
|
||||
{
|
||||
name: "form 缺 fields",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields = nil })
|
||||
},
|
||||
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].fields",
|
||||
},
|
||||
{
|
||||
name: "form 声明 source(形状错误)",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) {
|
||||
p.Sections[0].Source = &sdk.Source{Action: "plugin.listProviders"}
|
||||
})
|
||||
},
|
||||
want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].source",
|
||||
},
|
||||
{
|
||||
name: "form 声明 columns(形状错误)",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) {
|
||||
p.Sections[0].Columns = []sdk.Column{{Key: "k"}}
|
||||
})
|
||||
},
|
||||
want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].columns",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// rejectCasesTableShape 是 table 区块的形状(§5.2.1 section.table 消费 columns[]/rowActions[]/source)。
|
||||
func rejectCasesTableShape() []rejectCase {
|
||||
return []rejectCase{
|
||||
{
|
||||
name: "table 缺 columns",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns = nil })
|
||||
},
|
||||
want: ErrMissingField, wantPath: "adminPanels[0].sections[1].columns",
|
||||
},
|
||||
{
|
||||
name: "table 缺 source",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Source = nil })
|
||||
},
|
||||
want: ErrMissingField, wantPath: "adminPanels[0].sections[1].source",
|
||||
},
|
||||
{
|
||||
name: "table 上出现写面 submit",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) {
|
||||
p.Sections[1].Submit = &sdk.Submit{Action: "plugin.saveProvider"}
|
||||
})
|
||||
},
|
||||
want: ErrReadOnlySection, wantPath: "adminPanels[0].sections[1].submit",
|
||||
},
|
||||
{
|
||||
name: "table 声明字段(形状错误)",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) {
|
||||
p.Sections[1].Fields = []sdk.Field{{Key: "state", Type: sdk.FieldText}}
|
||||
})
|
||||
},
|
||||
want: ErrSectionShape, wantPath: "adminPanels[0].sections[1].fields",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// rejectCasesReadOnlySections 是只读区块(status / log)的写面与形状。
|
||||
func rejectCasesReadOnlySections() []rejectCase {
|
||||
return []rejectCase{
|
||||
{
|
||||
name: "status 上出现写面 submit",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(statusPanel(), func(p *sdk.Panel) {
|
||||
p.Sections[0].Submit = &sdk.Submit{Action: "plugin.saveProvider"}
|
||||
})
|
||||
},
|
||||
want: ErrReadOnlySection, wantPath: "adminPanels[0].sections[0].submit",
|
||||
},
|
||||
{
|
||||
name: "status 上出现行内动作",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(statusPanel(), func(p *sdk.Panel) {
|
||||
p.Sections[0].RowActions = []sdk.RowAction{{ID: "test", Label: "测试", Action: "plugin.t"}}
|
||||
})
|
||||
},
|
||||
want: ErrReadOnlySection, wantPath: "adminPanels[0].sections[0].rowActions",
|
||||
},
|
||||
{
|
||||
name: "status 声明 source(形状错误)",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(statusPanel(), func(p *sdk.Panel) {
|
||||
p.Sections[0].Source = &sdk.Source{Action: "plugin.chain"}
|
||||
})
|
||||
},
|
||||
want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].source",
|
||||
},
|
||||
{
|
||||
name: "log 缺 source",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(logPanel(), func(p *sdk.Panel) { p.Sections[0].Source = nil })
|
||||
},
|
||||
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].source",
|
||||
},
|
||||
{
|
||||
name: "log 的 limit 越界",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(logPanel(), func(p *sdk.Panel) { p.Sections[0].Limit = 5000 })
|
||||
},
|
||||
want: ErrInvalidLimit, wantPath: "adminPanels[0].sections[0].limit",
|
||||
},
|
||||
{
|
||||
name: "log 声明 fields(形状错误)",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(logPanel(), func(p *sdk.Panel) {
|
||||
p.Sections[0].Fields = []sdk.Field{{Key: "line", Type: sdk.FieldText}}
|
||||
})
|
||||
},
|
||||
want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].fields",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package paneldecl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件锁定「非法声明逐条可分类」这条验收线:每一类非法声明一条用例,断言的是
|
||||
// **分类哨兵 + 出错路径**,不是错误字符串(字符串匹配会在改文案时假绿)。
|
||||
//
|
||||
// 用例表按层次分在三个文件里:本文件(插件/面板层与聚合错误)、validate_section_test.go
|
||||
// (区块层)、validate_field_test.go(字段与表格元素层)。
|
||||
|
||||
// rejectCase 是一条拒绝用例。
|
||||
type rejectCase struct {
|
||||
name string
|
||||
// pluginID 为空表示用默认的 "web-search";blankPluginID 用于「插件 id 缺失」这条。
|
||||
pluginID string
|
||||
blankPluginID bool
|
||||
panels func() []sdk.Panel
|
||||
want error
|
||||
wantPath string
|
||||
}
|
||||
|
||||
// panelsOf 用 base 造一份面板并施加 edit(edit 为 nil 表示原样)。
|
||||
func panelsOf(base sdk.Panel, edit func(*sdk.Panel)) []sdk.Panel {
|
||||
p := base
|
||||
if edit != nil {
|
||||
edit(&p)
|
||||
}
|
||||
return []sdk.Panel{p}
|
||||
}
|
||||
|
||||
// requireViolation 断言 err 中存在一条 Kind==want 且 Path==wantPath 的违规。
|
||||
//
|
||||
// 两条断言缺一不可:errors.Is 证明**可分类**,Path 证明指的是**那一处**(而不是碰巧
|
||||
// 别的字段也不合法)。
|
||||
func requireViolation(t *testing.T, err error, want error, wantPath string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("期望校验失败(%v @ %s),实际通过", want, wantPath)
|
||||
}
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("errors.Is(err, %v) 为假;err = %v", want, err)
|
||||
}
|
||||
var agg *Errors
|
||||
if !errors.As(err, &agg) {
|
||||
t.Fatalf("err 不是 *Errors 聚合,实际 %T:%v", err, err)
|
||||
}
|
||||
for _, v := range agg.Violations {
|
||||
if errors.Is(v.Kind, want) && v.Path == wantPath {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("聚合错误里没有 %v @ %s 这一条;err = %v", want, wantPath, err)
|
||||
}
|
||||
|
||||
// allRejectCases 汇总三个层次的拒绝用例。
|
||||
func allRejectCases() []rejectCase {
|
||||
cases := rejectCasesPanelLevel()
|
||||
cases = append(cases, rejectCasesSectionCommon()...)
|
||||
cases = append(cases, rejectCasesFormShape()...)
|
||||
cases = append(cases, rejectCasesTableShape()...)
|
||||
cases = append(cases, rejectCasesReadOnlySections()...)
|
||||
cases = append(cases, rejectCasesFieldCore()...)
|
||||
cases = append(cases, rejectCasesFieldBoundsAndDefaults()...)
|
||||
cases = append(cases, rejectCasesTableItems()...)
|
||||
return cases
|
||||
}
|
||||
|
||||
func TestValidate_Rejects(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range allRejectCases() {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
pluginID := tc.pluginID
|
||||
if pluginID == "" && !tc.blankPluginID {
|
||||
pluginID = "web-search"
|
||||
}
|
||||
requireViolation(t, Validate(pluginID, tc.panels()), tc.want, tc.wantPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// rejectCasesPanelLevel 是插件 id 与面板层的拒绝用例。
|
||||
func rejectCasesPanelLevel() []rejectCase {
|
||||
return []rejectCase{
|
||||
{
|
||||
name: "插件 id 含空格与大写(不能当路由段)", pluginID: "Web Search",
|
||||
panels: func() []sdk.Panel { return panelsOf(samplePanel(), nil) },
|
||||
want: ErrInvalidPluginID, wantPath: "pluginID",
|
||||
},
|
||||
{
|
||||
name: "插件 id 缺失", blankPluginID: true,
|
||||
panels: func() []sdk.Panel { return panelsOf(samplePanel(), nil) },
|
||||
want: ErrMissingField, wantPath: "pluginID",
|
||||
},
|
||||
{
|
||||
name: "面板 id 缺失",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.ID = "" })
|
||||
},
|
||||
want: ErrMissingField, wantPath: "adminPanels[0].id",
|
||||
},
|
||||
{
|
||||
name: "面板 id 含大写与斜杠",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.ID = "Providers/List" })
|
||||
},
|
||||
want: ErrInvalidPanelID, wantPath: "adminPanels[0].id",
|
||||
},
|
||||
{
|
||||
name: "面板标题缺失",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Title = " " })
|
||||
},
|
||||
want: ErrMissingField, wantPath: "adminPanels[0].title",
|
||||
},
|
||||
{
|
||||
name: "分组不在闭集(本期只有 plugins)",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Group = "search" })
|
||||
},
|
||||
want: ErrUnknownGroup, wantPath: "adminPanels[0].group",
|
||||
},
|
||||
{
|
||||
name: "order 为负",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Order = -1 })
|
||||
},
|
||||
want: ErrInvalidOrder, wantPath: "adminPanels[0].order",
|
||||
},
|
||||
{
|
||||
name: "图标名塞了 SVG 片段",
|
||||
panels: func() []sdk.Panel {
|
||||
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Icon = "<svg onload=x>" })
|
||||
},
|
||||
want: ErrInvalidIcon, wantPath: "adminPanels[0].icon",
|
||||
},
|
||||
{
|
||||
name: "面板 id 在本插件内重复",
|
||||
panels: func() []sdk.Panel {
|
||||
second := statusPanel()
|
||||
second.ID = "providers"
|
||||
return []sdk.Panel{samplePanel(), second}
|
||||
},
|
||||
want: ErrDuplicateID, wantPath: "adminPanels[1].id",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_AcceptsSampleDeclaration 锁定「合法声明照单通过」:夹具本身就是 §5.2 的示例,
|
||||
// 它必须过 —— 否则上面的拒绝用例可能只是因为夹具本来就不合法(假绿)。
|
||||
func TestValidate_AcceptsSampleDeclaration(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]sdk.Panel{
|
||||
"§5.2 示例面板": samplePanel(),
|
||||
"只读事实面": statusPanel(),
|
||||
"只读日志卡": logPanel(),
|
||||
}
|
||||
for name, panel := range cases {
|
||||
panel := panel
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := Validate("web-search", []sdk.Panel{panel}); err != nil {
|
||||
t.Fatalf("合法声明被拒:%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_AllowsDocumentedLeniency 锁定几处**刻意放宽**的口径,防止后续把「收紧」
|
||||
// 当成默认动作无限加码(设计没禁止的就不该拒)。见 PLG1-report.md。
|
||||
func TestValidate_AllowsDocumentedLeniency(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 空分组 ⇒ 回落到唯一分组(§5.2「本期固定 plugins」),不算必填缺失。
|
||||
noGroup := samplePanel()
|
||||
noGroup.Group = ""
|
||||
// 零区块面板 ⇒ §5.1「一个插件可声明零到多个管理页面」,面板本身可以是纯壳。
|
||||
shell := samplePanel()
|
||||
shell.Sections = nil
|
||||
// when 缺省 ⇒ 无条件显示(条件显示是可选能力)。
|
||||
noWhen := samplePanel()
|
||||
noWhen.When = nil
|
||||
// 列/字段的 label 缺省 ⇒ 宿主回落到 key。
|
||||
noLabel := samplePanel()
|
||||
noLabel.Sections[0].Fields[0].Label = ""
|
||||
noLabel.Sections[1].Columns[0].Label = ""
|
||||
|
||||
for name, panel := range map[string]sdk.Panel{
|
||||
"空分组回落": noGroup, "零区块面板": shell, "when 缺省": noWhen, "label 缺省": noLabel,
|
||||
} {
|
||||
panel := panel
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := Validate("web-search", []sdk.Panel{panel}); err != nil {
|
||||
t.Fatalf("刻意放宽的口径被拒:%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatePanel_PathUsesIndex 锁定单面板校验的路径锚点与拒绝粒度(§5.2.1「该面板不注册」)。
|
||||
func TestValidatePanel_PathUsesIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := ValidatePanel(samplePanel(), 0); err != nil {
|
||||
t.Fatalf("合法面板被拒:%v", err)
|
||||
}
|
||||
broken := statusPanel()
|
||||
broken.Sections[0].Source = &sdk.Source{Action: "plugin.chain"}
|
||||
requireViolation(t, ValidatePanel(broken, 7), ErrSectionShape, "adminPanels[7].sections[0].source")
|
||||
}
|
||||
|
||||
// TestErrors_IsBoundedAndClassifiable 锁定聚合错误的两件事:Error() 不无界铺开(一条坏清单
|
||||
// 可能有几百条违规),但结构化的完整列表一条不少。
|
||||
func TestErrors_IsBoundedAndClassifiable(t *testing.T) {
|
||||
t.Parallel()
|
||||
var vs []Violation
|
||||
for i := 0; i < maxReportedViolations+3; i++ {
|
||||
vs = append(vs, violation(ErrInvalidPanelID, panelPath(i)+".id", "坏 id"))
|
||||
}
|
||||
err := violationsToError(vs)
|
||||
if !errors.Is(err, ErrInvalidPanelID) {
|
||||
t.Fatalf("errors.Is 未穿透聚合:%v", err)
|
||||
}
|
||||
var agg *Errors
|
||||
if !errors.As(err, &agg) {
|
||||
t.Fatalf("errors.As 未取到 *Errors:%T", err)
|
||||
}
|
||||
if len(agg.Violations) != maxReportedViolations+3 {
|
||||
t.Fatalf("结构化列表条数 = %d,期望 %d", len(agg.Violations), maxReportedViolations+3)
|
||||
}
|
||||
if n := strings.Count(err.Error(), "坏 id"); n != maxReportedViolations {
|
||||
t.Fatalf("Error() 铺设了 %d 条违规,期望最多 %d 条", n, maxReportedViolations)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "more") {
|
||||
t.Fatalf("Error() 应提示还有更多条违规:%s", err.Error())
|
||||
}
|
||||
if errors.Is(err, ErrMissingField) {
|
||||
t.Fatal("无关哨兵不该被判为命中(分类必须精确)")
|
||||
}
|
||||
if got := violationsToError(nil); got != nil {
|
||||
t.Fatalf("空违规应返回 nil,得到 %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package paneldecl
|
||||
|
||||
import "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
|
||||
// 本文件是 `when` 条件显示的求值(§5.2.1 nav.item:「`when` 不满足 ⇒ 条目不出现」)。
|
||||
|
||||
// Context 是 when 求值的上下文:**调用方注入的最小事实**。
|
||||
//
|
||||
// 为什么本包不自己去问「这个插件启用了吗」:AGENTS §9 要求单一事实来源,启用状态的真源在
|
||||
// 插件管理器(§5.2.1 B 的生命周期一栏),本包去读 config / env / 文件只会造出第二份可能
|
||||
// 不一致的状态,而且让单测依赖外部环境。所以这里只声明「求值需要什么」,由调用方填。
|
||||
type Context struct {
|
||||
// Enabled 是「该插件当前已启用」这一事实。install / enable / disable / uninstall
|
||||
// 之后由管理器给出最新值,对应 §5.2.1「都重算面板集合」。
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Resolver 按插件 id 给出求值上下文。用函数类型而不是接口:调用方通常只是读一张 map 或
|
||||
// 问一次管理器,闭包足够,不值得为它定义一个类型。
|
||||
//
|
||||
// 约束(写在这里因为编译器管不了):Resolver 不得回调 Catalog 的 Register/List/Groups ——
|
||||
// 那些方法持有目录锁,回调会自锁死。
|
||||
type Resolver func(pluginID string) Context
|
||||
|
||||
// Eval 求值一份 when 声明,报告该面板的入口是否应当出现。
|
||||
//
|
||||
// 语义(本期闭集只有 enabled 一个条件键):
|
||||
// - when 为 nil、或对象里没写 enabled ⇒ **无条件** ⇒ 显示(条件显示是可选能力,
|
||||
// 不写就等于不设条件);
|
||||
// - when.enabled = true ⇒ 仅当 ctx.Enabled 为真时显示(§5.6.1「未启用的插件 ⇒ 入口不出现」);
|
||||
// - when.enabled = false ⇒ 仅当 ctx.Enabled 为假时显示 —— 条件是「要求值相等」,
|
||||
// 写 false 不等于没写(把 false 当「无条件」会让声明表达力凭空消失一半)。
|
||||
//
|
||||
// 「不满足」的效果是**入口整个不出现**,不是灰掉(§5.2.1 nav.item 明写)。
|
||||
func Eval(w *sdk.When, ctx Context) bool {
|
||||
if w == nil || w.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return ctx.Enabled == *w.Enabled
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package sdk
|
||||
|
||||
// 本文件是 design/09-插件生态 §5.2「声明形状(唯一权威形态)」在 Go 侧的唯一定义:
|
||||
// 插件作者写下的就是这份形状(plugins/<pluginId>/manifest.json 的 adminPanels 段)。
|
||||
//
|
||||
// 为什么声明放在 SDK 而不是后端内部包:§5.2.1 的红线是「声明而非编码」—— 插件不注入
|
||||
// 任何 JS/CSS/HTML,只说「我要什么字段、怎么校验、失败怎么说」,渲染与流程由宿主负责
|
||||
// (面板可接入点 + 插件管理器)。声明既然是插件作者可见的对外契约,它就属于 SDK;
|
||||
// JSON tag 与 §5.2 的示例逐字同形,改 tag 等于改对外契约。
|
||||
//
|
||||
// 本文件只放**数据形状 + 闭集判定**:校验、目录(catalog)、when 求值、角色过滤都在
|
||||
// internal/capability/paneldecl —— 数据与策略分开,插件包不依赖任何内部包。
|
||||
|
||||
// GroupID 是侧边栏折叠分组 id(§5.2 的 `group`)。
|
||||
type GroupID string
|
||||
|
||||
const (
|
||||
// GroupPlugins 是本期的唯一分组。依据 §5.2「group: 侧边栏折叠分组 id(本期固定
|
||||
// plugins)」—— 闭集只有一个成员,因此空值可以安全地回落到它(回落动作在
|
||||
// paneldecl.Register/Validate 里做,SDK 这里只给判定)。
|
||||
GroupPlugins GroupID = "plugins"
|
||||
)
|
||||
|
||||
// Valid 报告 g 是否属于本期已知的分组闭集。
|
||||
func (g GroupID) Valid() bool { return g == GroupPlugins }
|
||||
|
||||
// SectionType 是 section 的渲染形状(§5.2 的 section 类型闭集)。
|
||||
//
|
||||
// 闭集是刻意的:它让「插件声明 UI」在没有任意代码注入的前提下仍然够用,并且每个形状
|
||||
// 都能复用既有 Miuix 组件(§5.2 末尾的口径)。
|
||||
type SectionType string
|
||||
|
||||
const (
|
||||
// SectionForm 是表单区块:消费 `fields[]` + `submit.action`(§5.2.1 section.form),
|
||||
// 由既有字段渲染器泛化渲染。它是四种区块里**唯一含写面**的一种。
|
||||
SectionForm SectionType = "form"
|
||||
// SectionTable 是表格区块:消费 `columns[]` / `rowActions[]` / `source`(§5.2.1
|
||||
// section.table),由既有虚拟滚动表格渲染。
|
||||
SectionTable SectionType = "table"
|
||||
// SectionStatus 是只读事实面(如「当前生效的降级链」):消费只读 `fields[]`
|
||||
// (§5.2.1 section.status)。
|
||||
SectionStatus SectionType = "status"
|
||||
// SectionLog 是只读日志卡:消费 `source` / `limit`,最新在上(§5.2.1 section.log,
|
||||
// 与 L1 口径一致)。
|
||||
SectionLog SectionType = "log"
|
||||
)
|
||||
|
||||
// Valid 报告 t 是否属于 section 类型闭集。
|
||||
func (t SectionType) Valid() bool {
|
||||
switch t {
|
||||
case SectionForm, SectionTable, SectionStatus, SectionLog:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ReadOnly 报告该 section 类型本身是否只读。角色过滤的第一道判据
|
||||
// (design/09 §5.4.1「只读事实面对 viewer 可见,写动作一律 owner/admin」):
|
||||
// 只有 form 自带写面(submit),table 的行内动作需要额外摘除(见 paneldecl.filterPanel)。
|
||||
func (t SectionType) ReadOnly() bool { return t != SectionForm }
|
||||
|
||||
// Panel 是一个插件声明的管理页面(§5.2 的 adminPanels 元素)。
|
||||
type Panel struct {
|
||||
// ID 是面板 id。它与插件 id 组合成前端路由 /admin/plugin/<pluginId>/<panelId>
|
||||
// (§5.7 C),所以必须能直接当 URL 路径段与端点段用 —— 字符集由 paneldecl 校验,
|
||||
// 不在这里放宽。
|
||||
ID string `json:"id"`
|
||||
// Title 是页面标题,同时是侧边栏条目文案(§5.2.1 nav.item 消费 id/title/icon/order/when)。
|
||||
Title string `json:"title"`
|
||||
// Description 是页面描述。§5.2 的示例里没有这一行,但 §5.2.1 的 `page.root` 一行
|
||||
// 明确写「消费 title / description」—— 取并集,缺省为空(不一致处记在 PLG1-report.md)。
|
||||
Description string `json:"description,omitempty"`
|
||||
// Group 是侧边栏折叠分组 id。空值按 §5.2「本期固定 plugins」回落到 GroupPlugins;
|
||||
// 非空但不是闭集成员 ⇒ 校验拒绝。
|
||||
Group GroupID `json:"group"`
|
||||
// Order 是组内排序,小在前;同 order 时按声明顺序(稳定序,前端导航不许因 map 迭代
|
||||
// 抖动)。
|
||||
Order int `json:"order"`
|
||||
// Icon 是宿主图标名(§5.2 的 `icon`,示例为 "search")。宿主按名字取内置图标,
|
||||
// 因此这里只接受图标名,不接受 URL / SVG 片段 —— 它是「无任意代码注入」的一部分。
|
||||
Icon string `json:"icon,omitempty"`
|
||||
// When 是条件显示声明:不满足 ⇒ 入口**不出现**(不是灰掉,§5.2.1 nav.item)。
|
||||
// 缺省(nil)表示无条件显示。求值在 paneldecl.Eval,上下文由调用方注入。
|
||||
When *When `json:"when,omitempty"`
|
||||
// Sections 是页面内的区块列表。允许为空(§5.1「一个插件可声明**零到多个**管理页面」,
|
||||
// 面板本身也可以是纯壳),不做「空面板即非法」的收紧。
|
||||
Sections []Section `json:"sections"`
|
||||
}
|
||||
|
||||
// Section 是页面内的一个区块(§5.2.1 section.form / section.table / section.status /
|
||||
// section.log 四个接入点之一)。
|
||||
//
|
||||
// 每种类型**消费哪些键**由 §5.2.1 的接入点表定死,paneldecl 据此做形状校验:形状错了
|
||||
// 到前端就是「区块在、功能不在」的半渲染(§5.2.1 点名要防的),所以按面板整体拒掉。
|
||||
type Section struct {
|
||||
// Type 决定这个区块怎么渲染,必须落在闭集内(form|table|status|log)。
|
||||
Type SectionType `json:"type"`
|
||||
// ID 是区块 id,在面板内唯一:它既是前端渲染 key,也是「哪一段坏了」的定位锚点。
|
||||
ID string `json:"id"`
|
||||
// Title 是区块标题。block 无标题会让用户看到一段无来由的表单/表格,因此必填。
|
||||
Title string `json:"title"`
|
||||
// Fields 是字段声明:form(可写)与 status(只读)消费。
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
// Submit 是表单提交动作:只有 form 消费(写面的唯一入口)。
|
||||
Submit *Submit `json:"submit,omitempty"`
|
||||
// Source 是数据来源:table 与 log 消费。它只给**动作名**,不给 URL(§5.4.4)。
|
||||
Source *Source `json:"source,omitempty"`
|
||||
// Columns 是表格列声明:只有 table 消费。
|
||||
Columns []Column `json:"columns,omitempty"`
|
||||
// RowActions 是行内动作:只有 table 消费(如「测试连通」「重配凭据」)。
|
||||
RowActions []RowAction `json:"rowActions,omitempty"`
|
||||
// Limit 是 log 区块只读的最近条数(§5.2.1 section.log 消费 source/limit)。
|
||||
// §5.2 的示例里没有这个键 —— 不一致处记在 PLG1-report.md。
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// Source 是 table / log 的数据来源。
|
||||
//
|
||||
// 为什么只有一个 action 字段:§5.4.4 的硬约束是「插件声明的只是**动作名**,不是任意
|
||||
// URL ⇒ 不给插件开 SSRF 面」。所以这里既没有 url 也没有 path —— 想塞 URL 的插件会
|
||||
// 在校验期被拒(paneldecl)。
|
||||
type Source struct {
|
||||
// Action 是插件声明的动作名(示例 plugin.listProviders),由宿主端点
|
||||
// POST /api/v1/admin/plugins/{id}/actions/{action} 分发。
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// Submit 是 form 区块的提交动作(§5.2 的 `submit.action`)。
|
||||
type Submit struct {
|
||||
// Action 的语义与 Source.Action 相同:只有动作名,没有 URL。
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// Column 是 table 的一列(§5.2 的 `columns` 元素)。
|
||||
type Column struct {
|
||||
// Key 是行数据里的字段名,也是列的身份:列内唯一,用于渲染与排序。
|
||||
Key string `json:"key"`
|
||||
// Label 是表头文案。缺省时宿主回落到 Key(Key 是机器可读标识且列内唯一,
|
||||
// 有可靠回落 ⇒ 不作为必填)。
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// RowAction 是 table 的行内动作(§5.2 的 `rowActions` 元素,示例「测试连通」/「重配凭据」)。
|
||||
type RowAction struct {
|
||||
// ID 是动作在行内的身份,必须唯一 —— 同一行里两个同名动作会让用户点错。
|
||||
ID string `json:"id"`
|
||||
// Label 是按钮文案。**必填且没有可回落的东西**(不像 Column.Label 能回落到 Key):
|
||||
// 一个没有文字的按钮就是半渲染。
|
||||
Label string `json:"label"`
|
||||
// Action 是插件声明的动作名(同样只有名字、没有 URL)。
|
||||
Action string `json:"action"`
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件锁定两件事:
|
||||
// 1. 声明的 JSON tag 与 design/09 §5.2 的示例**逐字同形**(示例 JSON 直接解码进来);
|
||||
// 2. 闭集(分组 / section 类型 / 字段类型)与 when 的严格解码确实按闭集工作。
|
||||
|
||||
// designExample 是 design/09-插件生态 §5.2 示例里 adminPanels 段的**逐字拷贝**
|
||||
// (只去掉 JSONC 注释)。它在这里是权威输入:tag 一旦改坏,本用例必红。
|
||||
const designExample = `[
|
||||
{
|
||||
"id": "providers",
|
||||
"title": "搜索提供商",
|
||||
"group": "plugins",
|
||||
"order": 20,
|
||||
"icon": "search",
|
||||
"when": { "enabled": true },
|
||||
"sections": [
|
||||
{ "type": "form", "id": "add", "title": "添加提供商",
|
||||
"fields": [
|
||||
{ "key": "provider", "label": "提供商", "type": "select", "optionsFrom": "plugin:providers", "required": true },
|
||||
{ "key": "apiKey", "label": "API Key", "type": "secret", "required": true },
|
||||
{ "key": "endpoint", "label": "接口地址", "type": "text", "placeholder": "https://..." }
|
||||
],
|
||||
"submit": { "action": "plugin.saveProvider" } },
|
||||
{ "type": "table", "id": "list", "title": "已配置的提供商",
|
||||
"source": { "action": "plugin.listProviders" },
|
||||
"columns": [
|
||||
{ "key": "provider", "label": "提供商" },
|
||||
{ "key": "state", "label": "状态" }
|
||||
],
|
||||
"rowActions": [
|
||||
{ "id": "test", "label": "测试连通", "action": "plugin.testProvider" },
|
||||
{ "id": "reauth","label": "重配凭据", "action": "plugin.reauthProvider" }
|
||||
] }
|
||||
]
|
||||
}]`
|
||||
|
||||
// TestPanel_DecodesDesignExample 证明 §5.2 的示例能原样解码进我们的类型。
|
||||
func TestPanel_DecodesDesignExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
var panels []Panel
|
||||
if err := json.Unmarshal([]byte(designExample), &panels); err != nil {
|
||||
t.Fatalf("§5.2 示例解码失败(说明 JSON tag 与文档不同形):%v", err)
|
||||
}
|
||||
if len(panels) != 1 {
|
||||
t.Fatalf("面板数 = %d,期望 1", len(panels))
|
||||
}
|
||||
p := panels[0]
|
||||
if p.ID != "providers" || p.Title != "搜索提供商" || p.Group != GroupPlugins {
|
||||
t.Fatalf("面板头字段解码错误:%+v", p)
|
||||
}
|
||||
if p.Order != 20 || p.Icon != "search" {
|
||||
t.Fatalf("order/icon 解码错误:%+v", p)
|
||||
}
|
||||
if p.When == nil || p.When.Enabled == nil || !*p.When.Enabled {
|
||||
t.Fatalf("when 解码错误:%+v", p.When)
|
||||
}
|
||||
if len(p.Sections) != 2 {
|
||||
t.Fatalf("区块数 = %d,期望 2", len(p.Sections))
|
||||
}
|
||||
form := p.Sections[0]
|
||||
if form.Type != SectionForm || form.ID != "add" || form.Submit == nil ||
|
||||
form.Submit.Action != "plugin.saveProvider" {
|
||||
t.Fatalf("form 区块解码错误:%+v", form)
|
||||
}
|
||||
if len(form.Fields) != 3 || form.Fields[0].OptionsFrom != "plugin:providers" ||
|
||||
!form.Fields[0].Required || form.Fields[1].Type != FieldSecret ||
|
||||
form.Fields[2].Placeholder != "https://..." {
|
||||
t.Fatalf("字段解码错误:%+v", form.Fields)
|
||||
}
|
||||
table := p.Sections[1]
|
||||
if table.Type != SectionTable || table.Source == nil ||
|
||||
table.Source.Action != "plugin.listProviders" {
|
||||
t.Fatalf("table 区块解码错误:%+v", table)
|
||||
}
|
||||
if len(table.Columns) != 2 || table.Columns[1].Key != "state" {
|
||||
t.Fatalf("列解码错误:%+v", table.Columns)
|
||||
}
|
||||
if len(table.RowActions) != 2 || table.RowActions[1].ID != "reauth" ||
|
||||
table.RowActions[1].Action != "plugin.reauthProvider" {
|
||||
t.Fatalf("行内动作解码错误:%+v", table.RowActions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPanel_RoundTrips 锁反向:编码再解码必须一模一样(tag 只对了一半就会在这里露出来)。
|
||||
func TestPanel_RoundTrips(t *testing.T) {
|
||||
t.Parallel()
|
||||
var first []Panel
|
||||
if err := json.Unmarshal([]byte(designExample), &first); err != nil {
|
||||
t.Fatalf("解码: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(first)
|
||||
if err != nil {
|
||||
t.Fatalf("编码: %v", err)
|
||||
}
|
||||
var second []Panel
|
||||
if err := json.Unmarshal(raw, &second); err != nil {
|
||||
t.Fatalf("再解码: %v (%s)", err, raw)
|
||||
}
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("编码往返不一致:\n%+v\n%+v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhen_StrictDecode 锁定 when 是**闭集**:未知条件键必须显式报错,不能被静默丢掉。
|
||||
func TestWhen_StrictDecode(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want error
|
||||
}{
|
||||
{"只有 enabled", `{"enabled":true}`, nil},
|
||||
{"空对象(无条件)", `{}`, nil},
|
||||
{"未知条件键", `{"configured":true}`, ErrUnknownWhenKey},
|
||||
{"未知条件键(多个,报错必须确定)", `{"zed":1,"alpha":2}`, ErrUnknownWhenKey},
|
||||
{"enabled 不是布尔", `{"enabled":"yes"}`, ErrInvalidWhen},
|
||||
{"不是对象", `true`, ErrInvalidWhen},
|
||||
{"是 null", `null`, ErrInvalidWhen},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var w When
|
||||
err := json.Unmarshal([]byte(tc.raw), &w)
|
||||
if tc.want == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("解码 %s 失败: %v", tc.raw, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("解码 %s err = %v,期望 %v", tc.raw, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhen_UnknownKeyIsDeterministic 锁定多未知键时报的是同一个键(错误信息不能随 map 迭代变)。
|
||||
func TestWhen_UnknownKeyIsDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
var first error
|
||||
for i := 0; i < 20; i++ {
|
||||
var w When
|
||||
err := json.Unmarshal([]byte(`{"zeta":1,"alpha":2,"mu":3}`), &w)
|
||||
if first == nil {
|
||||
first = err
|
||||
continue
|
||||
}
|
||||
if err == nil || err.Error() != first.Error() {
|
||||
t.Fatalf("第 %d 次报错 %v,与首次 %v 不一致", i, err, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhen_PropagatesThroughPanel 锁定严格性会穿透面板解码 —— 插件管理器拿到的就是这一层。
|
||||
func TestWhen_PropagatesThroughPanel(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := `[{"id":"p","title":"t","group":"plugins","order":1,` +
|
||||
`"when":{"enabled":true,"configured":true},"sections":[]}]`
|
||||
var panels []Panel
|
||||
err := json.Unmarshal([]byte(raw), &panels)
|
||||
if !errors.Is(err, ErrUnknownWhenKey) {
|
||||
t.Fatalf("err = %v,期望 ErrUnknownWhenKey 穿透到面板解码", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClosedSets 锁定三个闭集与它们的判定方法。新增成员必须同时改这里 —— 这正是闭集的意义。
|
||||
func TestClosedSets(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tt := range []FieldType{FieldText, FieldSecret, FieldNumber, FieldSwitch,
|
||||
FieldSelect, FieldMultiSelect, FieldDate, FieldKV} {
|
||||
if !tt.Valid() {
|
||||
t.Errorf("字段类型 %q 应当合法", tt)
|
||||
}
|
||||
}
|
||||
if FieldType("chart").Valid() {
|
||||
t.Error("字段类型 chart 不该合法(闭集外)")
|
||||
}
|
||||
if FieldType("").Valid() {
|
||||
t.Error("空字段类型不该合法")
|
||||
}
|
||||
for _, st := range []SectionType{SectionForm, SectionTable, SectionStatus, SectionLog} {
|
||||
if !st.Valid() {
|
||||
t.Errorf("区块类型 %q 应当合法", st)
|
||||
}
|
||||
}
|
||||
if SectionType("chart").Valid() {
|
||||
t.Error("区块类型 chart 不该合法(闭集外)")
|
||||
}
|
||||
if !GroupPlugins.Valid() || GroupID("search").Valid() {
|
||||
t.Error("分组闭集判定错误(本期只有 plugins)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypeHelpers 锁定三个语义判定:敏感、数值、要拉选项。
|
||||
func TestTypeHelpers(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !FieldSecret.Sensitive() || FieldText.Sensitive() {
|
||||
t.Error("Sensitive 判定错误")
|
||||
}
|
||||
if !FieldNumber.Numeric() || FieldDate.Numeric() {
|
||||
t.Error("Numeric 判定错误(只有 number 接受 min/max)")
|
||||
}
|
||||
if !FieldSelect.PullsOptions() || !FieldMultiSelect.PullsOptions() || FieldText.PullsOptions() {
|
||||
t.Error("PullsOptions 判定错误")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSectionType_ReadOnly 锁定角色过滤的第一道判据:只有 form 自带写面。
|
||||
func TestSectionType_ReadOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
if SectionForm.ReadOnly() {
|
||||
t.Error("form 不是只读区块(它有 submit)")
|
||||
}
|
||||
for _, st := range []SectionType{SectionTable, SectionStatus, SectionLog} {
|
||||
if !st.ReadOnly() {
|
||||
t.Errorf("%q 应当是只读区块", st)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package sdk
|
||||
|
||||
// 本文件是 design/09-插件生态 §5.2「字段类型集合(闭集)」在 Go 侧的唯一定义,
|
||||
// 以及字段声明本身(form / status 区块的 `fields[]` 元素)。
|
||||
|
||||
// FieldType 是字段控件类型(闭集,§5.2:不许插件自定义渲染器)。
|
||||
//
|
||||
// 闭集的意义:每个形状都能复用既有 Miuix 组件(渠道配置那套字段渲染 + 虚拟滚动表格),
|
||||
// 因此「插件声明 UI」在无任意代码注入的前提下依然够用 —— 配置类插件 90% 的需求就是
|
||||
// 这几个形状。
|
||||
type FieldType string
|
||||
|
||||
const (
|
||||
// FieldText 是单行文本(示例 endpoint:接口地址)。
|
||||
FieldText FieldType = "text"
|
||||
// FieldSecret 是凭据输入(示例 apiKey)。读侧永不回显明文(§5.4.2),因此
|
||||
// paneldecl 拒绝它声明默认值。
|
||||
FieldSecret FieldType = "secret"
|
||||
// FieldNumber 是数值输入(唯一接受 min/max 边界的类型)。
|
||||
FieldNumber FieldType = "number"
|
||||
// FieldSwitch 是布尔开关。
|
||||
FieldSwitch FieldType = "switch"
|
||||
// FieldSelect 是单选下拉(选项来自 optionsFrom)。
|
||||
FieldSelect FieldType = "select"
|
||||
// FieldMultiSelect 是多选下拉(选项来自 optionsFrom)。
|
||||
FieldMultiSelect FieldType = "multiSelect"
|
||||
// FieldDate 是日期。
|
||||
FieldDate FieldType = "date"
|
||||
// FieldKV 是键值对(§5.2 的例子:自定义 header)。
|
||||
FieldKV FieldType = "kv"
|
||||
)
|
||||
|
||||
// Valid 报告 t 是否属于字段类型闭集。
|
||||
func (t FieldType) Valid() bool {
|
||||
switch t {
|
||||
case FieldText, FieldSecret, FieldNumber, FieldSwitch,
|
||||
FieldSelect, FieldMultiSelect, FieldDate, FieldKV:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Sensitive 报告该类型是否承载凭据。§5.4.2 要求读侧永不回显明文(只回「是否已配置」+
|
||||
// 尾 4 位或哈希前缀),所以敏感字段不得声明任何默认值 —— 校验在 paneldecl。
|
||||
func (t FieldType) Sensitive() bool { return t == FieldSecret }
|
||||
|
||||
// Numeric 报告该类型是否接受 min/max 数值边界。依据:闭集里只有 number 是数值控件,
|
||||
// date 在声明里是日期字符串,用数值边界表达日期范围只会产生歧义。
|
||||
func (t FieldType) Numeric() bool { return t == FieldNumber }
|
||||
|
||||
// PullsOptions 报告该类型是否需要宿主去拉下拉选项。依据:只有 select / multiSelect 会
|
||||
// 渲染选项列表,其它类型上的 optionsFrom 是无效声明(会被 paneldecl 拒绝)。
|
||||
func (t FieldType) PullsOptions() bool { return t == FieldSelect || t == FieldMultiSelect }
|
||||
|
||||
// Field 是一个字段声明(§5.2 的 fields 元素,form 与 status 共用)。
|
||||
type Field struct {
|
||||
// Key 是配置键 / 行字段名,形如 §5.2 示例的 provider / apiKey / endpoint。
|
||||
// 它同时是表单的提交键与 status 行的数据键,因此在区块内必须唯一。
|
||||
Key string `json:"key"`
|
||||
// Label 是字段标签。缺省时宿主回落到 Key(与 Column.Label 同一取舍)。
|
||||
Label string `json:"label,omitempty"`
|
||||
// Type 决定控件,必须落在闭集内。
|
||||
Type FieldType `json:"type"`
|
||||
// Required 是**配置值**的必填标记(§5.2 示例的 `"required": true`),不是声明层的
|
||||
// 必填 —— 声明层的必填只认 Key 与 Type。
|
||||
Required bool `json:"required,omitempty"`
|
||||
// Placeholder 是输入提示(示例 `https://...`)。
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
// OptionsFrom 是下拉选项来源。§5.2 只给了插件内的一种来源(示例 plugin:providers);
|
||||
// **只允许 `plugin:` 前缀**,其它一律拒 —— 防「声明里塞任意 URL」。
|
||||
OptionsFrom string `json:"optionsFrom,omitempty"`
|
||||
// Default 是字段初始值。§5.2 的示例里没有这个键;它按任务书「若你定义了这些可选键」
|
||||
// 的口径加入,且对 secret 类型被 paneldecl 硬拒(§5.4.2 读侧无明文)。
|
||||
Default any `json:"default,omitempty"`
|
||||
// Min 是数值下界,仅 number 类型可声明(§5.2 示例里没有这个键,同上)。
|
||||
Min *float64 `json:"min,omitempty"`
|
||||
// Max 是数值上界,仅 number 类型可声明,且必须 >= Min。
|
||||
Max *float64 `json:"max,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// 本文件定义面板入口的**条件显示**声明 `when`(design/09 §5.2.1 nav.item:
|
||||
// 「`when` 不满足 ⇒ 条目不出现」,不是灰掉)。
|
||||
|
||||
const (
|
||||
// whenKeyEnabled 是本期唯一支持的条件键:§5.2 的 `"when": { "enabled": true }`
|
||||
// 是设计里出现过的唯一条件形态,§5.6 的验收判据也只有「未启用的插件 ⇒ 入口不出现」。
|
||||
// 刻意不留第二个键 —— 闭集外的条件属于「先改设计再加」的范畴。
|
||||
whenKeyEnabled = "enabled"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidWhen 报告 when 不是合法的条件对象(非对象、null、或 enabled 不是布尔)。
|
||||
ErrInvalidWhen = errors.New("sdk: invalid when clause")
|
||||
// ErrUnknownWhenKey 报告 when 里出现了闭集外的条件键。
|
||||
//
|
||||
// 刻意**报错而不是忽略**:静默忽略会让插件作者以为条件生效了,而入口照常出现 ——
|
||||
// §5.2.1 的口径是「绝不静默丢弃」。这条只能在解码期拦住(结构体解码本身会丢弃未知
|
||||
// 键),所以这里自己实现 UnmarshalJSON。
|
||||
ErrUnknownWhenKey = errors.New("sdk: unknown when key")
|
||||
)
|
||||
|
||||
// When 是面板入口的条件显示声明。零值语义是「没有条件」,见 paneldecl.Eval。
|
||||
type When struct {
|
||||
// Enabled 有三个状态,用指针而不是 bool 表达:
|
||||
// - nil:没声明这个条件(含整份 when 缺省的 nil 指针)⇒ 不参与判定;
|
||||
// - &true:要求插件当前已启用(§5.6.1「未启用 ⇒ 入口不出现」);
|
||||
// - &false:要求插件当前**未**启用 —— 条件是「要求值相等」,写 false 不等于没写。
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON 严格解码 when:只接受对象,只接受闭集内的键,enabled 必须是布尔。
|
||||
//
|
||||
// 为什么不用默认的 struct 解码:默认解码会**静默丢弃**未知键(`{"enabled":true,"x":1}`
|
||||
// 会解码成一个看似正常的 When),插件作者会以为自己写了条件却没有任何效果。这里把
|
||||
// 未知键变成显式拒绝,由插件管理器按 §5.2.1「该面板不注册 + 显式报错」处理。
|
||||
func (w *When) UnmarshalJSON(raw []byte) error {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if bytes.Equal(trimmed, []byte("null")) {
|
||||
return fmt.Errorf("%w: null is not a condition set", ErrInvalidWhen)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(trimmed, &fields); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrInvalidWhen, err)
|
||||
}
|
||||
// 按键名排序后再判定:多个未知键时错误信息必须确定(否则同一个坏声明在不同进程里
|
||||
// 报不同的键,排障与测试都会被搞乱)。
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if k != whenKeyEnabled {
|
||||
return fmt.Errorf("%w: %q", ErrUnknownWhenKey, k)
|
||||
}
|
||||
}
|
||||
rawEnabled, ok := fields[whenKeyEnabled]
|
||||
if !ok {
|
||||
w.Enabled = nil
|
||||
return nil
|
||||
}
|
||||
var enabled bool
|
||||
if err := json.Unmarshal(rawEnabled, &enabled); err != nil {
|
||||
return fmt.Errorf("%w: enabled must be a boolean: %w", ErrInvalidWhen, err)
|
||||
}
|
||||
w.Enabled = &enabled
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user