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, "
"+n.title) {
t.Errorf("公开笔记 meta 应注入笔记标题,body: %s", bodyAnon)
}
} else {
if contains(bodyAnon, n.title) {
t.Errorf("非公开笔记 meta 不应注入笔记标题")
}
if !contains(bodyAnon, "测试站") {
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)
}
})
}