- 插件管理器加生产动作处理器登记面(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 补)。
221 lines
9.1 KiB
Go
221 lines
9.1 KiB
Go
package plugin
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
)
|
||
|
||
// 本文件锁定切片 L1/PLG-2 的**执行面接线**(design/09 §5.2.1 动作分发):
|
||
// - 登记面按 (pluginId, action) 精确路由,一个插件登记的处理器不替另一个插件作答;
|
||
// - 登记**不**放宽声明闸门:没声明的名字即使有处理器也 404;
|
||
// - 未登记处理器仍诚实回 ErrActionUnavailable(501),绝不假装执行成功;
|
||
// - 登记本身的错误(空命名空间 / 空名字 / nil 处理器)当场拒,不静默半登记。
|
||
|
||
// registerPanel 给 id 登记一份最简声明(一个读来源动作 + 一个写动作)。
|
||
func registerPanel(t *testing.T, hub *Panels, pluginID string, readAction, writeAction string) {
|
||
t.Helper()
|
||
enabled := true
|
||
panel := sdk.Panel{
|
||
ID: "p", Title: "P", Order: 1,
|
||
When: &sdk.When{Enabled: &enabled},
|
||
Sections: []sdk.Section{
|
||
{Type: sdk.SectionForm, ID: "f", Title: "F",
|
||
Fields: []sdk.Field{{Key: "k", Type: sdk.FieldText}},
|
||
Submit: &sdk.Submit{Action: writeAction}},
|
||
{Type: sdk.SectionTable, ID: "t", Title: "T",
|
||
Source: &sdk.Source{Action: readAction},
|
||
Columns: []sdk.Column{{Key: "k"}}},
|
||
},
|
||
}
|
||
if _, err := hub.Declare(pluginID, []sdk.Panel{panel}); err != nil {
|
||
t.Fatalf("Declare(%s): %v", pluginID, err)
|
||
}
|
||
}
|
||
|
||
// TestPanels_RegisterActionsRoutesByPluginAndAction 锁定登记面的精确路由:
|
||
// 同名动作在两个插件下各走各的处理器,返回结果互不串台。
|
||
func TestPanels_RegisterActionsRoutesByPluginAndAction(t *testing.T) {
|
||
t.Parallel()
|
||
hub, _ := newTestHub(t)
|
||
registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider")
|
||
registerPanel(t, hub, "beta", "plugin.listProviders", "plugin.saveProvider")
|
||
|
||
for _, id := range []string{"alpha", "beta"} {
|
||
id := id
|
||
if err := hub.RegisterActions(id, map[string]ActionHandler{
|
||
"plugin.listProviders": func(_ context.Context, r ActionRequest) (any, error) {
|
||
return "from-" + r.PluginID, nil
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("RegisterActions(%s): %v", id, err)
|
||
}
|
||
}
|
||
for _, id := range []string{"alpha", "beta"} {
|
||
got, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: id, Action: "plugin.listProviders"})
|
||
if err != nil {
|
||
t.Fatalf("Dispatch(%s): %v", id, err)
|
||
}
|
||
if got != "from-"+id {
|
||
t.Fatalf("Dispatch(%s) = %v,期望走自己的处理器", id, got)
|
||
}
|
||
}
|
||
// 只登记了读动作:同插件的写动作仍未接线(501),不因「这个插件登记过东西」而放行。
|
||
if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.saveProvider"}); !errors.Is(err, ErrActionUnavailable) {
|
||
t.Fatalf("未单独登记的动作 err = %v,期望 ErrActionUnavailable", err)
|
||
}
|
||
}
|
||
|
||
// TestPanels_RegisteredHandlerCannotBypassDeclaration 锁定登记的从属地位:
|
||
// 处理器存在但动作**没声明** ⇒ 仍然 404(且处理器一次都不跑)。
|
||
func TestPanels_RegisteredHandlerCannotBypassDeclaration(t *testing.T) {
|
||
t.Parallel()
|
||
hub, _ := newTestHub(t)
|
||
registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider")
|
||
|
||
ran := 0
|
||
if err := hub.RegisterActions("alpha", map[string]ActionHandler{
|
||
"plugin.nukeEverything": func(_ context.Context, _ ActionRequest) (any, error) {
|
||
ran++
|
||
return "should-not-run", nil
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("RegisterActions: %v", err)
|
||
}
|
||
if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.nukeEverything"}); !errors.Is(err, ErrActionNotDeclared) {
|
||
t.Fatalf("未声明动作 err = %v,期望 ErrActionNotDeclared", err)
|
||
}
|
||
if ran != 0 {
|
||
t.Fatalf("未声明动作的处理器跑了 %d 次(声明闸门被绕过)", ran)
|
||
}
|
||
// 停用的插件同理:声明还在,但不再分发。
|
||
if err := hub.RegisterActions("alpha", map[string]ActionHandler{
|
||
"plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return "ok", nil },
|
||
}); err != nil {
|
||
t.Fatalf("RegisterActions: %v", err)
|
||
}
|
||
hub.SetEnabled("alpha", false)
|
||
if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.listProviders"}); !errors.Is(err, ErrPluginDisabled) {
|
||
t.Fatalf("停用后 err = %v,期望 ErrPluginDisabled", err)
|
||
}
|
||
}
|
||
|
||
// TestPanels_RegisterActionsRejectsInvalidRegistration 锁定登记期的形状约束:
|
||
// 空命名空间 / 空名字 / nil 处理器一律当场拒(静默忽略会让「点了没反应」重新变成谜)。
|
||
func TestPanels_RegisterActionsRejectsInvalidRegistration(t *testing.T) {
|
||
t.Parallel()
|
||
hub, _ := newTestHub(t)
|
||
ok := func(_ context.Context, _ ActionRequest) (any, error) { return nil, nil }
|
||
|
||
cases := []struct {
|
||
name string
|
||
pluginID string
|
||
handlers map[string]ActionHandler
|
||
}{
|
||
{"空命名空间", "", map[string]ActionHandler{"plugin.listProviders": ok}},
|
||
{"非法命名空间", "not a key", map[string]ActionHandler{"plugin.listProviders": ok}},
|
||
{"空处理器表", "alpha", map[string]ActionHandler{}},
|
||
{"空动作名", "alpha", map[string]ActionHandler{" ": ok}},
|
||
{"nil 处理器", "alpha", map[string]ActionHandler{"plugin.listProviders": nil}},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
err := hub.RegisterActions(tc.pluginID, tc.handlers)
|
||
if !errors.Is(err, ErrInvalidActionRegistration) {
|
||
t.Fatalf("err = %v,期望 ErrInvalidActionRegistration", err)
|
||
}
|
||
})
|
||
}
|
||
// 被拒的登记不得留下任何痕迹(半登记最坏:看起来接好了,实际没人执行)。
|
||
if names := hub.ActionHandlerNames("alpha"); len(names) != 0 {
|
||
t.Fatalf("被拒的登记留下了处理器: %v", names)
|
||
}
|
||
}
|
||
|
||
// TestPanels_RegisterActionsMergesAndOverwrites 锁定重复登记的语义:
|
||
// 第二次登记是**前缀合并**,同名覆盖(插件可分批登记自己的动作面)。
|
||
func TestPanels_RegisterActionsMergesAndOverwrites(t *testing.T) {
|
||
t.Parallel()
|
||
hub, _ := newTestHub(t)
|
||
registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider")
|
||
|
||
first := func(_ context.Context, _ ActionRequest) (any, error) { return "v1", nil }
|
||
second := func(_ context.Context, _ ActionRequest) (any, error) { return "v2", nil }
|
||
if err := hub.RegisterActions("alpha", map[string]ActionHandler{
|
||
"plugin.listProviders": first,
|
||
"plugin.saveProvider": first,
|
||
}); err != nil {
|
||
t.Fatalf("RegisterActions: %v", err)
|
||
}
|
||
if err := hub.RegisterActions("alpha", map[string]ActionHandler{
|
||
"plugin.listProviders": second,
|
||
}); err != nil {
|
||
t.Fatalf("RegisterActions(merge): %v", err)
|
||
}
|
||
if got := hub.ActionHandlerNames("alpha"); strings.Join(got, ",") != "plugin.listProviders,plugin.saveProvider" {
|
||
t.Fatalf("合并后的处理器表 = %v", got)
|
||
}
|
||
out, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.listProviders"})
|
||
if err != nil || out != "v2" {
|
||
t.Fatalf("同名覆盖后 Dispatch = (%v,%v),期望 (v2,nil)", out, err)
|
||
}
|
||
out, err = hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.saveProvider"})
|
||
if err != nil || out != "v1" {
|
||
t.Fatalf("未覆盖的那条 Dispatch = (%v,%v),期望 (v1,nil)", out, err)
|
||
}
|
||
}
|
||
|
||
// TestManager_RegisterActionsRequiresAttachedHub 锁定管理器侧入口的降级口径:
|
||
// 面板中枢未接线时**响亮失败**(组合根会以为动作接好了,而端点在 501)。
|
||
func TestManager_RegisterActionsRequiresAttachedHub(t *testing.T) {
|
||
t.Parallel()
|
||
mgr := NewManager(nil, t.TempDir()+"/plugins.json", nil)
|
||
err := mgr.RegisterActions("alpha", map[string]ActionHandler{
|
||
"plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return nil, nil },
|
||
})
|
||
if !errors.Is(err, ErrInvalidActionRegistration) {
|
||
t.Fatalf("未接线中枢时 err = %v,期望 ErrInvalidActionRegistration", err)
|
||
}
|
||
hub, herr := NewPanels(nil)
|
||
if herr != nil {
|
||
t.Fatalf("NewPanels: %v", herr)
|
||
}
|
||
mgr.AttachPanels(hub)
|
||
if err := mgr.RegisterActions("alpha", map[string]ActionHandler{
|
||
"plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return "ok", nil },
|
||
}); err != nil {
|
||
t.Fatalf("接线后 RegisterActions: %v", err)
|
||
}
|
||
if names := hub.ActionHandlerNames("alpha"); strings.Join(names, ",") != "plugin.listProviders" {
|
||
t.Fatalf("管理器登记没有落到中枢上: %v", names)
|
||
}
|
||
}
|
||
|
||
// TestActionErrorCarriesStructuredFailure 锁定结构化失败的形状与可判类
|
||
// (§5.4.6:错误码与文案分离;病因只进日志、不进响应)。
|
||
func TestActionErrorCarriesStructuredFailure(t *testing.T) {
|
||
t.Parallel()
|
||
cause := errors.New("disk full")
|
||
err := fmt.Errorf("save failed: %w", NewActionError("config_write_failed", "写配置失败", 500, cause))
|
||
|
||
ae, ok := AsActionError(err)
|
||
if !ok {
|
||
t.Fatalf("AsActionError 没认出结构化失败: %v", err)
|
||
}
|
||
if ae.Code != "config_write_failed" || ae.Message != "写配置失败" || ae.Status != 500 {
|
||
t.Fatalf("ActionError = %+v", ae)
|
||
}
|
||
if !errors.Is(err, cause) {
|
||
t.Fatalf("err 链必须穿到病因(errors.Is(cause) 为假): %v", err)
|
||
}
|
||
if _, ok := AsActionError(context.Canceled); ok {
|
||
t.Fatalf("普通错误不得被当成结构化失败")
|
||
}
|
||
}
|