Files
pure-note/internal/webui/webui_test.go
T
wangairnan 6e83426ca9 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 单测
2026-09-08 08:14:23 +08:00

111 lines
3.6 KiB
Go

package webui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
)
func testFS() fstest.MapFS {
return fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte(`<!doctype html><html><head><title>{{.Title}}</title><meta name="description" content="{{.Description}}"><meta property="og:title" content="{{.OGTitle}}"></head><body><script src="/assets/app-abc123.js"></script></body></html>`)},
"assets/app-abc123.js": &fstest.MapFile{Data: []byte("console.log(1)")},
"favicon.svg": &fstest.MapFile{Data: []byte("<svg/>")},
}
}
func newTestUI(t *testing.T) *UI {
t.Helper()
u, err := newFromFS(testFS())
if err != nil {
t.Fatal(err)
}
return u
}
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestSPAAndMeta(t *testing.T) {
u := newTestUI(t)
meta := func(r *http.Request) Meta {
if strings.HasPrefix(r.URL.Path, "/notes/hello") {
return Meta{Title: "笔记标题 - 站点", Description: "摘要", OGTitle: "笔记标题", OGType: "article", OGURL: "http://x/notes/hello", SiteName: "站点"}
}
return Meta{Title: "站点", Description: "默认", OGTitle: "站点", OGType: "website", SiteName: "站点"}
}
h := u.Handler(meta)
// 根路径:默认 meta
rec := get(t, h, "/")
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<title>站点</title>") {
t.Errorf("根路径应渲染默认 meta: %d %s", rec.Code, rec.Body.String())
}
// SPA 深链:注入笔记 meta
rec = get(t, h, "/notes/hello")
if !strings.Contains(rec.Body.String(), "<title>笔记标题 - 站点</title>") {
t.Errorf("深链应注入笔记 meta: %s", rec.Body.String())
}
// html/template 自动转义:标题含恶意内容不破坏标签结构
h2 := u.Handler(func(r *http.Request) Meta {
return Meta{Title: `<script>alert(1)</script>`, OGTitle: `" onclick="x`}
})
rec = get(t, h2, "/notes/evil")
body := rec.Body.String()
if strings.Contains(body, "<script>alert") {
t.Errorf("meta 注入必须转义: %s", body)
}
if !strings.Contains(body, "&lt;script&gt;") {
t.Errorf("应含转义后的实体: %s", body)
}
// 指纹资产:200 + 正确 MIME + immutable 缓存
rec = get(t, h, "/assets/app-abc123.js")
if rec.Code != 200 {
t.Errorf("资产应 200: %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
t.Errorf("JS MIME 错误: %s", ct)
}
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
t.Errorf("指纹资产应 immutable: %s", cc)
}
// 缺失资产(带扩展名)→ 404,不回退 HTML
rec = get(t, h, "/assets/missing-abc.js")
if rec.Code != http.StatusNotFound {
t.Errorf("缺失资产应 404: %d", rec.Code)
}
// 无扩展名深链 → 回退 index.html
rec = get(t, h, "/tags/some-tag")
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<title>") {
t.Errorf("无扩展名深链应回退 index.html: %d", rec.Code)
}
}
func TestPlaceholderWhenNoIndex(t *testing.T) {
u, err := newFromFS(fstest.MapFS{"dist/.keep": &fstest.MapFile{Data: []byte("")}})
if err != nil {
t.Fatal(err)
}
rec := get(t, u.Handler(nil), "/")
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("无 index.html 应 503 占位: %d", rec.Code)
}
}
func TestNonGetRejected(t *testing.T) {
u := newTestUI(t)
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
u.Handler(nil).ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("非 GET 应 405: %d", rec.Code)
}
}