clearlove2.1
1package main
2
3import (
4 "encoding/json"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "clearlove/internal/config"
14 "clearlove/internal/database"
15 "clearlove/internal/handlers"
16 "clearlove/internal/middleware"
17 "clearlove/internal/models"
18 "clearlove/internal/plugin"
19)
20
21// TestAppPluginHTTPIntegration 端到端验证应用型插件:
22// 完整路由链(中间件 + 插件分发 + 鉴权)与站点模板渲染。
23func TestAppPluginHTTPIntegration(t *testing.T) {
24 root := t.TempDir()
25 dataDir := filepath.Join(root, "data")
26 uploadDir := filepath.Join(root, "uploads")
27 if err := os.MkdirAll(dataDir, 0o755); err != nil {
28 t.Fatal(err)
29 }
30
31 config.Cfg = config.Config{
32 DataDir: dataDir,
33 UploadDir: uploadDir,
34 DBType: "sqlite",
35 SQLitePath: filepath.Join(dataDir, "clearlove.db"),
36 Installed: true, // 跳过安装门禁
37 Secret: "integration-secret",
38 Version: config.Version,
39 }
40 if err := database.Connect(); err != nil {
41 t.Fatalf("数据库连接失败: %v", err)
42 }
43 t.Cleanup(func() {
44 if database.DB != nil {
45 _ = database.DB.Close()
46 database.DB = nil
47 }
48 })
49 if err := database.Migrate(); err != nil {
50 t.Fatalf("建表失败: %v", err)
51 }
52 _ = models.SetSetting("site_name", "集成测试站")
53
54 // 装载模板(同时完成插件视图能力注入)
55 handlers.SetupTemplates(webFS)
56
57 // 准备应用型插件
58 pluginDir := filepath.Join(dataDir, "plugins", "demo")
59 if err := os.MkdirAll(pluginDir, 0o755); err != nil {
60 t.Fatal(err)
61 }
62 writeFile(t, filepath.Join(pluginDir, "plugin.json"), `{
63 "kind": "app", "name": "demo", "slug": "demo", "version": "1.0.0",
64 "runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 800 },
65 "permissions": ["db", "site.read"]
66 }`)
67 writeFile(t, filepath.Join(pluginDir, "main.js"), `
68 function setup() {
69 clv.table("hit", { path: "string", created_at: "time" });
70 clv.route("GET", "/open", "openPage", { auth: "none" });
71 clv.route("GET", "/json", "jsonPage", { auth: "none", json: true });
72 clv.route("GET", "/me", "mePage", { auth: "user" });
73 clv.adminMenu({ label: "演示管理", path: "/", perm: "plugins" });
74 clv.adminPage("/", "adminHome", { perm: "plugins" });
75 clv.slot("footer_html", "foot");
76 clv.middleware("http.before", "guard");
77 }
78 function openPage(ctx) { return { body: "<h1>plugin-open</h1>" }; }
79 function jsonPage(ctx) { return { json: { ok: true, dialect: clv.db.dialect() } }; }
80 function mePage(ctx) { return { json: { uid: ctx.userId } }; }
81 function adminHome(ctx) { return { body: "<h1>plugin-admin</h1>" }; }
82 function foot(data) { return "<span id='pf'>plugin-footer</span>"; }
83 function guard(req) {
84 if (req.path === "/guard-me") return { abort: true, status: 403, body: "blocked" };
85 return null;
86 }
87 `)
88 plugin.SetEnabled("demo", true)
89 if err := plugin.LoadApp("demo"); err != nil {
90 t.Fatalf("加载应用型插件失败: %v", err)
91 }
92 t.Cleanup(func() {
93 plugin.UnloadApp("demo")
94 plugin.SetEnabled("demo", false)
95 })
96
97 srv := middleware.Use(buildMux())
98 do := func(method, path string) *httptest.ResponseRecorder {
99 w := httptest.NewRecorder()
100 srv.ServeHTTP(w, httptest.NewRequest(method, path, nil))
101 return w
102 }
103
104 // 1) 插件公开路由(HTML)
105 if w := do(http.MethodGet, "/x/demo/open"); w.Code != 200 || !strings.Contains(w.Body.String(), "plugin-open") {
106 t.Fatalf("公开路由异常: %d %q", w.Code, w.Body.String())
107 }
108
109 // 2) 插件 JSON 路由
110 w := do(http.MethodGet, "/x/demo/json")
111 if w.Code != 200 {
112 t.Fatalf("JSON 路由异常: %d", w.Code)
113 }
114 var payload map[string]any
115 if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
116 t.Fatalf("JSON 解析失败: %v (%q)", err, w.Body.String())
117 }
118 if payload["dialect"] != "sqlite" {
119 t.Fatalf("dialect 异常: %v", payload["dialect"])
120 }
121
122 // 3) 需要登录的插件路由:未登录跳转
123 if w := do(http.MethodGet, "/x/demo/me"); w.Code != http.StatusFound {
124 t.Fatalf("未登录应跳转,实际 %d", w.Code)
125 }
126
127 // 4) 插件中间件短路
128 if w := do(http.MethodGet, "/guard-me"); w.Code != http.StatusForbidden {
129 t.Fatalf("中间件未短路: %d", w.Code)
130 }
131
132 // 5) 站点首页正常渲染,并包含插件 footer slot 输出
133 w = do(http.MethodGet, "/")
134 if w.Code != 200 {
135 t.Fatalf("首页异常: %d", w.Code)
136 }
137 if !strings.Contains(w.Body.String(), "plugin-footer") {
138 t.Fatalf("footer slot 未注入首页")
139 }
140
141 // 6) 后台页面未登录跳转登录页
142 if w := do(http.MethodGet, "/admin/plugins/demo/"); w.Code != http.StatusFound {
143 t.Fatalf("后台页面未拦截: %d", w.Code)
144 }
145}
146
147func writeFile(t *testing.T, path, content string) {
148 t.Helper()
149 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
150 t.Fatal(err)
151 }
152}
153
154// copyDir 递归复制目录(测试里把仓库示例插件装进临时站点)
155func copyDir(t *testing.T, src, dst string) {
156 t.Helper()
157 entries, err := os.ReadDir(src)
158 if err != nil {
159 t.Fatal(err)
160 }
161 if err := os.MkdirAll(dst, 0o755); err != nil {
162 t.Fatal(err)
163 }
164 for _, e := range entries {
165 s := filepath.Join(src, e.Name())
166 d := filepath.Join(dst, e.Name())
167 if e.IsDir() {
168 copyDir(t, s, d)
169 continue
170 }
171 b, err := os.ReadFile(s)
172 if err != nil {
173 t.Fatal(err)
174 }
175 if err := os.WriteFile(d, b, 0o644); err != nil {
176 t.Fatal(err)
177 }
178 }
179}
180
181// TestAIPolishPlugin 端到端验证「AI 语句美化」应用型插件:
182// 静态 hook 注入、可用性探测、调用本机 AI 服务、长度限制。
183func TestAIPolishPlugin(t *testing.T) {
184 dataDir := setupSite(t)
185 copyDir(t, filepath.Join("plugins", "ai-polish"), filepath.Join(dataDir, "plugins", "ai-polish"))
186
187 plugin.SetEnabled("AI 语句美化", true)
188 if err := plugin.LoadApp("ai-polish"); err != nil {
189 t.Fatalf("加载 AI 美化插件失败: %v", err)
190 }
191 defer func() {
192 plugin.UnloadApp("ai-polish")
193 plugin.SetEnabled("AI 语句美化", false)
194 }()
195
196 // 1) 声明式 hook 静态注入按钮脚本(不占用运行时)
197 if got := plugin.CallHTML("footer_html"); !strings.Contains(got, "/x/ai-polish") {
198 t.Fatalf("未注入美化脚本: %q", got)
199 }
200
201 srv := middleware.Use(buildMux())
202 do := func(method, path, body, csrf string) *httptest.ResponseRecorder {
203 var rd io.Reader
204 if body != "" {
205 rd = strings.NewReader(body)
206 }
207 req := httptest.NewRequest(method, path, rd)
208 if body != "" {
209 req.Header.Set("Content-Type", "application/json")
210 }
211 if csrf != "" {
212 req.Header.Set("X-CSRF-Token", csrf)
213 req.AddCookie(&http.Cookie{Name: "clv_csrf", Value: csrf})
214 }
215 w := httptest.NewRecorder()
216 srv.ServeHTTP(w, req)
217 return w
218 }
219
220 w0 := do(http.MethodGet, "/", "", "")
221 csrf := ""
222 for _, c := range w0.Result().Cookies() {
223 if c.Name == "clv_csrf" {
224 csrf = c.Value
225 }
226 }
227 if csrf == "" {
228 t.Fatal("未取到 CSRF 令牌")
229 }
230
231 // 2) 未接入 AI:config 标记不可用,polish 明确拒绝
232 if w := do(http.MethodGet, "/x/ai-polish/config", "", ""); !strings.Contains(w.Body.String(), `"available":false`) {
233 t.Fatalf("未接入 AI 时可用性判断异常: %s", w.Body.String())
234 }
235 if w := do(http.MethodPost, "/x/ai-polish/polish", `{"text":"你好呀"}`, csrf); w.Code != 400 {
236 t.Fatalf("未接入 AI 应返回 400,实际 %d: %s", w.Code, w.Body.String())
237 }
238
239 // 3) 配置本机 AI 服务(模拟 OpenAI 兼容接口,验证 net.local 权限放行内网)
240 ai := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
241 if r.URL.Path != "/chat/completions" {
242 http.NotFound(rw, r)
243 return
244 }
245 if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
246 rw.WriteHeader(http.StatusUnauthorized)
247 return
248 }
249 rw.Header().Set("Content-Type", "application/json")
250 _, _ = rw.Write([]byte(`{"choices":[{"message":{"content":"\"今天天气很好,适合出门走走。\""}}]}`))
251 }))
252 defer ai.Close()
253
254 _ = models.SetSetting("ai_enabled", "1")
255 _ = models.SetSetting("ai_base", ai.URL)
256 _ = models.SetSetting("ai_key", "test-key")
257 _ = models.SetSetting("ai_model", "test-model")
258
259 if w := do(http.MethodGet, "/x/ai-polish/config", "", ""); !strings.Contains(w.Body.String(), `"available":true`) {
260 t.Fatalf("接入 AI 后可用性判断异常: %s", w.Body.String())
261 }
262
263 // 4) 美化成功,并清理模型输出的引号包裹
264 w := do(http.MethodPost, "/x/ai-polish/polish", `{"text":"今天天气不错"}`, csrf)
265 if w.Code != 200 {
266 t.Fatalf("美化失败: %d %s", w.Code, w.Body.String())
267 }
268 body := w.Body.String()
269 if !strings.Contains(body, "今天天气很好,适合出门走走。") {
270 t.Fatalf("返回内容异常: %s", body)
271 }
272 if strings.Contains(body, `\"今天天气很好`) {
273 t.Fatalf("未清理引号包裹: %s", body)
274 }
275
276 // 5) 超出单次处理上限被拒绝
277 long := strings.Repeat("啊", 700)
278 if w := do(http.MethodPost, "/x/ai-polish/polish", `{"text":"`+long+`"}`, csrf); w.Code != 400 {
279 t.Fatalf("超长内容应被拒绝,实际 %d", w.Code)
280 }
281}
282
283// setupSite 初始化一次完整的站点环境(数据库 + 模板)
284func setupSite(t *testing.T) (dataDir string) {
285 t.Helper()
286 root := t.TempDir()
287 dataDir = filepath.Join(root, "data")
288 if err := os.MkdirAll(dataDir, 0o755); err != nil {
289 t.Fatal(err)
290 }
291 config.Cfg = config.Config{
292 DataDir: dataDir,
293 UploadDir: filepath.Join(root, "uploads"),
294 DBType: "sqlite",
295 SQLitePath: filepath.Join(dataDir, "clearlove.db"),
296 Installed: true,
297 Secret: "integration-secret",
298 Version: config.Version,
299 }
300 if err := database.Connect(); err != nil {
301 t.Fatalf("数据库连接失败: %v", err)
302 }
303 t.Cleanup(func() {
304 if database.DB != nil {
305 _ = database.DB.Close()
306 database.DB = nil
307 }
308 })
309 if err := database.Migrate(); err != nil {
310 t.Fatalf("建表失败: %v", err)
311 }
312 _ = models.SetSetting("site_name", "集成测试站")
313 handlers.SetupTemplates(webFS)
314 return dataDir
315}
316
317// TestPluginFiltersTemplateAndLogs 覆盖新增能力:
318// 过滤器(card / api.response / post.visible)、http.after、模板覆盖与运行日志。
319func TestPluginFiltersTemplateAndLogs(t *testing.T) {
320 dataDir := setupSite(t)
321
322 pluginDir := filepath.Join(dataDir, "plugins", "fx")
323 if err := os.MkdirAll(filepath.Join(pluginDir, "templates"), 0o755); err != nil {
324 t.Fatal(err)
325 }
326 writeFile(t, filepath.Join(pluginDir, "plugin.json"), `{
327 "kind": "app", "name": "fx", "slug": "fx", "version": "1.0.0",
328 "runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 800 },
329 "permissions": ["db", "cache"]
330 }`)
331 writeFile(t, filepath.Join(pluginDir, "main.js"), `
332 function setup() {
333 clv.filter("filter.card", 10, "cardFilter");
334 clv.filter("filter.api.response", 10, "respFilter");
335 clv.filter("filter.post.visible", 10, "visibleFilter");
336 clv.middleware("http.after", "afterLog");
337 }
338 function cardFilter(card) { card.nickname = "改写:" + card.nickname; return card; }
339 function respFilter(resp) { resp.plugin_tag = "fx"; return resp; }
340 function visibleFilter(ok, ctx) { return (ctx && ctx.post_id === 999) ? false : ok; }
341 function afterLog(info) { clv.cache.set("last_status", info.status, 60); }
342 function __status() { return clv.cache.get("last_status") || 0; }
343 `)
344 // 模板覆盖:插件目录下的同名模板会覆盖内核片段
345 writeFile(t, filepath.Join(pluginDir, "templates", "pg_index.html"),
346 `{{define "pg_index"}}<div id="plugin-override">OVERRIDE</div>{{end}}`)
347
348 plugin.SetEnabled("fx", true)
349 if err := plugin.LoadApp("fx"); err != nil {
350 t.Fatalf("加载失败: %v", err)
351 }
352 defer func() {
353 plugin.UnloadApp("fx")
354 plugin.SetEnabled("fx", false)
355 handlers.ReloadTemplates()
356 }()
357 handlers.ReloadTemplates() // 应用插件模板覆盖
358
359 // 准备一条帖子(含一条 id=999 用于可见性过滤)
360 if _, err := database.DB.Exec(
361 `INSERT INTO posts(id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at)
362 VALUES(1,'原昵称','正文',0,'','',1,0,'',?)`, models.Now()); err != nil {
363 t.Fatal(err)
364 }
365
366 srv := middleware.Use(buildMux())
367 do := func(method, path string) *httptest.ResponseRecorder {
368 w := httptest.NewRecorder()
369 srv.ServeHTTP(w, httptest.NewRequest(method, path, nil))
370 return w
371 }
372
373 // 1) 模板覆盖生效
374 if w := do(http.MethodGet, "/"); !strings.Contains(w.Body.String(), "plugin-override") {
375 t.Fatalf("模板覆盖未生效: %q", w.Body.String())
376 }
377
378 // 2) filter.api.response 改写响应体
379 if w := do(http.MethodGet, "/api/v1/topics"); !strings.Contains(w.Body.String(), "plugin_tag") {
380 t.Fatalf("filter.api.response 未生效: %q", w.Body.String())
381 }
382
383 // 3) filter.card 改写卡片数据
384 w := do(http.MethodGet, "/api/v1/posts?limit=5")
385 if !strings.Contains(w.Body.String(), "改写:原昵称") {
386 t.Fatalf("filter.card 未生效: %q", w.Body.String())
387 }
388
389 // 4) filter.post.visible 否决可见性
390 if _, err := database.DB.Exec(
391 `INSERT INTO posts(id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at)
392 VALUES(999,'x','y',0,'','',1,0,'',?)`, models.Now()); err != nil {
393 t.Fatal(err)
394 }
395 if w := do(http.MethodGet, "/post/999"); w.Code != http.StatusNotFound {
396 t.Fatalf("filter.post.visible 未生效: %d", w.Code)
397 }
398
399 // 5) http.after 被调用(状态码写入插件缓存)
400 out, err := plugin.CallFn("fx", "__status")
401 if err != nil {
402 t.Fatalf("脚本调用失败: %v", err)
403 }
404 if n, ok := out.(int64); !ok || n == 0 {
405 t.Fatalf("http.after 未执行: %v", out)
406 }
407
408 // 6) 运行日志可读
409 logs := plugin.PluginLogs("fx", 50)
410 if len(logs) == 0 {
411 t.Fatal("插件运行日志为空")
412 }
413}
414
415// TestAppPluginHTTPIntegration 端到端验证应用型插件:
416// 完整路由链(中间件 + 插件分发 + 鉴权)与站点模板渲染。