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,222 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestVisibilityMatrix 核心不变量(§13):主体 × 笔记状态 × 出口 的表驱动矩阵。
|
||||
// 判定规则:匿名不可见 ⇒ 404 或输出中不含。
|
||||
func TestVisibilityMatrix(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
f := e.fixtures()
|
||||
|
||||
type outlet struct {
|
||||
name string
|
||||
probe func(c *http.Client, subject string) (status int, body string)
|
||||
}
|
||||
|
||||
contains := func(body, sub string) bool { return strings.Contains(body, sub) }
|
||||
|
||||
outlets := []outlet{
|
||||
{
|
||||
name: "列表/api/notes",
|
||||
probe: func(c *http.Client, _ string) (int, string) {
|
||||
resp, b := e.get(c, "/api/notes?page_size=100")
|
||||
return resp.StatusCode, string(b)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "详情/api/notes/{slug}",
|
||||
probe: func(c *http.Client, slug string) (int, string) {
|
||||
resp, b := e.get(c, "/api/notes/"+slug)
|
||||
return resp.StatusCode, string(b)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "标签/api/tags",
|
||||
probe: func(c *http.Client, _ string) (int, string) {
|
||||
resp, b := e.get(c, "/api/tags")
|
||||
return resp.StatusCode, string(b)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RSS/feed.xml",
|
||||
probe: func(c *http.Client, _ string) (int, string) {
|
||||
resp, b := e.get(c, "/feed.xml")
|
||||
return resp.StatusCode, string(b)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sitemap.xml",
|
||||
probe: func(c *http.Client, _ string) (int, string) {
|
||||
resp, b := e.get(c, "/sitemap.xml")
|
||||
return resp.StatusCode, string(b)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "HTML meta 注入",
|
||||
probe: func(c *http.Client, slug string) (int, string) {
|
||||
resp, b := e.get(c, "/notes/"+slug)
|
||||
return resp.StatusCode, string(b)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// (出口, 状态特征, 匿名期望, 管理员期望)
|
||||
type expect struct {
|
||||
anonVisible bool
|
||||
adminVisible bool
|
||||
}
|
||||
notes := []struct {
|
||||
slug string
|
||||
title string
|
||||
tag string
|
||||
visible expect
|
||||
}{
|
||||
{f.pubSlug, "公开笔记Alpha", "公开", expect{true, true}},
|
||||
{f.privSlug, "私有笔记Beta", "秘密", expect{false, false}}, // 管理员也只在详情/admin 出口可见
|
||||
{f.trashSlug, "回收站笔记Gamma", "公开", expect{false, false}},
|
||||
{"no-such-note", "不存在笔记", "无", expect{false, false}},
|
||||
}
|
||||
|
||||
for _, n := range notes {
|
||||
for _, o := range outlets {
|
||||
t.Run(o.name+"/"+n.slug, func(t *testing.T) {
|
||||
statusAnon, bodyAnon := o.probe(f.anon, n.slug)
|
||||
statusAdmin, bodyAdmin := o.probe(f.admin, n.slug)
|
||||
|
||||
if statusAnon >= 500 || statusAdmin >= 500 {
|
||||
t.Fatalf("5xx: anon=%d admin=%d", statusAnon, statusAdmin)
|
||||
}
|
||||
// 详情出口:非公开匿名必须 404;HTML meta 出口走 SPA fallback(200),
|
||||
// 按 §13 用「输出中不含」判定(下方 meta 内容断言)
|
||||
isDetail := strings.Contains(o.name, "详情")
|
||||
isMeta := strings.Contains(o.name, "meta")
|
||||
// 匿名判定
|
||||
if isDetail {
|
||||
if n.visible.anonVisible {
|
||||
if statusAnon != http.StatusOK {
|
||||
t.Errorf("匿名应可见但状态 %d", statusAnon)
|
||||
}
|
||||
} else if statusAnon != http.StatusNotFound {
|
||||
t.Errorf("匿名不可见应为 404,实际 %d(body: %.100s)", statusAnon, bodyAnon)
|
||||
}
|
||||
} else if !isMeta {
|
||||
if !n.visible.anonVisible && (contains(bodyAnon, n.title) || contains(bodyAnon, n.slug)) {
|
||||
t.Errorf("匿名输出中不应出现 %q/%q", n.title, n.slug)
|
||||
}
|
||||
}
|
||||
// meta 注入内容判定:公开 → 注入笔记标题;非公开匿名 → 站点默认标题
|
||||
if strings.Contains(o.name, "meta") {
|
||||
if n.visible.anonVisible {
|
||||
if !contains(bodyAnon, "<title>"+n.title) {
|
||||
t.Errorf("公开笔记 meta 应注入笔记标题,body: %s", bodyAnon)
|
||||
}
|
||||
} else {
|
||||
if contains(bodyAnon, n.title) {
|
||||
t.Errorf("非公开笔记 meta 不应注入笔记标题")
|
||||
}
|
||||
if !contains(bodyAnon, "<title>测试站</title>") {
|
||||
t.Errorf("非公开笔记 meta 应回退站点默认标题,body: %s", bodyAnon)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 管理员判定:列表/标签/RSS/sitemap 仍只含公开内容
|
||||
if strings.Contains(o.name, "列表") || strings.Contains(o.name, "标签") ||
|
||||
strings.Contains(o.name, "RSS") || strings.Contains(o.name, "sitemap") {
|
||||
if contains(bodyAdmin, n.title) && !n.visible.anonVisible {
|
||||
t.Errorf("管理员的公开聚合出口(%s)也不应包含非公开内容 %q", o.name, n.title)
|
||||
}
|
||||
}
|
||||
// 详情:管理员可读公开+私有,不可读回收站/不存在
|
||||
if isDetail {
|
||||
switch {
|
||||
case n.slug == f.pubSlug:
|
||||
if statusAdmin != http.StatusOK || !contains(bodyAdmin, `"status":"public"`) {
|
||||
t.Errorf("管理员读公开笔记失败: %d", statusAdmin)
|
||||
}
|
||||
case n.slug == f.privSlug:
|
||||
if statusAdmin != http.StatusOK || !contains(bodyAdmin, `"status":"private"`) {
|
||||
t.Errorf("管理员应可私有预览: %d body: %.200s", statusAdmin, bodyAdmin)
|
||||
}
|
||||
default:
|
||||
if statusAdmin != http.StatusNotFound {
|
||||
t.Errorf("管理员读 %s 应 404(回收站仅经 /api/admin/trash),实际 %d", n.slug, statusAdmin)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 图片出口(并集语义 + 缓存头分流,§6.2/§7.4)----
|
||||
imageCases := []struct {
|
||||
name string
|
||||
id int64
|
||||
anonCode int
|
||||
anonCache string
|
||||
adminCode int
|
||||
adminCache string
|
||||
}{
|
||||
{"公开图", f.imgPub, 200, "public, max-age=31536000, immutable", 200, "public, max-age=31536000, immutable"},
|
||||
{"私有图", f.imgPriv, 404, "", 200, "private, no-store"},
|
||||
{"回收站图", f.imgTrash, 404, "", 200, "private, no-store"},
|
||||
{"孤儿图", f.imgOrphan, 404, "", 200, "private, no-store"},
|
||||
{"不存在图", 99999, 404, "", 404, ""},
|
||||
}
|
||||
for _, ic := range imageCases {
|
||||
t.Run("图片/"+ic.name, func(t *testing.T) {
|
||||
path := "/api/images/" + fmt.Sprint(ic.id)
|
||||
respA, _ := e.get(f.anon, path)
|
||||
if respA.StatusCode != ic.anonCode {
|
||||
t.Errorf("匿名期望 %d 实际 %d", ic.anonCode, respA.StatusCode)
|
||||
}
|
||||
if ic.anonCache != "" && respA.Header.Get("Cache-Control") != ic.anonCache {
|
||||
t.Errorf("匿名缓存头期望 %q 实际 %q", ic.anonCache, respA.Header.Get("Cache-Control"))
|
||||
}
|
||||
respM, _ := e.get(f.admin, path)
|
||||
if respM.StatusCode != ic.adminCode {
|
||||
t.Errorf("管理员期望 %d 实际 %d", ic.adminCode, respM.StatusCode)
|
||||
}
|
||||
if ic.adminCache != "" && respM.Header.Get("Cache-Control") != ic.adminCache {
|
||||
t.Errorf("管理员缓存头期望 %q 实际 %q", ic.adminCache, respM.Header.Get("Cache-Control"))
|
||||
}
|
||||
// 统一 404 不泄露存在性:私有与不存在响应体一致
|
||||
if ic.anonCode == 404 {
|
||||
_, b1 := e.get(f.anon, path)
|
||||
_, b2 := e.get(f.anon, "/api/images/99998")
|
||||
if string(b1) != string(b2) {
|
||||
t.Errorf("404 响应体应统一(防枚举): %q vs %q", b1, b2)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 管理接口权限 ----
|
||||
t.Run("管理接口权限", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "/api/admin/notes"},
|
||||
{http.MethodGet, "/api/admin/trash"},
|
||||
{http.MethodGet, "/api/admin/settings"},
|
||||
} {
|
||||
resp, _ := e.get(f.anon, tc.path)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("匿名 %s %s 应 401,实际 %d", tc.method, tc.path, resp.StatusCode)
|
||||
}
|
||||
respM, _ := e.get(f.admin, tc.path)
|
||||
if respM.StatusCode != http.StatusOK {
|
||||
t.Errorf("管理员 %s %s 应 200,实际 %d", tc.method, tc.path, respM.StatusCode)
|
||||
}
|
||||
}
|
||||
// 回收站内容仅经 /api/admin/trash 可见
|
||||
resp, body := e.get(f.admin, "/api/admin/trash")
|
||||
if resp.StatusCode != 200 || !contains(string(body), "回收站笔记Gamma") {
|
||||
t.Errorf("回收站列表应包含已删笔记: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user