package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"clearlove/internal/config"
"clearlove/internal/database"
"clearlove/internal/handlers"
"clearlove/internal/middleware"
"clearlove/internal/models"
"clearlove/internal/plugin"
)
// TestAppPluginHTTPIntegration 端到端验证应用型插件:
// 完整路由链(中间件 + 插件分发 + 鉴权)与站点模板渲染。
func TestAppPluginHTTPIntegration(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "data")
uploadDir := filepath.Join(root, "uploads")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
config.Cfg = config.Config{
DataDir: dataDir,
UploadDir: uploadDir,
DBType: "sqlite",
SQLitePath: filepath.Join(dataDir, "clearlove.db"),
Installed: true, // 跳过安装门禁
Secret: "integration-secret",
Version: config.Version,
}
if err := database.Connect(); err != nil {
t.Fatalf("数据库连接失败: %v", err)
}
t.Cleanup(func() {
if database.DB != nil {
_ = database.DB.Close()
database.DB = nil
}
})
if err := database.Migrate(); err != nil {
t.Fatalf("建表失败: %v", err)
}
_ = models.SetSetting("site_name", "集成测试站")
// 装载模板(同时完成插件视图能力注入)
handlers.SetupTemplates(webFS)
// 准备应用型插件
pluginDir := filepath.Join(dataDir, "plugins", "demo")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(pluginDir, "plugin.json"), `{
"kind": "app", "name": "demo", "slug": "demo", "version": "1.0.0",
"runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 800 },
"permissions": ["db", "site.read"]
}`)
writeFile(t, filepath.Join(pluginDir, "main.js"), `
function setup() {
clv.table("hit", { path: "string", created_at: "time" });
clv.route("GET", "/open", "openPage", { auth: "none" });
clv.route("GET", "/json", "jsonPage", { auth: "none", json: true });
clv.route("GET", "/me", "mePage", { auth: "user" });
clv.adminMenu({ label: "演示管理", path: "/", perm: "plugins" });
clv.adminPage("/", "adminHome", { perm: "plugins" });
clv.slot("footer_html", "foot");
clv.middleware("http.before", "guard");
}
function openPage(ctx) { return { body: "
plugin-open
" }; }
function jsonPage(ctx) { return { json: { ok: true, dialect: clv.db.dialect() } }; }
function mePage(ctx) { return { json: { uid: ctx.userId } }; }
function adminHome(ctx) { return { body: "plugin-admin
" }; }
function foot(data) { return "plugin-footer"; }
function guard(req) {
if (req.path === "/guard-me") return { abort: true, status: 403, body: "blocked" };
return null;
}
`)
plugin.SetEnabled("demo", true)
if err := plugin.LoadApp("demo"); err != nil {
t.Fatalf("加载应用型插件失败: %v", err)
}
t.Cleanup(func() {
plugin.UnloadApp("demo")
plugin.SetEnabled("demo", false)
})
srv := middleware.Use(buildMux())
do := func(method, path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
srv.ServeHTTP(w, httptest.NewRequest(method, path, nil))
return w
}
// 1) 插件公开路由(HTML)
if w := do(http.MethodGet, "/x/demo/open"); w.Code != 200 || !strings.Contains(w.Body.String(), "plugin-open") {
t.Fatalf("公开路由异常: %d %q", w.Code, w.Body.String())
}
// 2) 插件 JSON 路由
w := do(http.MethodGet, "/x/demo/json")
if w.Code != 200 {
t.Fatalf("JSON 路由异常: %d", w.Code)
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("JSON 解析失败: %v (%q)", err, w.Body.String())
}
if payload["dialect"] != "sqlite" {
t.Fatalf("dialect 异常: %v", payload["dialect"])
}
// 3) 需要登录的插件路由:未登录跳转
if w := do(http.MethodGet, "/x/demo/me"); w.Code != http.StatusFound {
t.Fatalf("未登录应跳转,实际 %d", w.Code)
}
// 4) 插件中间件短路
if w := do(http.MethodGet, "/guard-me"); w.Code != http.StatusForbidden {
t.Fatalf("中间件未短路: %d", w.Code)
}
// 5) 站点首页正常渲染,并包含插件 footer slot 输出
w = do(http.MethodGet, "/")
if w.Code != 200 {
t.Fatalf("首页异常: %d", w.Code)
}
if !strings.Contains(w.Body.String(), "plugin-footer") {
t.Fatalf("footer slot 未注入首页")
}
// 6) 后台页面未登录跳转登录页
if w := do(http.MethodGet, "/admin/plugins/demo/"); w.Code != http.StatusFound {
t.Fatalf("后台页面未拦截: %d", w.Code)
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// copyDir 递归复制目录(测试里把仓库示例插件装进临时站点)
func copyDir(t *testing.T, src, dst string) {
t.Helper()
entries, err := os.ReadDir(src)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
for _, e := range entries {
s := filepath.Join(src, e.Name())
d := filepath.Join(dst, e.Name())
if e.IsDir() {
copyDir(t, s, d)
continue
}
b, err := os.ReadFile(s)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(d, b, 0o644); err != nil {
t.Fatal(err)
}
}
}
// TestAIPolishPlugin 端到端验证「AI 语句美化」应用型插件:
// 静态 hook 注入、可用性探测、调用本机 AI 服务、长度限制。
func TestAIPolishPlugin(t *testing.T) {
dataDir := setupSite(t)
copyDir(t, filepath.Join("plugins", "ai-polish"), filepath.Join(dataDir, "plugins", "ai-polish"))
plugin.SetEnabled("AI 语句美化", true)
if err := plugin.LoadApp("ai-polish"); err != nil {
t.Fatalf("加载 AI 美化插件失败: %v", err)
}
defer func() {
plugin.UnloadApp("ai-polish")
plugin.SetEnabled("AI 语句美化", false)
}()
// 1) 声明式 hook 静态注入按钮脚本(不占用运行时)
if got := plugin.CallHTML("footer_html"); !strings.Contains(got, "/x/ai-polish") {
t.Fatalf("未注入美化脚本: %q", got)
}
srv := middleware.Use(buildMux())
do := func(method, path, body, csrf string) *httptest.ResponseRecorder {
var rd io.Reader
if body != "" {
rd = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, rd)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if csrf != "" {
req.Header.Set("X-CSRF-Token", csrf)
req.AddCookie(&http.Cookie{Name: "clv_csrf", Value: csrf})
}
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
return w
}
w0 := do(http.MethodGet, "/", "", "")
csrf := ""
for _, c := range w0.Result().Cookies() {
if c.Name == "clv_csrf" {
csrf = c.Value
}
}
if csrf == "" {
t.Fatal("未取到 CSRF 令牌")
}
// 2) 未接入 AI:config 标记不可用,polish 明确拒绝
if w := do(http.MethodGet, "/x/ai-polish/config", "", ""); !strings.Contains(w.Body.String(), `"available":false`) {
t.Fatalf("未接入 AI 时可用性判断异常: %s", w.Body.String())
}
if w := do(http.MethodPost, "/x/ai-polish/polish", `{"text":"你好呀"}`, csrf); w.Code != 400 {
t.Fatalf("未接入 AI 应返回 400,实际 %d: %s", w.Code, w.Body.String())
}
// 3) 配置本机 AI 服务(模拟 OpenAI 兼容接口,验证 net.local 权限放行内网)
ai := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
http.NotFound(rw, r)
return
}
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
rw.WriteHeader(http.StatusUnauthorized)
return
}
rw.Header().Set("Content-Type", "application/json")
_, _ = rw.Write([]byte(`{"choices":[{"message":{"content":"\"今天天气很好,适合出门走走。\""}}]}`))
}))
defer ai.Close()
_ = models.SetSetting("ai_enabled", "1")
_ = models.SetSetting("ai_base", ai.URL)
_ = models.SetSetting("ai_key", "test-key")
_ = models.SetSetting("ai_model", "test-model")
if w := do(http.MethodGet, "/x/ai-polish/config", "", ""); !strings.Contains(w.Body.String(), `"available":true`) {
t.Fatalf("接入 AI 后可用性判断异常: %s", w.Body.String())
}
// 4) 美化成功,并清理模型输出的引号包裹
w := do(http.MethodPost, "/x/ai-polish/polish", `{"text":"今天天气不错"}`, csrf)
if w.Code != 200 {
t.Fatalf("美化失败: %d %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "今天天气很好,适合出门走走。") {
t.Fatalf("返回内容异常: %s", body)
}
if strings.Contains(body, `\"今天天气很好`) {
t.Fatalf("未清理引号包裹: %s", body)
}
// 5) 超出单次处理上限被拒绝
long := strings.Repeat("啊", 700)
if w := do(http.MethodPost, "/x/ai-polish/polish", `{"text":"`+long+`"}`, csrf); w.Code != 400 {
t.Fatalf("超长内容应被拒绝,实际 %d", w.Code)
}
}
// setupSite 初始化一次完整的站点环境(数据库 + 模板)
func setupSite(t *testing.T) (dataDir string) {
t.Helper()
root := t.TempDir()
dataDir = filepath.Join(root, "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatal(err)
}
config.Cfg = config.Config{
DataDir: dataDir,
UploadDir: filepath.Join(root, "uploads"),
DBType: "sqlite",
SQLitePath: filepath.Join(dataDir, "clearlove.db"),
Installed: true,
Secret: "integration-secret",
Version: config.Version,
}
if err := database.Connect(); err != nil {
t.Fatalf("数据库连接失败: %v", err)
}
t.Cleanup(func() {
if database.DB != nil {
_ = database.DB.Close()
database.DB = nil
}
})
if err := database.Migrate(); err != nil {
t.Fatalf("建表失败: %v", err)
}
_ = models.SetSetting("site_name", "集成测试站")
handlers.SetupTemplates(webFS)
return dataDir
}
// TestPluginFiltersTemplateAndLogs 覆盖新增能力:
// 过滤器(card / api.response / post.visible)、http.after、模板覆盖与运行日志。
func TestPluginFiltersTemplateAndLogs(t *testing.T) {
dataDir := setupSite(t)
pluginDir := filepath.Join(dataDir, "plugins", "fx")
if err := os.MkdirAll(filepath.Join(pluginDir, "templates"), 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(pluginDir, "plugin.json"), `{
"kind": "app", "name": "fx", "slug": "fx", "version": "1.0.0",
"runtime": { "engine": "js", "entry": "main.js", "timeout_ms": 800 },
"permissions": ["db", "cache"]
}`)
writeFile(t, filepath.Join(pluginDir, "main.js"), `
function setup() {
clv.filter("filter.card", 10, "cardFilter");
clv.filter("filter.api.response", 10, "respFilter");
clv.filter("filter.post.visible", 10, "visibleFilter");
clv.middleware("http.after", "afterLog");
}
function cardFilter(card) { card.nickname = "改写:" + card.nickname; return card; }
function respFilter(resp) { resp.plugin_tag = "fx"; return resp; }
function visibleFilter(ok, ctx) { return (ctx && ctx.post_id === 999) ? false : ok; }
function afterLog(info) { clv.cache.set("last_status", info.status, 60); }
function __status() { return clv.cache.get("last_status") || 0; }
`)
// 模板覆盖:插件目录下的同名模板会覆盖内核片段
writeFile(t, filepath.Join(pluginDir, "templates", "pg_index.html"),
`{{define "pg_index"}}OVERRIDE
{{end}}`)
plugin.SetEnabled("fx", true)
if err := plugin.LoadApp("fx"); err != nil {
t.Fatalf("加载失败: %v", err)
}
defer func() {
plugin.UnloadApp("fx")
plugin.SetEnabled("fx", false)
handlers.ReloadTemplates()
}()
handlers.ReloadTemplates() // 应用插件模板覆盖
// 准备一条帖子(含一条 id=999 用于可见性过滤)
if _, err := database.DB.Exec(
`INSERT INTO posts(id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at)
VALUES(1,'原昵称','正文',0,'','',1,0,'',?)`, models.Now()); err != nil {
t.Fatal(err)
}
srv := middleware.Use(buildMux())
do := func(method, path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
srv.ServeHTTP(w, httptest.NewRequest(method, path, nil))
return w
}
// 1) 模板覆盖生效
if w := do(http.MethodGet, "/"); !strings.Contains(w.Body.String(), "plugin-override") {
t.Fatalf("模板覆盖未生效: %q", w.Body.String())
}
// 2) filter.api.response 改写响应体
if w := do(http.MethodGet, "/api/v1/topics"); !strings.Contains(w.Body.String(), "plugin_tag") {
t.Fatalf("filter.api.response 未生效: %q", w.Body.String())
}
// 3) filter.card 改写卡片数据
w := do(http.MethodGet, "/api/v1/posts?limit=5")
if !strings.Contains(w.Body.String(), "改写:原昵称") {
t.Fatalf("filter.card 未生效: %q", w.Body.String())
}
// 4) filter.post.visible 否决可见性
if _, err := database.DB.Exec(
`INSERT INTO posts(id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at)
VALUES(999,'x','y',0,'','',1,0,'',?)`, models.Now()); err != nil {
t.Fatal(err)
}
if w := do(http.MethodGet, "/post/999"); w.Code != http.StatusNotFound {
t.Fatalf("filter.post.visible 未生效: %d", w.Code)
}
// 5) http.after 被调用(状态码写入插件缓存)
out, err := plugin.CallFn("fx", "__status")
if err != nil {
t.Fatalf("脚本调用失败: %v", err)
}
if n, ok := out.(int64); !ok || n == 0 {
t.Fatalf("http.after 未执行: %v", out)
}
// 6) 运行日志可读
logs := plugin.PluginLogs("fx", 50)
if len(logs) == 0 {
t.Fatal("插件运行日志为空")
}
}
// TestAppPluginHTTPIntegration 端到端验证应用型插件:
// 完整路由链(中间件 + 插件分发 + 鉴权)与站点模板渲染。