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 单测
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
// 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", "公开内容 +")", "public", []string{"公开"})
|
||||
priv := e.createNote(f.admin, "priv-note", "私有笔记Beta", "私有内容 +")", "private", []string{"秘密"})
|
||||
trash := e.createNote(f.admin, "trash-note", "回收站笔记Gamma", "回收站内容 +")", "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
|
||||
}
|
||||
Reference in New Issue
Block a user