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) }