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 补)。
This commit is contained in:
1 parent
a150211468
commit
2e1c3ed3fd
13 files changed
+2043
-204
No files matched your search
@@ -3,6 +3,7 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -219,11 +220,11 @@ func TestAdminPluginActionUndeclaredIs404(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAdminPluginActionWriteRequiresOwnerOrAdmin 锁定写动作门(§5.4.1):
|
||||
// viewer 触发 form 的 submit ⇒ 403 且落审计;owner 且已接线执行器 ⇒ 200 且结果透传。
|
||||
// viewer 触发 form 的 submit ⇒ 403 且落审计;owner 且已登记处理器 ⇒ 200 且结果透传。
|
||||
func TestAdminPluginActionWriteRequiresOwnerOrAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPanelTestEnv(t)
|
||||
env.hub.SetExecutor(func(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
registerTestActions(t, env.hub, "plugin.saveProvider", func(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
return map[string]any{"saved": req.Action, "payload": req.Payload}, nil
|
||||
})
|
||||
url := env.ts.URL + "/api/v1/admin/plugins/web-search/actions/plugin.saveProvider"
|
||||
@@ -259,7 +260,7 @@ func TestAdminPluginActionWriteRequiresOwnerOrAdmin(t *testing.T) {
|
||||
func TestAdminPluginActionReadSourceIsOpenToViewer(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPanelTestEnv(t)
|
||||
env.hub.SetExecutor(func(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
registerTestActions(t, env.hub, "plugin.listProviders", func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
return []map[string]any{{"provider": "searxng"}}, nil
|
||||
})
|
||||
resp, raw := doReq(t, http.MethodPost,
|
||||
@@ -272,9 +273,10 @@ func TestAdminPluginActionReadSourceIsOpenToViewer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPluginActionHonest501WithoutExecutor 锁定「声明了但没接线」的诚实作答:
|
||||
// 501 action_not_implemented,绝不回一份假的成功。
|
||||
func TestAdminPluginActionHonest501WithoutExecutor(t *testing.T) {
|
||||
// TestAdminPluginActionHonest501WithoutHandler 锁定「声明了但宿主没登记处理器」的诚实
|
||||
// 作答:501 action_not_implemented,绝不回一份假的成功。它**不是**兜底档 ——
|
||||
// 已登记处理器后同一条路必须真的执行(见下一个用例)。
|
||||
func TestAdminPluginActionHonest501WithoutHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPanelTestEnv(t)
|
||||
resp, raw := doReq(t, http.MethodPost,
|
||||
@@ -283,7 +285,45 @@ func TestAdminPluginActionHonest501WithoutExecutor(t *testing.T) {
|
||||
t.Fatalf("status = %d, body = %s,期望 501 action_not_implemented", resp.StatusCode, raw)
|
||||
}
|
||||
if !hasAudit(env.auditRecords(t), "plugin.action", "unimplemented") {
|
||||
t.Fatalf("未接线的动作结算必须落审计(unimplemented)")
|
||||
t.Fatalf("未登记处理器的动作结算必须落审计(unimplemented)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPluginActionMapsStructuredFailure 锁定插件侧结构化失败的透传口径
|
||||
// (§5.4.6):错误码与文案分离、状态码由实现给出、病因只进日志;且失败也落审计。
|
||||
func TestAdminPluginActionMapsStructuredFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPanelTestEnv(t)
|
||||
registerTestActions(t, env.hub, "plugin.testProvider", func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
return nil, pluginmgr.NewActionError("unauthorized", "上游拒绝了凭据(401/403):请核对 API Key 是否有效",
|
||||
http.StatusBadGateway, errors.New("upstream 401"))
|
||||
})
|
||||
resp, raw := doReq(t, http.MethodPost,
|
||||
env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.testProvider",
|
||||
map[string]any{"row": map[string]any{"provider": "zhipu"}}, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, body = %s,期望 502(上游失败)", resp.StatusCode, raw)
|
||||
}
|
||||
var out struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
if out.Error.Code != "unauthorized" {
|
||||
t.Fatalf("错误码 = %q,期望实现给出的 unauthorized(不是笼统的 action_failed)", out.Error.Code)
|
||||
}
|
||||
if !strings.Contains(out.Error.Message, "上游拒绝了凭据") {
|
||||
t.Fatalf("文案 = %q,期望实现给出的那句话(错误码与文案分离)", out.Error.Message)
|
||||
}
|
||||
if strings.Contains(string(raw), "upstream 401") {
|
||||
t.Fatalf("病因(底层报错原文)不得进响应体:%s", raw)
|
||||
}
|
||||
if !hasAudit(env.auditRecords(t), "plugin.action", "error") {
|
||||
t.Fatalf("失败的插件动作必须落审计(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +344,7 @@ func TestAdminPluginActionDisabledIs409(t *testing.T) {
|
||||
func TestAdminPluginActionRejectsMalformedBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPanelTestEnv(t)
|
||||
env.hub.SetExecutor(func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
registerTestActions(t, env.hub, "plugin.saveProvider", func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
return "should-not-run", nil
|
||||
})
|
||||
url := env.ts.URL + "/api/v1/admin/plugins/web-search/actions/plugin.saveProvider"
|
||||
@@ -343,6 +383,15 @@ func readBody(t *testing.T, resp *http.Response) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
// registerTestActions 给测试插件(web-search)登记一个动作处理器 —— 走的是生产同一条
|
||||
// 登记面(`Panels.RegisterActions`),不另开测试专用的后门。
|
||||
func registerTestActions(t *testing.T, hub *pluginmgr.Panels, action string, handler pluginmgr.ActionHandler) {
|
||||
t.Helper()
|
||||
if err := hub.RegisterActions("web-search", map[string]pluginmgr.ActionHandler{action: handler}); err != nil {
|
||||
t.Fatalf("RegisterActions(%s): %v", action, err)
|
||||
}
|
||||
}
|
||||
|
||||
// hasAudit 报告审计链里是否存在指定的 (what, result) 记录。
|
||||
func hasAudit(recs []AuditRecord, what, result string) bool {
|
||||
for _, r := range recs {
|
||||
|
||||
Reference in new issue
Block a user