272 lines
8.3 KiB
Go
272 lines
8.3 KiB
Go
// 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+"/pn.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
|
||
}
|