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, "<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) } }) }