Files
Cyrene_v2/internal/services/gateway/admin_panels_test.go
T
AskaEth 2d21aa553e feat(plugins): 插件管理器接线 —— 面板接入点快照 + 动作分发(PLG-2)
- GET /api/v1/admin/panels:按调用者角色过滤后的接入点快照(PLG-1 的 catalog 挂进插件管理器)
- POST /api/v1/admin/plugins/{id}/actions/{action}:**只接受该插件声明过的动作名**
  (未声明 ⇒ 404);写动作 writeAuth + 落审计;插件只给动作名不给 URL ⇒ 不给插件开任意请求面
- 生命周期:install/uninstall/enable/disable 变化后**重算面板集合**并经进程内 bus 推 panels_changed
  (前端可热更新;不新开端口)

契约 v0.78 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
2026-10-06 09:03:57 +08:00

355 lines
13 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"
"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)
env.hub.SetExecutor(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)
env.hub.SetExecutor(func(_ context.Context, req 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)
}
}
// TestAdminPluginActionHonest501WithoutExecutor 锁定「声明了但没接线」的诚实作答:
// 501 action_not_implemented,绝不回一份假的成功。
func TestAdminPluginActionHonest501WithoutExecutor(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)")
}
}
// 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)
env.hub.SetExecutor(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
}
// 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
}