Files
Cyrene_v2/internal/capability/plugin/panels_test.go
T
AskaEth 2e1c3ed3fd feat(plugins): 面板动作执行器接线 —— 动作真可用,不再 501 兜底(L1)
- 插件管理器加生产动作处理器登记面(RegisterActions);未登记处理器/未声明动作仍 404
- web-search 四个动作真读写真源 + 真发 HTTP 探针:plugin.listProviders/saveProvider/
  testProvider/reauthProvider(配置落 $DATA_DIR/config/plugins/web-search.json,0600)
- 失败回结构化错误码(不含上游原文)+ 审计带 code 字段;501 文案改为「未登记处理器」
- 拆出 panelactions.go(动作索引推导/OptionsActionName/三闸门分发),panels.go 519→347 行

缺口(如实):面板保存后运行中的工具**不热重载**(下一片做);声明面缺 primary/fallback
(按 DECISIONS-2026-10-06 #1/#2 补)。
2026-10-06 14:12:23 +08:00

355 lines
13 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 与下拉的 optionsFrom 是读来源,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},
// 下拉候选来源 `plugin:providers` 也进索引(读面):前端按它拉候选,
// 宿主不认这个名字,控件就只能降级成文本框。
{"plugin.providers", ActionRead},
}
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.providers,plugin.saveProvider,plugin.testProvider" {
t.Fatalf("DeclaredActions = %v", got)
}
}
// TestOptionsActionNameMatchesFrontendMapping 锁定 `optionsFrom` → 动作名的映射
// 与前端 `resolveOptionsAction` **逐字同源**(前端片 PLG-3 登记的口径):
// 两处一旦分叉,声明的下拉就会去调一个宿主不认识的名字。
func TestOptionsActionNameMatchesFrontendMapping(t *testing.T) {
t.Parallel()
cases := map[string]string{
"plugin:providers": "plugin.providers",
"plugin:search.regions": "plugin.search.regions",
" plugin:providers ": "plugin.providers",
"plugin:": "",
"providers": "",
"": "",
}
for in, want := range cases {
if got := OptionsActionName(in); got != want {
t.Errorf("OptionsActionName(%q) = %q, want %q", in, got, want)
}
}
}
// 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
if err := hub.RegisterActions("web-search", map[string]ActionHandler{
"plugin.listProviders": func(_ context.Context, r ActionRequest) (any, error) {
seen = r
return map[string]any{"rows": 1}, nil
},
}); err != nil {
t.Fatalf("RegisterActions: %v", err)
}
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)
}