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 }