Files
pure-note/internal/httpapi/visibility_test.go
T
wangairnan 5457aeaa33 feat: 新增发布日期自选、站点 Logo、回收站清空等十项改进
- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建)
- 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期
- 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案
- 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验
- 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小
- 后台各页顶栏固定高度,切换页面不再抖动
- 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片,
  博客 header 站点名前展示,设置页支持上传/更换/清除
- 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗)
- pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
2026-09-09 11:10:30 +08:00

223 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
})
}