Files
Cyrene_v2/internal/capability/plugin/panels_test.go
T
AskaEth 2d21aa553e feat(plugins): 插件管理器接线 —— 面板接入点快照 + 动作分发(PLG-2)
- GET /api/v1/admin/panels:按调用者角色过滤后的接入点快照(PLG-1 的 catalog 挂进插件管理器)
- POST /api/v1/admin/plugins/{id}/actions/{action}:**只接受该插件声明过的动作名**
  (未声明 ⇒ 404);写动作 writeAuth + 落审计;插件只给动作名不给 URL ⇒ 不给插件开任意请求面
- 生命周期:install/uninstall/enable/disable 变化后**重算面板集合**并经进程内 bus 推 panels_changed
  (前端可热更新;不新开端口)

契约 v0.78 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
2026-10-06 09:03:57 +08:00

327 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"context"
"errors"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件锁定 PLG-2 的面板中枢口径:声明登记 → 目录 → 生命周期重算 → 变更通知,
// 以及「已声明动作」的读写面与分发三态。
// boolPtr 是 sdk.When.Enabled 三态指针的简写。
func boolPtr(v bool) *bool { return &v }
// samplePanel 是逐字对齐 design/09 §5.2 示例的声明(form 写面 + table 读/写面)。
func samplePanel() sdk.Panel {
return sdk.Panel{
ID: "providers",
Title: "搜索提供商",
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},
},
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"},
},
},
},
}
}
// newTestHub 构造一个接好发布器(把变更记进切片)的中枢。
func newTestHub(t *testing.T) (*Panels, *changeRecorder) {
t.Helper()
hub, err := NewPanels(nil)
if err != nil {
t.Fatalf("NewPanels: %v", err)
}
rec := &changeRecorder{}
hub.SetPublisher(rec.record)
return hub, rec
}
// changeRecorder 以并发安全方式收集面板变更(中枢在任意 goroutine 上发布)。
type changeRecorder struct {
mu sync.Mutex
changes []PanelChange
}
func (r *changeRecorder) record(ch PanelChange) {
r.mu.Lock()
r.changes = append(r.changes, ch)
r.mu.Unlock()
}
func (r *changeRecorder) all() []PanelChange {
r.mu.Lock()
defer r.mu.Unlock()
return append([]PanelChange(nil), r.changes...)
}
// panelIDs 抽取快照里的面板 id(顺序敏感)。
func panelIDs(entries []paneldecl.Entry) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Panel.ID)
}
return out
}
// TestPanels_RoleFilterReachesWireShape 锁定「快照按角色裁剪」经中枢一路生效到
// 返回值:owner 拿到 form(写面)与 table,viewer 只剩 table 且行内动作被摘掉。
func TestPanels_RoleFilterReachesWireShape(t *testing.T) {
t.Parallel()
hub, _ := newTestHub(t)
if _, err := hub.Declare("web-search", []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare: %v", err)
}
owner, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List(owner): %v", err)
}
viewer, err := hub.List(auth.RoleViewer)
if err != nil {
t.Fatalf("List(viewer): %v", err)
}
if len(owner) != 1 || len(owner[0].Panel.Sections) != 2 {
t.Fatalf("owner 快照 = %+v,期望 1 个面板 / 2 个区块", owner)
}
if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 {
t.Fatalf("viewer 快照 = %+v,期望 1 个面板 / 1 个区块(form 是写面,应被裁掉)", viewer)
}
if s := viewer[0].Panel.Sections[0]; s.Type != sdk.SectionTable || len(s.RowActions) != 0 {
t.Fatalf("viewer 的 table = %+v,期望行内动作被摘成空", s)
}
// 未知角色必须 fail-closed(不猜默认角色放行)。
if _, err := hub.List(auth.Role("guest")); err == nil {
t.Fatalf("未知角色 List 应当报错,实际通过")
}
}
// TestPanels_WhenEnabledHidesAndRestores 锁定 §5.6.1:显式停用 ⇒ 入口不出现,
// 重新启用 ⇒ 入口原样回来(声明没有被删掉)。
func TestPanels_WhenEnabledHidesAndRestores(t *testing.T) {
t.Parallel()
hub, rec := newTestHub(t)
if _, err := hub.Declare("web-search", []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare: %v", err)
}
if got := mustIDs(t, hub); len(got) != 1 {
t.Fatalf("默认启用态下应当看到 1 个面板,实际 %v", got)
}
hub.SetEnabled("web-search", false)
if got := mustIDs(t, hub); len(got) != 0 {
t.Fatalf("停用后入口应当不出现,实际 %v", got)
}
hub.SetEnabled("web-search", true)
if got := mustIDs(t, hub); len(got) != 1 {
t.Fatalf("重新启用后入口应当回来,实际 %v", got)
}
// 变更通知:declare→disable→enable(「启用两次」的第二次是幂等空操作,
// 因为状态没变、面板集合也没变)。
changes := rec.all()
if len(changes) != 3 {
t.Fatalf("变更通知 = %+v,期望 3 条", changes)
}
hub.SetEnabled("web-search", true)
if n := len(rec.all()); n != 3 {
t.Fatalf("幂等启用不应再推通知,实际 %d 条", n)
}
}
// TestPanels_RejectedPanelKeepsItsNeighboursAndItsActionsStayUndeclared 锁定口径:
// 非法面板只拒该面板(同插件其余面板照常注册),且**被拒面板的动作不可调用**。
func TestPanels_RejectedPanelKeepsItsNeighboursAndItsActionsStayUndeclared(t *testing.T) {
t.Parallel()
hub, _ := newTestHub(t)
bad := samplePanel()
bad.ID = "Bad_ID" // 大写与下划线不在 id 形状内
bad.Sections[0].Submit = &sdk.Submit{Action: "plugin.nukeProvider"}
accepted, err := hub.Declare("web-search", []sdk.Panel{samplePanel(), bad})
if err == nil {
t.Fatalf("含非法面板的声明应当返回聚合错误")
}
if len(accepted) != 1 || accepted[0] != "providers" {
t.Fatalf("accepted = %v,期望只接受 providers", accepted)
}
if got := mustIDs(t, hub); len(got) != 1 {
t.Fatalf("合法面板应当照常注册,实际 %v", got)
}
if _, ok := hub.Action("web-search", "plugin.saveProvider"); !ok {
t.Fatalf("合法面板的 submit 动作应当已声明")
}
// 被拒面板的动作不得可调用:面板没渲染出来、动作却能调,就是「半渲染 + 越权面」。
if _, ok := hub.Action("web-search", "plugin.nukeProvider"); ok {
t.Fatalf("被拒面板的动作不得进入白名单")
}
}
// TestPanels_InvalidPluginIDIsRejectedWholesale 锁定 paneldecl 的整插件拒绝口径:
// 没有合法命名空间就没有可挂的东西,一个面板都不进目录,也不留动作。
func TestPanels_InvalidPluginIDIsRejectedWholesale(t *testing.T) {
t.Parallel()
hub, _ := newTestHub(t)
accepted, err := hub.Declare("Web Search", []sdk.Panel{samplePanel()})
if !errors.Is(err, paneldecl.ErrInvalidPluginID) {
t.Fatalf("err = %v,期望 ErrInvalidPluginID 可判类", err)
}
if len(accepted) != 0 || len(mustIDs(t, hub)) != 0 {
t.Fatalf("非法插件 id 应当整插件拒绝,accepted=%v ids=%v", accepted, mustIDs(t, hub))
}
if _, ok := hub.Action("Web Search", "plugin.saveProvider"); ok {
t.Fatalf("被整插件拒绝的声明不得留下任何动作")
}
}
// TestPanels_ActionKindsFollowDeclarationSite 锁定读写面由**声明位置**决定:
// table/log 的 source 是读来源,form 的 submit 与 table 的 rowActions 是写面。
func TestPanels_ActionKindsFollowDeclarationSite(t *testing.T) {
t.Parallel()
hub, _ := newTestHub(t)
if _, err := hub.Declare("web-search", []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare: %v", err)
}
cases := []struct {
action string
want ActionKind
}{
{"plugin.listProviders", ActionRead},
{"plugin.saveProvider", ActionWrite},
{"plugin.testProvider", ActionWrite},
}
for _, c := range cases {
got, ok := hub.Action("web-search", c.action)
if !ok || got != c.want {
t.Fatalf("Action(%s) = (%q,%v),期望 (%q,true)", c.action, got, ok, c.want)
}
}
if _, ok := hub.Action("web-search", "plugin.deleteEverything"); ok {
t.Fatalf("未声明的动作不得被判为已声明")
}
if got := hub.DeclaredActions("web-search"); strings.Join(got, ",") !=
"plugin.listProviders,plugin.saveProvider,plugin.testProvider" {
t.Fatalf("DeclaredActions = %v", got)
}
}
// TestPanels_DispatchThreeWayRefusal 锁定分发失败的三种病因各自可判类,
// 且执行器只在「已声明 + 已启用 + 已接线」时才被调用。
func TestPanels_DispatchThreeWayRefusal(t *testing.T) {
t.Parallel()
hub, _ := newTestHub(t)
if _, err := hub.Declare("web-search", []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare: %v", err)
}
req := ActionRequest{PluginID: "web-search", Action: "plugin.listProviders", Payload: map[string]any{"k": "v"}}
if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "web-search", Action: "nope"}); !errors.Is(err, ErrActionNotDeclared) {
t.Fatalf("未声明动作 err = %v,期望 ErrActionNotDeclared", err)
}
if _, err := hub.Dispatch(context.Background(), req); !errors.Is(err, ErrActionUnavailable) {
t.Fatalf("未接线执行器 err = %v,期望 ErrActionUnavailable", err)
}
var seen ActionRequest
hub.SetExecutor(func(_ context.Context, r ActionRequest) (any, error) {
seen = r
return map[string]any{"rows": 1}, nil
})
out, err := hub.Dispatch(context.Background(), req)
if err != nil {
t.Fatalf("Dispatch: %v", err)
}
if seen.PluginID != "web-search" || seen.Action != "plugin.listProviders" {
t.Fatalf("执行器收到 %+v", seen)
}
if m, ok := out.(map[string]any); !ok || m["rows"] != 1 {
t.Fatalf("Dispatch 返回 %#v,期望执行器结果原样透传", out)
}
hub.SetEnabled("web-search", false)
if _, err := hub.Dispatch(context.Background(), req); !errors.Is(err, ErrPluginDisabled) {
t.Fatalf("停用后 err = %v,期望 ErrPluginDisabled", err)
}
}
// TestPanels_UninstallRemovesDeclarationAndActions 锁定卸载口径:声明与动作一起消失
// (「没有声明的面板就不存在」),且目录里不再列出该插件。
func TestPanels_UninstallRemovesDeclarationAndActions(t *testing.T) {
t.Parallel()
hub, rec := newTestHub(t)
if _, err := hub.Declare("web-search", []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare: %v", err)
}
hub.MarkUninstalled("web-search")
if ids := mustIDs(t, hub); len(ids) != 0 {
t.Fatalf("卸载后不该再有面板,实际 %v", ids)
}
if _, ok := hub.Action("web-search", "plugin.saveProvider"); ok {
t.Fatalf("卸载后动作不得仍被声明")
}
if pids := hub.PluginIDs(); len(pids) != 0 {
t.Fatalf("卸载后 PluginIDs = %v,期望空", pids)
}
changes := rec.all()
if len(changes) != 2 || changes[1].Reason != PanelChangeUninstalled {
t.Fatalf("变更通知 = %+v,期望最后一条是 uninstall", changes)
}
}
// TestPanelPluginIDPrefersName 锁定目录键口径:优先插件名(跨重启稳定的
// plugins.json 标签),名字不是合法路径段时才回落 id。
func TestPanelPluginIDPrefersName(t *testing.T) {
t.Parallel()
cases := []struct {
cfg runtimeconfig.PluginConfig
want string
}{
{runtimeconfig.PluginConfig{Name: "web-search", ID: "deadbeef"}, "web-search"},
{runtimeconfig.PluginConfig{Name: "Web Search", ID: "deadbeef"}, "deadbeef"},
{runtimeconfig.PluginConfig{Name: "Web Search"}, ""},
{runtimeconfig.PluginConfig{ID: "deadbeef"}, "deadbeef"},
}
for _, c := range cases {
if got := PanelPluginID(c.cfg); got != c.want {
t.Fatalf("PanelPluginID(%+v) = %q,期望 %q", c.cfg, got, c.want)
}
}
}
// mustIDs 返回某插件目录键下的面板 id 序列(owner 视角)。
func mustIDs(t *testing.T, hub *Panels) []string {
t.Helper()
entries, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
return panelIDs(entries)
}