Files
pure-note/internal/httpapi/testenv_test.go
T
wangairnan 6e83426ca9 HTTP 层与单二进制入口:路由 handler、会话/CSRF/限流防线、SPA 嵌入与 meta 注入
- internal/httpapi:§7.1 全部路由(公开浏览 / 管理端 / 认证 / feed),
  服务端统一可见性过滤(含回收站仅 admin 出口)、图片魔数校验与
  immutable/no-store 缓存头分流、统一 404 防枚举、slug 自解冲突与
  409 字段级错误、fail-only 登录限流(429 + Retry-After)、
  设置白名单(永不序列化口令哈希)
- internal/webui:go:embed dist + SPA fallback(资产指纹长缓存、
  深链回退 index.html)+ html/template 元信息注入(仅可见笔记)
- cmd/pure-note:serve/init/backup/gc/version 子命令,优雅停机与
  每小时会话清理
- 含全部 §13 测试组:表驱动可见性矩阵、迁移守卫、认证会话、CSRF、
  上传、回收站/gc、slug 策略、设置白名单、webui MapFS 单测
2026-09-08 08:14:23 +08:00

272 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// httpapi 集成测试环境:httptest + 临时目录真实 SQLite(§13)。
package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"testing"
"text/template"
"time"
"pure-note/internal/auth"
"pure-note/internal/config"
"pure-note/internal/middleware"
"pure-note/internal/store"
)
const testPassword = "admin-password-123"
// testEnv 测试环境。
type testEnv struct {
t *testing.T
st *store.Store
srv *Server
ts *httptest.Server
csrf string // 当前管理员 CSRF token
}
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(newDiscard(), nil))
}
func newEnv(t *testing.T) *testEnv {
t.Helper()
e := newEnvCustom(t, nil, nil, nil)
return e
}
// newEnvCustom 可注入限流器(nil = 高容量测试桶,避免全局限流干扰)。
func newEnvCustom(t *testing.T, global, loginIP, loginAcct *middleware.Limiter) *testEnv {
t.Helper()
if global == nil {
global = middleware.NewLimiter(1e9, 1<<20, 1<<20, time.Minute)
}
if loginIP == nil {
loginIP = middleware.NewLimiter(1e9, 1<<20, 1<<20, time.Minute)
}
if loginAcct == nil {
loginAcct = middleware.NewLimiter(1e9, 1<<20, 1<<20, time.Minute)
}
dir := t.TempDir()
st, err := store.Open(dir+"/pure-note.db", false)
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
t.Cleanup(func() { st.Close() })
hash, err := auth.HashPassword(testPassword)
if err != nil {
t.Fatal(err)
}
if err := st.SetSetting(store.KeyAdminPasswordHash, hash); err != nil {
t.Fatal(err)
}
if err := st.SetSetting(store.KeySiteTitle, "测试站"); err != nil {
t.Fatal(err)
}
cfg := &config.Config{Dev: true, DataDir: dir}
srv := NewWithLimiters(st, cfg, quietLogger(), global, loginIP, loginAcct)
// index 模板桩:渲染 ResolveMeta 结果,用于 meta 注入断言
tmpl := template.Must(template.New("i").Parse(
`<html><head><title>{{.Title}}</title><meta name="description" content="{{.Description}}"><meta property="og:title" content="{{.OGTitle}}"></head><body>SPA</body></html>`))
ui := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m := srv.ResolveMeta(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = tmpl.Execute(w, m)
})
ts := httptest.NewServer(srv.Handler(ui))
t.Cleanup(ts.Close)
return &testEnv{t: t, st: st, srv: srv, ts: ts}
}
func newDiscard() *bytes.Buffer { return &bytes.Buffer{} }
// client 新建带 Cookie Jar 的客户端。
func (e *testEnv) client() *http.Client {
jar, _ := cookiejar.New(nil)
return &http.Client{Jar: jar}
}
// do 发请求。默认带 Origin(与服务器同源)。
func (e *testEnv) do(c *http.Client, method, path string, body []byte, hdr map[string]string) (*http.Response, []byte) {
e.t.Helper()
var rd *bytes.Reader
if body == nil {
rd = bytes.NewReader(nil)
} else {
rd = bytes.NewReader(body)
}
req, err := http.NewRequest(method, e.ts.URL+path, rd)
if err != nil {
e.t.Fatal(err)
}
if method != http.MethodGet && method != http.MethodHead {
if _, ok := hdr["Origin"]; !ok {
req.Header.Set("Origin", e.ts.URL)
}
}
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := c.Do(req)
if err != nil {
e.t.Fatal(err)
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
return resp, buf.Bytes()
}
func (e *testEnv) get(c *http.Client, path string) (*http.Response, []byte) {
return e.do(c, http.MethodGet, path, nil, nil)
}
// loginAdmin 登录并保留会话与 CSRF。
func (e *testEnv) loginAdmin() *http.Client {
e.t.Helper()
c := e.client()
resp, body := e.do(c, http.MethodPost, "/api/auth/login",
[]byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
if resp.StatusCode != http.StatusOK {
e.t.Fatalf("管理员登录失败: %d %s", resp.StatusCode, body)
}
var out struct {
Data struct {
CSRFToken string `json:"csrf_token"`
} `json:"data"`
}
if err := json.Unmarshal(body, &out); err != nil {
e.t.Fatalf("解析登录响应失败: %v", err)
}
e.csrf = out.Data.CSRFToken
return c
}
func (e *testEnv) adminHeaders() map[string]string {
e.t.Helper()
if e.csrf == "" {
e.t.Fatal("尚未登录管理员")
}
return map[string]string{"X-CSRF-Token": e.csrf, "Content-Type": "application/json"}
}
// createNote 管理员建笔记,返回 Note JSON。
func (e *testEnv) createNote(c *http.Client, slug, title, content, status string, tags []string) map[string]any {
e.t.Helper()
payload := map[string]any{
"title": title, "slug": slug, "content": content, "status": status, "tags": tags,
}
b, _ := json.Marshal(payload)
resp, body := e.do(c, http.MethodPost, "/api/admin/notes", b, e.adminHeaders())
if resp.StatusCode != http.StatusCreated {
e.t.Fatalf("创建笔记失败(%s): %d %s", title, resp.StatusCode, body)
}
var out struct {
Data map[string]any `json:"data"`
}
_ = json.Unmarshal(body, &out)
return out.Data
}
// uploadPNG 管理员上传一张最小 PNG,返回图片 id。
func (e *testEnv) uploadPNG(c *http.Client, png []byte) int64 {
e.t.Helper()
var body bytes.Buffer
boundary := "testboundary123"
body.WriteString("--" + boundary + "\r\n")
body.WriteString(`Content-Disposition: form-data; name="file"; filename="t.png"` + "\r\n")
body.WriteString("Content-Type: image/png\r\n\r\n")
body.Write(png)
body.WriteString("\r\n--" + boundary + "--\r\n")
req, err := http.NewRequest(http.MethodPost, e.ts.URL+"/api/admin/images", &body)
if err != nil {
e.t.Fatal(err)
}
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
req.Header.Set("Origin", e.ts.URL)
req.Header.Set("X-CSRF-Token", e.csrf)
resp, err := c.Do(req)
if err != nil {
e.t.Fatal(err)
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
if resp.StatusCode != http.StatusCreated {
e.t.Fatalf("上传图片失败: %d %s", resp.StatusCode, buf.String())
}
var out struct {
Data struct {
ID int64 `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(buf.Bytes(), &out)
return out.Data.ID
}
// fixtures 构造可见性矩阵的标准夹具(§13)。
type fixtures struct {
admin *http.Client
anon *http.Client
pubID int64 // 公开笔记(引用 imgPublic)
privID int64 // 私有笔记(引用 imgPrivate)
trashID int64 // 回收站笔记(原公开,引用 imgTrash)
pubSlug string
privSlug string
trashSlug string
imgPub int64
imgPriv int64
imgTrash int64
imgOrphan int64
}
var png1x1 = []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, 0xB0, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
}
// uniquePNG 追加 IEND 之后的差异化尾部,绕开 sha256 去重(设计 §14:
// 去重会合并同字节图片;夹具需要四张不同图)。
func uniquePNG(tag byte) []byte {
b := make([]byte, len(png1x1), len(png1x1)+8)
copy(b, png1x1)
return append(b, 0, 0, 0, 0, 't', 'a', 'g', tag)
}
func (e *testEnv) fixtures() *fixtures {
e.t.Helper()
f := &fixtures{admin: e.loginAdmin(), anon: e.client()}
f.imgPub = e.uploadPNG(f.admin, uniquePNG('a'))
f.imgPriv = e.uploadPNG(f.admin, uniquePNG('b'))
f.imgTrash = e.uploadPNG(f.admin, uniquePNG('c'))
f.imgOrphan = e.uploadPNG(f.admin, uniquePNG('d'))
pub := e.createNote(f.admin, "pub-note", "公开笔记Alpha", "公开内容 ![x](/api/images/"+fmt.Sprint(f.imgPub)+")", "public", []string{"公开"})
priv := e.createNote(f.admin, "priv-note", "私有笔记Beta", "私有内容 ![y](/api/images/"+fmt.Sprint(f.imgPriv)+")", "private", []string{"秘密"})
trash := e.createNote(f.admin, "trash-note", "回收站笔记Gamma", "回收站内容 ![z](/api/images/"+fmt.Sprint(f.imgTrash)+")", "public", []string{"公开"})
f.pubID = int64(pub["id"].(float64))
f.privID = int64(priv["id"].(float64))
f.trashID = int64(trash["id"].(float64))
f.pubSlug = pub["slug"].(string)
f.privSlug = priv["slug"].(string)
f.trashSlug = trash["slug"].(string)
// 软删除 trash-note
resp, body := e.do(f.admin, http.MethodDelete, fmt.Sprintf("/api/admin/notes/%d", f.trashID), nil, e.adminHeaders())
if resp.StatusCode != http.StatusOK {
e.t.Fatalf("软删除失败: %d %s", resp.StatusCode, body)
}
return f
}