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,562 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pure-note/internal/middleware"
|
||||
)
|
||||
|
||||
// ---- 认证会话组(§13)----
|
||||
|
||||
func TestLoginFlow(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
|
||||
// 匿名 /api/me
|
||||
c := e.client()
|
||||
resp, body := e.get(c, "/api/me")
|
||||
if resp.StatusCode != 200 || strings.Contains(string(body), `"authenticated":true`) {
|
||||
t.Errorf("匿名 /api/me 应为 authenticated:false: %s", body)
|
||||
}
|
||||
// 错误口令:统一 401 文案
|
||||
resp, body = e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("错误口令应 401,实际 %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(body), "用户名或密码错误") {
|
||||
t.Errorf("统一文案缺失: %s", body)
|
||||
}
|
||||
// 空口令同样 401
|
||||
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":""}`), nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("空口令应 401,实际 %d", resp.StatusCode)
|
||||
}
|
||||
// 正确口令
|
||||
resp, body = e.do(c, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("登录失败: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
var out struct {
|
||||
Data struct {
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &out)
|
||||
if out.Data.CSRFToken == "" {
|
||||
t.Fatal("登录响应应含 csrf_token")
|
||||
}
|
||||
// 认证态 /api/me
|
||||
resp, body = e.get(c, "/api/me")
|
||||
if !strings.Contains(string(body), `"authenticated":true`) || !strings.Contains(string(body), out.Data.CSRFToken) {
|
||||
t.Errorf("认证态 /api/me 应含同一 csrf_token: %s", body)
|
||||
}
|
||||
// 登出
|
||||
resp, _ = e.do(c, http.MethodPost, "/api/auth/logout", nil, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("登出应 200: %d", resp.StatusCode)
|
||||
}
|
||||
// 登出后会话失效
|
||||
resp, body = e.get(c, "/api/me")
|
||||
if strings.Contains(string(body), `"authenticated":true`) {
|
||||
t.Errorf("登出后 /api/me 应为匿名: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimit(t *testing.T) {
|
||||
// 真实限流参数:per-IP 10/5min,per-账号 5/10min
|
||||
e := newEnvRealLimits(t)
|
||||
c := e.client()
|
||||
// 前 5 次失败:401
|
||||
for i := 0; i < 5; i++ {
|
||||
resp, _ := e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("第 %d 次失败应 401,实际 %d", i+1, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
// 第 6 次:账号桶耗尽 → 429 + Retry-After
|
||||
resp, body := e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("第 6 次应 429,实际 %d %s", resp.StatusCode, body)
|
||||
}
|
||||
if resp.Header.Get("Retry-After") == "" {
|
||||
t.Error("429 应带 Retry-After")
|
||||
}
|
||||
// 正确口令也被 429(桶已空)
|
||||
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Errorf("桶空时正确口令也应 429,实际 %d", resp.StatusCode)
|
||||
}
|
||||
// 成功不计费:1 次失败后成功仍可
|
||||
e2 := newEnvRealLimits(t)
|
||||
c2 := e2.client()
|
||||
_, _ = e2.do(c2, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
|
||||
resp, _ = e2.do(c2, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("失败 1 次后正确口令应可登录: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func newEnvRealLimits(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
return newEnvCustom(t,
|
||||
nil, // 全局用高容量桶(隔离测试)
|
||||
middleware.NewLimiter(10.0/(5*60), 10, 4096, 10*time.Minute),
|
||||
middleware.NewLimiter(5.0/(10*60), 5, 16, 10*time.Minute),
|
||||
)
|
||||
}
|
||||
|
||||
func TestPasswordChange(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
admin := e.loginAdmin()
|
||||
|
||||
// 旧密码错误
|
||||
resp, body := e.do(admin, http.MethodPost, "/api/admin/password",
|
||||
[]byte(`{"old_password":"wrong","new_password":"new-password-456"}`), e.adminHeaders())
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("旧密码错误应 401: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
// 新密码太弱
|
||||
resp, _ = e.do(admin, http.MethodPost, "/api/admin/password",
|
||||
[]byte(fmt.Sprintf(`{"old_password":%q,"new_password":"short"}`, testPassword)), e.adminHeaders())
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("弱口令应 400: %d", resp.StatusCode)
|
||||
}
|
||||
// 成功
|
||||
resp, _ = e.do(admin, http.MethodPost, "/api/admin/password",
|
||||
[]byte(fmt.Sprintf(`{"old_password":%q,"new_password":"new-password-456"}`, testPassword)), e.adminHeaders())
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("改密应成功: %d", resp.StatusCode)
|
||||
}
|
||||
// 旧口令不能再登录
|
||||
c := e.client()
|
||||
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("旧口令应失效: %d", resp.StatusCode)
|
||||
}
|
||||
// 新口令可登录;当前会话不失效(§7.3-7)
|
||||
resp, body = e.get(admin, "/api/me")
|
||||
if !strings.Contains(string(body), `"authenticated":true`) {
|
||||
t.Errorf("改密不应失效当前会话: %s", body)
|
||||
}
|
||||
c2 := e.client()
|
||||
resp, _ = e.do(c2, http.MethodPost, "/api/auth/login", []byte(`{"password":"new-password-456"}`), nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("新口令应可登录: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRotation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
c := e.client()
|
||||
// 直接构造一个 2 天后过期的会话(< 3 天阈值)
|
||||
token := "tok-" + fmt.Sprint(time.Now().UnixNano())
|
||||
csrf := "csrf-" + fmt.Sprint(time.Now().UnixNano())
|
||||
now := time.Now().Unix()
|
||||
if err := e.st.CreateSession(hashToken(token), csrf, now, now+2*86400); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, e.ts.URL+"/api/me", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "pn_session", Value: token})
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// 响应应 Set-Cookie 轮换新 token
|
||||
var newCookie *http.Cookie
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == "pn_session" && ck.Value != "" {
|
||||
newCookie = ck
|
||||
}
|
||||
}
|
||||
if newCookie == nil || newCookie.Value == token {
|
||||
t.Fatal("距过期 <3 天应轮换 Cookie token")
|
||||
}
|
||||
// csrf 保持不变(前端无感,§7.3-5)
|
||||
b := new(bytes.Buffer)
|
||||
_, _ = b.ReadFrom(resp.Body)
|
||||
if !strings.Contains(b.String(), csrf) {
|
||||
t.Errorf("轮换后 csrf_token 应不变: %s", b.String())
|
||||
}
|
||||
// 旧 token 行删除、新 token 行存在
|
||||
if _, err := e.st.GetSession(hashToken(token)); err == nil {
|
||||
t.Error("旧 token 应已删除")
|
||||
}
|
||||
sess, err := e.st.GetSession(hashToken(newCookie.Value))
|
||||
if err != nil {
|
||||
t.Fatalf("新 token 应存在: %v", err)
|
||||
}
|
||||
if sess.CSRFToken != csrf {
|
||||
t.Error("新行 csrf 应保持不变")
|
||||
}
|
||||
// 长期有效会话不轮换
|
||||
e2 := newEnv(t)
|
||||
a := e2.loginAdmin()
|
||||
resp, _ = e2.get(a, "/api/admin/notes")
|
||||
if len(resp.Cookies()) > 0 {
|
||||
t.Errorf("距过期 >3 天不应轮换")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CSRF 组(§13)----
|
||||
|
||||
func TestCSRF(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
admin := e.loginAdmin()
|
||||
|
||||
// 无 token 403
|
||||
resp, _ := e.do(admin, http.MethodPost, "/api/admin/notes",
|
||||
[]byte(`{"title":"x","status":"private"}`), map[string]string{"Content-Type": "application/json"})
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("无 CSRF token 应 403: %d", resp.StatusCode)
|
||||
}
|
||||
// 错 token 403
|
||||
bad := map[string]string{"X-CSRF-Token": "bad", "Content-Type": "application/json"}
|
||||
resp, _ = e.do(admin, http.MethodPost, "/api/admin/notes", []byte(`{"title":"x","status":"private"}`), bad)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("错误 CSRF token 应 403: %d", resp.StatusCode)
|
||||
}
|
||||
// 跨源 Origin 拒绝(admin POST)
|
||||
evil := map[string]string{
|
||||
"Origin": "http://evil.example", "X-CSRF-Token": e.csrf, "Content-Type": "application/json",
|
||||
}
|
||||
resp, _ = e.do(admin, http.MethodPost, "/api/admin/notes", []byte(`{"title":"x","status":"private"}`), evil)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("跨源 Origin 应 403: %d", resp.StatusCode)
|
||||
}
|
||||
// 跨源 login 拒绝
|
||||
c := e.client()
|
||||
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"x"}`),
|
||||
map[string]string{"Origin": "http://evil.example"})
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("跨源 login 应 403: %d", resp.StatusCode)
|
||||
}
|
||||
// 跨源 logout 拒绝
|
||||
resp, _ = e.do(admin, http.MethodPost, "/api/auth/logout", nil,
|
||||
map[string]string{"Origin": "http://evil.example"})
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("跨源 logout 应 403: %d", resp.StatusCode)
|
||||
}
|
||||
// 缺 Origin 且缺 Referer 的变更请求拒绝
|
||||
c2 := e.client()
|
||||
req, _ := http.NewRequest(http.MethodPost, e.ts.URL+"/api/auth/login",
|
||||
strings.NewReader(`{"password":"x"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp2, err := c2.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("无 Origin/Referer 应 403: %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 设置白名单组(§9.3)----
|
||||
|
||||
func TestSettingsWhitelist(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
admin := e.loginAdmin()
|
||||
|
||||
// GET 不含 admin_password_hash
|
||||
resp, body := e.get(admin, "/api/admin/settings")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("settings GET: %d", resp.StatusCode)
|
||||
}
|
||||
if strings.Contains(string(body), "password") || strings.Contains(string(body), "argon2") {
|
||||
t.Errorf("settings 响应不得含口令哈希: %s", body)
|
||||
}
|
||||
// 匿名 /api/site 同样不含
|
||||
_, body = e.get(e.client(), "/api/site")
|
||||
if strings.Contains(string(body), "password") || strings.Contains(string(body), "argon2") {
|
||||
t.Errorf("/api/site 不得含口令哈希: %s", body)
|
||||
}
|
||||
// PUT 未知键拒绝
|
||||
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||
[]byte(`{"admin_password_hash":"evil"}`), e.adminHeaders())
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("未知键应 400: %d", resp.StatusCode)
|
||||
}
|
||||
// PUT 合法键
|
||||
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||
[]byte(`{"site_title":"新标题","site_desc":"描述","page_size":5}`), e.adminHeaders())
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("合法 PUT 应 200: %d", resp.StatusCode)
|
||||
}
|
||||
// page_size 生效为公开列表默认
|
||||
_, body = e.get(e.client(), "/api/notes")
|
||||
if !strings.Contains(string(body), `"page_size":5`) {
|
||||
t.Errorf("page_size 设置应生效: %s", body)
|
||||
}
|
||||
// page_size 越界
|
||||
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||
[]byte(`{"page_size":0}`), e.adminHeaders())
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("page_size=0 应 400: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- slug 策略组(§13)----
|
||||
|
||||
func TestSlugPolicy(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
admin := e.loginAdmin()
|
||||
|
||||
// 纯中文标题 → post-YYYYMMDD
|
||||
cnDate := time.Now().Format("20060102")
|
||||
n1 := e.createNote(admin, "", "中文标题一", "内容", "public", nil)
|
||||
if n1["slug"] != "post-"+cnDate {
|
||||
t.Errorf("中文标题应生成 post-%s,实际 %v", cnDate, n1["slug"])
|
||||
}
|
||||
// 同日冲突 → 自动后缀 -2
|
||||
n2 := e.createNote(admin, "", "另一篇中文", "内容", "public", nil)
|
||||
if n2["slug"] != "post-"+cnDate+"-2" {
|
||||
t.Errorf("同日冲突应后缀 -2,实际 %v", n2["slug"])
|
||||
}
|
||||
// ASCII 标题 → 小写连字符
|
||||
n3 := e.createNote(admin, "", "Hello World 42", "内容", "public", nil)
|
||||
if n3["slug"] != "hello-world-42" {
|
||||
t.Errorf("ASCII 标题应 slugify,实际 %v", n3["slug"])
|
||||
}
|
||||
// 与回收站冲突:软删除后同标题仍被占用
|
||||
id := int64(n3["id"].(float64))
|
||||
if err := e.st.SoftDeleteNote(id, time.Now().Unix()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n4 := e.createNote(admin, "", "Hello World 42?", "内容", "public", nil)
|
||||
if n4["slug"] != "hello-world-42-2" {
|
||||
t.Errorf("回收站占用应自解冲突,实际 %v", n4["slug"])
|
||||
}
|
||||
// 手改冲突 → 409 + 字段级错误
|
||||
resp, body := e.do(admin, http.MethodPut, fmt.Sprintf("/api/admin/notes/%d", int64(n4["id"].(float64))),
|
||||
[]byte(fmt.Sprintf(`{"title":"Hello World 42?","slug":%q,"status":"public"}`, "post-"+cnDate)),
|
||||
e.adminHeaders())
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("手改冲突应 409: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
var errOut struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Field string `json:"field"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &errOut)
|
||||
if errOut.Error.Code != "slug_conflict" || errOut.Error.Field != "slug" {
|
||||
t.Errorf("应返回 slug 字段级错误: %s", body)
|
||||
}
|
||||
// 非法 slug 格式
|
||||
resp, _ = e.do(admin, http.MethodPut, fmt.Sprintf("/api/admin/notes/%d", int64(n4["id"].(float64))),
|
||||
[]byte(`{"title":"x","slug":"Bad Slug!","status":"public"}`), e.adminHeaders())
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("非法 slug 应 400: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 回收站 + gc 组(§13)----
|
||||
|
||||
func TestTrashLifecycleAndGC(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
admin := e.loginAdmin()
|
||||
anon := e.client()
|
||||
|
||||
imgID := e.uploadPNG(admin, uniquePNG('g'))
|
||||
note := e.createNote(admin, "", "生命周期", "引用 +")", "public", nil)
|
||||
id := int64(note["id"].(float64))
|
||||
slug := note["slug"].(string)
|
||||
|
||||
// 删除 → 公开列表/详情不可见
|
||||
resp, _ := e.do(admin, http.MethodDelete, fmt.Sprintf("/api/admin/notes/%d", id), nil, e.adminHeaders())
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("软删除失败: %d", resp.StatusCode)
|
||||
}
|
||||
_, body := e.get(anon, "/api/notes")
|
||||
if strings.Contains(string(body), slug) {
|
||||
t.Error("回收站笔记不应出现在公开列表")
|
||||
}
|
||||
resp, _ = e.get(anon, "/api/notes/"+slug)
|
||||
if resp.StatusCode != 404 {
|
||||
t.Errorf("回收站详情应 404: %d", resp.StatusCode)
|
||||
}
|
||||
// 图片同样退出公开(并集语义失效)
|
||||
resp, _ = e.get(anon, fmt.Sprintf("/api/images/%d", imgID))
|
||||
if resp.StatusCode != 404 {
|
||||
t.Errorf("回收站引用图片应 404: %d", resp.StatusCode)
|
||||
}
|
||||
// 恢复 → 可见,图片引用天然保全
|
||||
resp, _ = e.do(admin, http.MethodPost, fmt.Sprintf("/api/admin/trash/%d/restore", id), nil, e.adminHeaders())
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("恢复失败: %d", resp.StatusCode)
|
||||
}
|
||||
resp, _ = e.get(anon, "/api/notes/"+slug)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("恢复后应可见: %d", resp.StatusCode)
|
||||
}
|
||||
resp, _ = e.get(anon, fmt.Sprintf("/api/images/%d", imgID))
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("恢复后图片应匿名可见: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// gc:30 天过期物理删除 + 7 天宽限孤儿图片 + 过期会话
|
||||
// 7 天内的孤儿图片不被删:先造一张新孤儿图(趁会话有效)
|
||||
freshImg := e.uploadPNG(admin, uniquePNG('h'))
|
||||
if _, err := e.st.DB().Exec(`DELETE FROM image_refs WHERE image_id=?`, freshImg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := time.Now().Add(-31 * 24 * time.Hour).Unix()
|
||||
if _, err := e.st.DB().Exec(`UPDATE notes SET deleted_at=? WHERE id=?`, old, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.DB().Exec(`UPDATE images SET created_at=? WHERE id=?`, old, imgID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 把管理员会话改为过期
|
||||
if _, err := e.st.DB().Exec(`UPDATE sessions SET expires_at=?`, time.Now().Add(-time.Hour).Unix()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// dry-run 不改数据
|
||||
rep, err := e.st.GC(time.Now(), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rep.ExpiredNotes) != 1 || rep.ExpiredSessons < 1 {
|
||||
t.Errorf("dry-run 应报告 1 篇过期笔记与过期会话: %+v", rep)
|
||||
}
|
||||
if _, err := e.st.GetNoteByID(id); err != nil {
|
||||
t.Error("dry-run 不得删除数据")
|
||||
}
|
||||
// commit 执行
|
||||
rep, err = e.st.GC(time.Now(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rep.Committed || len(rep.ExpiredNotes) != 1 {
|
||||
t.Errorf("commit 应物理删除: %+v", rep)
|
||||
}
|
||||
if _, err := e.st.GetNoteByID(id); err == nil {
|
||||
t.Error("过期笔记应被物理删除")
|
||||
}
|
||||
if _, err := e.st.GetImageMeta(imgID); err != nil {
|
||||
t.Error("超宽限期的孤儿图片应被删除")
|
||||
}
|
||||
if _, err := e.st.GetImageMeta(freshImg); err != nil {
|
||||
t.Error("7 天内的孤儿图片不应被删除")
|
||||
}
|
||||
sessLeft, err := e.st.DeleteExpiredSessions(time.Now().Unix())
|
||||
_ = sessLeft
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 过期会话已被 gc 清空:登录表里应无行
|
||||
var cnt int
|
||||
if err := e.st.DB().QueryRow(`SELECT COUNT(*) FROM sessions`).Scan(&cnt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cnt != 0 {
|
||||
t.Errorf("过期会话应被 gc 清除,剩余 %d", cnt)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 上传组(§13)----
|
||||
|
||||
func TestUploadValidation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
admin := e.loginAdmin()
|
||||
|
||||
upload := func(fileName, contentType string, data []byte) (int, string) {
|
||||
var body bytes.Buffer
|
||||
boundary := "bnd"
|
||||
body.WriteString("--" + boundary + "\r\n")
|
||||
body.WriteString(`Content-Disposition: form-data; name="file"; filename="` + fileName + `"` + "\r\n")
|
||||
body.WriteString("Content-Type: " + contentType + "\r\n\r\n")
|
||||
body.Write(data)
|
||||
body.WriteString("\r\n--" + boundary + "--\r\n")
|
||||
req, _ := http.NewRequest(http.MethodPost, e.ts.URL+"/api/admin/images", &body)
|
||||
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 := admin.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out bytes.Buffer
|
||||
_, _ = out.ReadFrom(resp.Body)
|
||||
return resp.StatusCode, out.String()
|
||||
}
|
||||
|
||||
// 魔数不符(文本伪装 png)
|
||||
code, body := upload("fake.png", "image/png", []byte("hello this is not an image"))
|
||||
if code != http.StatusUnsupportedMediaType {
|
||||
t.Errorf("魔数不符应 415: %d %s", code, body)
|
||||
}
|
||||
// SVG 一律拒绝
|
||||
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||||
code, _ = upload("x.svg", "image/svg+xml", svg)
|
||||
if code != http.StatusUnsupportedMediaType {
|
||||
t.Errorf("SVG 应拒绝: %d", code)
|
||||
}
|
||||
// 声明类型白名单外
|
||||
code, _ = upload("x.png", "application/octet-stream", png1x1)
|
||||
if code != http.StatusUnsupportedMediaType {
|
||||
t.Errorf("声明类型白名单外应拒绝: %d", code)
|
||||
}
|
||||
// 空文件
|
||||
code, _ = upload("x.png", "image/png", nil)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Errorf("空文件应 400: %d", code)
|
||||
}
|
||||
// 超 5MB
|
||||
big := make([]byte, 5<<20+1)
|
||||
copy(big, png1x1)
|
||||
code, _ = upload("big.png", "image/png", big)
|
||||
if code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("超限应 413: %d", code)
|
||||
}
|
||||
// 合法 PNG → 201
|
||||
code, body = upload("ok.png", "image/png", uniquePNG('v'))
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("合法 PNG 应 201: %d %s", code, body)
|
||||
}
|
||||
var out struct {
|
||||
Data struct {
|
||||
ID int64 `json:"id"`
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(body), &out)
|
||||
if out.Data.URL != fmt.Sprintf("/api/images/%d", out.Data.ID) {
|
||||
t.Errorf("响应应含 id 与 url: %s", body)
|
||||
}
|
||||
// 去重命中:同字节再传同 id
|
||||
code, body = upload("ok2.png", "image/png", uniquePNG('v'))
|
||||
_ = json.Unmarshal([]byte(body), &out)
|
||||
if code != http.StatusCreated || out.Data.ID == 0 {
|
||||
t.Fatalf("重复上传应 201: %d %s", code, body)
|
||||
}
|
||||
var first struct {
|
||||
Data struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(fmt.Sprintf(`{"data":{"id":%d}}`, mustImageID(t, e, uniquePNG('v')))), &first)
|
||||
// 直接查库确认只有一行
|
||||
var cnt int
|
||||
if err := e.st.DB().QueryRow(`SELECT COUNT(*) FROM images WHERE size=?`, len(uniquePNG('v'))).Scan(&cnt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cnt != 1 {
|
||||
t.Errorf("同字节图片应去重为一行,实际 %d 行", cnt)
|
||||
}
|
||||
_ = first
|
||||
}
|
||||
|
||||
func mustImageID(t *testing.T, e *testEnv, png []byte) int64 {
|
||||
t.Helper()
|
||||
return e.uploadPNG(e.loginAdmin(), png)
|
||||
}
|
||||
Reference in New Issue
Block a user