package gateway import ( "encoding/json" "net/http" "os" "path/filepath" "strings" "testing" capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // dailyBriefYAML 是一份合法的 Skill 定义:两步工作流 + 一条引用 + // 一条护栏,用来断言注册进注册表的确实是「工作流」而不是空壳。 const dailyBriefYAML = `name: daily-brief description: 每日简报 prompt: 你是简报助手 steps: - name: gather prompt: 收集今日要点 - name: summarize prompt: 归纳成 3 条 depends: [gather] refs: - name: calendar type: tool value: calendar guardrails: - id: no-pii message: 不要泄露隐私 enforced: true ` // skillInstallResult 是 install 响应中的 skill 分支字段。 type skillInstallResult struct { Installed bool `json:"installed"` Name string `json:"name"` Kind string `json:"kind"` Refreshed bool `json:"refreshed"` Steps int `json:"steps"` Guardrails int `json:"guardrails"` } // errorEnvelope 是 {error:{code,message}} 信封的最小投影。 type errorEnvelope struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } // installSkill 发起一次 skill 安装并返回状态码与原始响应体。 func installSkill(t *testing.T, env *pluginTestEnv, body any) (int, []byte) { t.Helper() resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", body, env.token) return resp.StatusCode, raw } // TestAdminPluginInstallSkill_RegistersWorkflowCapability 是本条的真断言: // POST kind="skill" 之后能力注册表里真的出现该 Skill(kind=skill), // 且其步骤/引用/护栏按 YAML 落地 —— 而不是只回一个 200。 func TestAdminPluginInstallSkill_RegistersWorkflowCapability(t *testing.T) { t.Parallel() env := newPluginTestEnv(t, nil) status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": dailyBriefYAML}) if status != http.StatusOK { t.Fatalf("install status = %d, body = %s", status, raw) } var envelope struct { Data skillInstallResult `json:"data"` } if err := json.Unmarshal(raw, &envelope); err != nil { t.Fatalf("decode: %v (%s)", err, raw) } if !envelope.Data.Installed || envelope.Data.Kind != "skill" || envelope.Data.Name != "daily-brief" || envelope.Data.Refreshed { t.Fatalf("install data = %+v", envelope.Data) } if envelope.Data.Steps != 2 || envelope.Data.Guardrails != 1 { t.Fatalf("install data counts = %+v, want 2 steps / 1 guardrail", envelope.Data) } cap, err := env.caps.Get("daily-brief") if err != nil { t.Fatalf("registry lookup: %v", err) } if cap.Kind != capreg.KindSkill { t.Fatalf("capability kind = %q, want %q", cap.Kind, capreg.KindSkill) } skill, ok := cap.Value.(sdk.Skill) if !ok { t.Fatalf("capability value = %T, want sdk.Skill", cap.Value) } if got := skill.Steps(); len(got) != 2 || got[1].Name != "summarize" || len(got[1].Depends) != 1 { t.Fatalf("steps = %+v", got) } if got := skill.Refs(); len(got) != 1 || got[0].Value != "calendar" { t.Fatalf("refs = %+v", got) } if got := skill.Guardrails(); len(got) != 1 || !got[0].Enforced || got[0].Message != "不要泄露隐私" { t.Fatalf("guardrails = %+v", got) } // 管理读面必须看得见它(面板靠 GET /admin/plugins 列出 kind=skill)。 resp, raw := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/plugins", nil, env.token) if resp.StatusCode != http.StatusOK { t.Fatalf("plugins list status = %d, body = %s", resp.StatusCode, raw) } var list struct { Data []PluginSummary `json:"data"` } if err := json.Unmarshal(raw, &list); err != nil { t.Fatalf("decode list: %v (%s)", err, raw) } found := false for _, it := range list.Data { if it.Name == "daily-brief" && it.Kind == "skill" && it.Description == "每日简报" { found = true } } if !found { t.Fatalf("skill not visible in /admin/plugins: %+v", list.Data) } } // TestAdminPluginInstallSkill_ReinstallRefreshesSameName 钉住「重复安装同名 = // 刷新」语义:第二次带新描述的 YAML 必须换掉旧定义,而不是报冲突、 // 更不是留下旧定义(否则面板「保存成功」是假的)。 func TestAdminPluginInstallSkill_ReinstallRefreshesSameName(t *testing.T) { t.Parallel() env := newPluginTestEnv(t, nil) if status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": dailyBriefYAML}); status != http.StatusOK { t.Fatalf("first install status = %d, body = %s", status, raw) } updated := strings.Replace(dailyBriefYAML, "description: 每日简报", "description: 每日简报 v2", 1) status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": updated}) if status != http.StatusOK { t.Fatalf("refresh status = %d, body = %s", status, raw) } var envelope struct { Data skillInstallResult `json:"data"` } if err := json.Unmarshal(raw, &envelope); err != nil { t.Fatalf("decode: %v (%s)", err, raw) } if !envelope.Data.Refreshed { t.Fatalf("refreshed = false, want true for same-name reinstall: %s", raw) } cap, err := env.caps.Get("daily-brief") if err != nil { t.Fatalf("registry lookup: %v", err) } if cap.Description != "每日简报 v2" { t.Fatalf("description after refresh = %q, want 每日简报 v2", cap.Description) } if n := len(env.caps.List(capreg.KindSkill)); n != 1 { t.Fatalf("skill count = %d, want 1 (refresh must not duplicate)", n) } } // TestAdminPluginInstallSkill_FromFile 覆盖 design/09 §2.1 的第二种来源: // 磁盘 YAML 路径。同时验证卸载沿用通用能力移除路径。 func TestAdminPluginInstallSkill_FromFile(t *testing.T) { t.Parallel() env := newPluginTestEnv(t, nil) path := filepath.Join(t.TempDir(), "daily-brief.yaml") if err := os.WriteFile(path, []byte(dailyBriefYAML), 0o600); err != nil { t.Fatalf("write skill file: %v", err) } status, raw := installSkill(t, env, map[string]any{"kind": "skill", "path": path}) if status != http.StatusOK { t.Fatalf("install status = %d, body = %s", status, raw) } if _, err := env.caps.Get("daily-brief"); err != nil { t.Fatalf("skill not registered from file: %v", err) } resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", map[string]any{"name": "daily-brief"}, env.token) if resp.StatusCode != http.StatusOK { t.Fatalf("uninstall status = %d, body = %s", resp.StatusCode, raw) } if _, err := env.caps.Get("daily-brief"); err == nil { t.Fatal("skill still registered after uninstall") } } // TestAdminPluginInstallSkill_RejectsNameConflictWithOtherKind 证明刷新语义 // 不会越界:同名已被一个 tool 占用时返回 409,且那个 tool 必须原样存活 // (绝不先删后装)。 func TestAdminPluginInstallSkill_RejectsNameConflictWithOtherKind(t *testing.T) { t.Parallel() env := newPluginTestEnv(t, nil) if err := env.caps.RegisterTool(stubTool{}); err != nil { // 名为 "echo" 的工具 t.Fatalf("register tool: %v", err) } conflicting := strings.Replace(dailyBriefYAML, "name: daily-brief", "name: echo", 1) status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": conflicting}) if status != http.StatusConflict { t.Fatalf("status = %d, want 409, body = %s", status, raw) } var envErr errorEnvelope if err := json.Unmarshal(raw, &envErr); err != nil { t.Fatalf("decode error envelope: %v (%s)", err, raw) } if envErr.Error.Code != "name_conflict" { t.Fatalf("error code = %q, want name_conflict (%s)", envErr.Error.Code, raw) } cap, err := env.caps.Get("echo") if err != nil { t.Fatalf("tool echo disappeared after a rejected skill install: %v", err) } if cap.Kind != capreg.KindTool { t.Fatalf("capability echo kind = %q, want %q", cap.Kind, capreg.KindTool) } } // TestAdminPluginInstallSkill_InvalidRequestsReturn400 逐条钉住非法输入的 // 显式 400(不静默、不留半安装状态)。 func TestAdminPluginInstallSkill_InvalidRequestsReturn400(t *testing.T) { t.Parallel() env := newPluginTestEnv(t, nil) dir := t.TempDir() leaky := filepath.Join(dir, "leaky.yaml") // 类型不匹配会让 yaml 解码器把出错值的前几个字符写进错误信息, // 因此「文件来源」这条必须只回显通用原因(见下一条用例)。 if err := os.WriteFile(leaky, []byte("name: leaky\nsteps: \"LEAKMARKER_SECRET\"\n"), 0o600); err != nil { t.Fatalf("write leaky skill: %v", err) } cases := []struct { name string body map[string]any wantCode string }{ {"neither yaml nor path", map[string]any{"kind": "skill"}, "bad_request"}, {"yaml and path", map[string]any{"kind": "skill", "yaml": dailyBriefYAML, "path": leaky}, "bad_request"}, {"name mismatch", map[string]any{"kind": "skill", "name": "other", "yaml": dailyBriefYAML}, "name_mismatch"}, {"blank name in yaml", map[string]any{"kind": "skill", "yaml": "description: 无名字\n"}, "skill_parse_failed"}, {"not yaml at all", map[string]any{"kind": "skill", "yaml": "]{ not yaml"}, "skill_parse_failed"}, {"missing file", map[string]any{"kind": "skill", "path": filepath.Join(dir, "nope.yaml")}, "skill_parse_failed"}, {"path is a directory", map[string]any{"kind": "skill", "path": dir}, "skill_parse_failed"}, {"bad yaml file", map[string]any{"kind": "skill", "path": leaky}, "skill_parse_failed"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() status, raw := installSkill(t, env, tc.body) if status != http.StatusBadRequest { t.Fatalf("status = %d, want 400, body = %s", status, raw) } var envErr errorEnvelope if err := json.Unmarshal(raw, &envErr); err != nil { t.Fatalf("decode error envelope: %v (%s)", err, raw) } if envErr.Error.Code != tc.wantCode { t.Fatalf("error code = %q, want %q (%s)", envErr.Error.Code, tc.wantCode, raw) } }) } if n := len(env.caps.List(capreg.KindSkill)); n != 0 { t.Fatalf("skills registered after rejected installs = %d, want 0", n) } } // TestAdminPluginInstallSkill_FileParseErrorDoesNotEchoContent 是 // 上一条的安全面:文件由服务端读取,解析错误可能带出文件片段 // (yaml 解码器会打印出错值的前 7 个字符),因此响应体绝不回显 // 内容,只给通用原因 + 路径。 func TestAdminPluginInstallSkill_FileParseErrorDoesNotEchoContent(t *testing.T) { t.Parallel() env := newPluginTestEnv(t, nil) path := filepath.Join(t.TempDir(), "leaky.yaml") if err := os.WriteFile(path, []byte("name: leaky\nsteps: \"LEAKMARKER_SECRET\"\n"), 0o600); err != nil { t.Fatalf("write leaky skill: %v", err) } status, raw := installSkill(t, env, map[string]any{"kind": "skill", "path": path}) if status != http.StatusBadRequest { t.Fatalf("status = %d, want 400, body = %s", status, raw) } // "LEAKMAR" 正是解码器会带出的前 7 个字符;回显内联内容时它必现, // 因此这一断言能真正抓住「把文件内容透给客户端」的回归。 if strings.Contains(string(raw), "LEAKMAR") { t.Fatalf("file-sourced parse error echoed file content: %s", raw) } var envErr errorEnvelope if err := json.Unmarshal(raw, &envErr); err != nil { t.Fatalf("decode error envelope: %v (%s)", err, raw) } if strings.Contains(envErr.Error.Message, "LEAKMAR") { t.Fatalf("decoded message leaked file content: %s", envErr.Error.Message) } if !strings.Contains(envErr.Error.Message, path) { t.Fatalf("error should name the file path, got %q", envErr.Error.Message) } }