Files
Cyrene_v2/internal/services/gateway/admin_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

404 lines
16 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 gateway
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件锁定 design/09 §5.2.1 的两条「唯一契约面」(切片 PLG-2):
// GET /api/v1/admin/panels 与 POST /api/v1/admin/plugins/{id}/actions/{action}。
//
// 断言重点不是「能返回 200」,而是三条红线:
// ① 快照按**调用者角色**过滤(viewer 拿不到写面);
// ② 动作**只接受已声明的名字**(未声明 ⇒ 404),写动作要 owner/admin;
// ③ 每个动作结算都落审计;未接线时诚实 501,绝不假装成功。
// panelTestEnv 是一个接线了「插件管理器 + 面板中枢」的网关。
type panelTestEnv struct {
ts *httptest.Server
srv *Server
hub *pluginmgr.Panels
jwt *auth.JWTService
}
func newPanelTestEnv(t *testing.T) *panelTestEnv {
t.Helper()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
caps := capreg.New()
mgr := pluginmgr.NewManager(caps, filepath.Join(t.TempDir(), "plugins.json"), nil)
hub, err := pluginmgr.NewPanels(nil)
if err != nil {
t.Fatalf("NewPanels: %v", err)
}
mgr.AttachPanels(hub)
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
srv, err := New(cfg, Deps{Auth: jwt, Bus: bus.New(), Registry: caps, Plugins: mgr})
if err != nil {
t.Fatalf("new server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(func() {
ts.Close()
mgr.Close(context.Background())
})
env := &panelTestEnv{ts: ts, srv: srv, hub: hub, jwt: jwt}
env.declare(t, "web-search")
return env
}
// declare 给 id 登记一份 form(写面)+ table(读面 + 行内写动作)的声明。
func (e *panelTestEnv) declare(t *testing.T, pluginID string) {
t.Helper()
if _, err := e.hub.Declare(pluginID, []sdk.Panel{gatewayTestPanel()}); err != nil {
t.Fatalf("Declare(%s): %v", pluginID, err)
}
}
// token 签发一个指定角色的访问令牌。
func (e *panelTestEnv) token(t *testing.T, role auth.Role) string {
t.Helper()
tok, err := e.jwt.Sign("acct-"+string(role), role, time.Hour)
if err != nil {
t.Fatalf("sign %s: %v", role, err)
}
return tok
}
// auditRecords 返回审计链里的记录(最新在前)。
func (e *panelTestEnv) auditRecords(t *testing.T) []AuditRecord {
t.Helper()
recs, err := e.srv.obs.audit.List(context.Background(), 50)
if err != nil {
t.Fatalf("list audit: %v", err)
}
return recs
}
// gatewayTestPanel 是 §5.2 示例形状的声明(form + table)。
func gatewayTestPanel() sdk.Panel {
enabled := true
return sdk.Panel{
ID: "providers",
Title: "搜索提供商",
Order: 20,
Icon: "search",
When: &sdk.When{Enabled: &enabled},
Sections: []sdk.Section{
{
Type: sdk.SectionForm,
ID: "add",
Title: "添加提供商",
Fields: []sdk.Field{
{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: "提供商"},
},
RowActions: []sdk.RowAction{
{ID: "test", Label: "测试连通", Action: "plugin.testProvider"},
},
},
},
}
}
// TestAdminPanelsSnapshotIsRoleFiltered 锁定:同一份声明对 owner 与 viewer 下发的
// 快照不同 —— owner 有 form(写面),viewer 只剩 table 且行内动作被摘掉。
func TestAdminPanelsSnapshotIsRoleFiltered(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
owner := panelsPayloadOf(t, env, auth.RoleOwner)
if owner.Role != "owner" || len(owner.Groups) != 1 {
t.Fatalf("owner 快照 = %+v", owner)
}
g := owner.Groups[0]
if g.ID != "plugins" || g.Title != "插件" || g.Order != 20 {
t.Fatalf("分组 = %+v,期望 plugins/插件/order=20", g)
}
if len(g.Items) != 1 {
t.Fatalf("条目 = %+v,期望 1 条", g.Items)
}
item := g.Items[0]
if item.PluginID != "web-search" || item.ID != "providers" || item.Route != "plugin/web-search/providers" {
t.Fatalf("条目 = %+v", item)
}
if len(item.Sections) != 2 {
t.Fatalf("owner 的区块 = %d 个,期望 form+table 两个", len(item.Sections))
}
if len(owner.PluginIDs) != 1 || owner.PluginIDs[0] != "web-search" {
t.Fatalf("pluginIds = %v(红线③靠它与已安装列表对照)", owner.PluginIDs)
}
viewer := panelsPayloadOf(t, env, auth.RoleViewer)
if viewer.Role != "viewer" || len(viewer.Groups) != 1 {
t.Fatalf("viewer 快照 = %+v", viewer)
}
vs := viewer.Groups[0].Items[0].Sections
if len(vs) != 1 || vs[0].Type != sdk.SectionTable {
t.Fatalf("viewer 的区块 = %+v,期望只剩 table(form 是写面)", vs)
}
if len(vs[0].RowActions) != 0 {
t.Fatalf("viewer 的行内动作应当被摘空,实际 %+v", vs[0].RowActions)
}
}
// panelsPayloadOf 以给定角色拉一次面板快照并解码。
func panelsPayloadOf(t *testing.T, env *panelTestEnv, role auth.Role) AdminPanelsPayload {
t.Helper()
resp, raw := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/panels", nil, env.token(t, role))
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET panels (%s) status = %d, body = %s", role, resp.StatusCode, raw)
}
var out struct {
Data AdminPanelsPayload `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode panels: %v (%s)", err, raw)
}
return out.Data
}
// TestAdminPanelsUnwiredAnswers501 锁定未接线口径:没有面板中枢时诚实回 501,
// 绝不回一份空列表把「没接线」伪装成「没有插件的页面」。
func TestAdminPanelsUnwiredAnswers501(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
_ = srv
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/panels", nil, tok)
if resp.StatusCode != http.StatusNotImplemented || !strings.Contains(string(raw), "panels_unavailable") {
t.Fatalf("GET panels status = %d, body = %s,期望 501 panels_unavailable", resp.StatusCode, raw)
}
resp, raw = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/plugins/calc/actions/plugin.listProviders", nil, tok)
if resp.StatusCode != http.StatusNotImplemented || !strings.Contains(string(raw), "panels_unavailable") {
t.Fatalf("POST action status = %d, body = %s,期望 501 panels_unavailable", resp.StatusCode, raw)
}
}
// TestAdminPluginActionUndeclaredIs404 锁定 §5.2.1 的**动作白名单**:未在声明里
// 出现过的动作名一律 404(插件只给名字,宿主没有可执行的东西)。
func TestAdminPluginActionUndeclaredIs404(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
tok := env.token(t, auth.RoleOwner)
resp, raw := doReq(t, http.MethodPost,
env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.nukeEverything", nil, tok)
if resp.StatusCode != http.StatusNotFound || !strings.Contains(string(raw), "action_not_declared") {
t.Fatalf("status = %d, body = %s,期望 404 action_not_declared", resp.StatusCode, raw)
}
// 没有任何声明的插件同样 404(没有声明就没有名字空间,更谈不上动作)。
resp, raw = doReq(t, http.MethodPost,
env.ts.URL+"/api/v1/admin/plugins/ghost/actions/plugin.listProviders", nil, tok)
if resp.StatusCode != http.StatusNotFound || !strings.Contains(string(raw), "action_not_declared") {
t.Fatalf("未声明插件的动作 status = %d, body = %s,期望 404", resp.StatusCode, raw)
}
}
// TestAdminPluginActionWriteRequiresOwnerOrAdmin 锁定写动作门(§5.4.1):
// viewer 触发 form 的 submit ⇒ 403 且落审计;owner 且已登记处理器 ⇒ 200 且结果透传。
func TestAdminPluginActionWriteRequiresOwnerOrAdmin(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
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"
resp, raw := doReq(t, http.MethodPost, url, map[string]any{"apiKey": "sk-x"}, env.token(t, auth.RoleViewer))
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer 写动作 status = %d, body = %s,期望 403", resp.StatusCode, raw)
}
if !hasAudit(env.auditRecords(t), "plugin.action", "denied") {
t.Fatalf("被拒的写动作必须落审计(denied)")
}
resp, raw = doReq(t, http.MethodPost, url, map[string]any{"apiKey": "sk-x"}, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("owner 写动作 status = %d, body = %s,期望 200", resp.StatusCode, raw)
}
var out struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
if out.Data["action"] != "plugin.saveProvider" || out.Data["kind"] != "write" {
t.Fatalf("动作响应 = %+v", out.Data)
}
if !hasAudit(env.auditRecords(t), "plugin.action", "ok") {
t.Fatalf("成功的写动作必须落审计(ok)")
}
}
// TestAdminPluginActionReadSourceIsOpenToViewer 锁定读来源动作对 viewer 开放
// (§5.4.1「只读事实面对 viewer 可见」):table 的 source 动作不被写门挡住。
func TestAdminPluginActionReadSourceIsOpenToViewer(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
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,
env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.listProviders", nil, env.token(t, auth.RoleViewer))
if resp.StatusCode != http.StatusOK {
t.Fatalf("viewer 读来源动作 status = %d, body = %s,期望 200", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), "searxng") {
t.Fatalf("读来源动作的结果应当透传:%s", raw)
}
}
// TestAdminPluginActionHonest501WithoutHandler 锁定「声明了但宿主没登记处理器」的诚实
// 作答:501 action_not_implemented,绝不回一份假的成功。它**不是**兜底档 ——
// 已登记处理器后同一条路必须真的执行(见下一个用例)。
func TestAdminPluginActionHonest501WithoutHandler(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
resp, raw := doReq(t, http.MethodPost,
env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.testProvider", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusNotImplemented || !strings.Contains(string(raw), "action_not_implemented") {
t.Fatalf("status = %d, body = %s,期望 501 action_not_implemented", resp.StatusCode, raw)
}
if !hasAudit(env.auditRecords(t), "plugin.action", "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)")
}
}
// TestAdminPluginActionDisabledIs409 锁定停用插件不再分发动作(声明还在,入口没了)。
func TestAdminPluginActionDisabledIs409(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
env.hub.SetEnabled("web-search", false)
resp, raw := doReq(t, http.MethodPost,
env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.listProviders", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusConflict || !strings.Contains(string(raw), "plugin_disabled") {
t.Fatalf("status = %d, body = %s,期望 409 plugin_disabled", resp.StatusCode, raw)
}
}
// TestAdminPluginActionRejectsMalformedBody 锁定请求体两条硬边界:
// 非法 JSON ⇒ 400(不当空体继续跑);超限 ⇒ 400(不把整个体读进内存)。
func TestAdminPluginActionRejectsMalformedBody(t *testing.T) {
t.Parallel()
env := newPanelTestEnv(t)
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"
// 手工构造请求:doReq 会把入参 JSON 序列化,无法产生畸形 JSON 体。
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader("{not json"))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+env.token(t, auth.RoleOwner))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
raw := readBody(t, resp)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("非法 JSON status = %d, body = %s,期望 400", resp.StatusCode, raw)
}
big := strings.Repeat("a", panelActionBodyLimit+16)
resp, raw = doReq(t, http.MethodPost, url, map[string]any{"blob": big}, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("超限请求体 status = %d, body = %s,期望 400", resp.StatusCode, raw)
}
}
// readBody 读完并关闭响应体(供手工构造的请求复用)。
func readBody(t *testing.T, resp *http.Response) []byte {
t.Helper()
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
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 {
if r.What == what && r.Result == result {
return true
}
}
return false
}