- 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 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
355 lines
13 KiB
Go
355 lines
13 KiB
Go
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
|
||
}
|