Files
Cyrene_v2/internal/capability/plugin/actions_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

221 lines
9.1 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"
"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("普通错误不得被当成结构化失败")
}
}