- 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 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
327 lines
12 KiB
Go
327 lines
12 KiB
Go
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)
|
||
}
|