Files
tamabox/internal/route/errorpage_test.go
T
tamakyi 5def41d0f8 feat(ux): 真 404 / HTML 500 页,以及「提问者找回自己的提问」
一、错误页

原来未知路径一律 302 到首页:用户点了个失效链接,地址栏莫名跳到首页却什么
提示都没有,分不清是链接坏了还是自己点错了;对搜索引擎是软 404,失效链接会
被当成有效页面一直占抓取预算。500 则是 flamego.Recovery() 的纯文本
"Internal Server Error",白底黑字两行英文,没有返回入口,也没法报编号。

现在是 internal/route/errorpage.go 里的 writeErrorPage:
- 404 保留状态码 + 一页能看懂的说明 + 返回首页按钮 + noindex
- panic 由自写的 recoveryMiddleware 兜住,渲染同样的 HTML 500 页,
  并带上 trace id(用户反馈时报这个号就能定位日志),日志里也带完整栈
- 响应已写出时不再写头(否则 net/http 会忽略状态码并打 superfluous 警告)

为什么不套模板:f.NotFound 不经过组级中间件 —— template.Templater 与
Contexter(连同 session、CSRF)都挂在 f.Group 上,在 NotFound 里注入
template.Data 会直接 panic(实测:"value not found for type template.Data")。
而 recovery 是最外层,那时模板还没装配。错误页恰恰最不该依赖可能出错的链路,
所以用内联 HTML + i18n.T,语言从 Accept-Language 猜(拿不到 session)。

蜜罐分流仍在 NotFound 最前面:临时打开 enable_honeypot 实测 /wp-admin
仍返回假 WordPress 登录页(200),普通未知路径返回 404。

二、提问者找回自己的提问

不填邮箱时,唯一能追踪这条提问的东西是提问成功页上那条链接 —— 关掉页面、
链接冲掉了就永久失联。箱主认真答了,提问者永远不知道。这是这类产品最核心的
体验断点,而它不需要账号体系就能解决:

- 成功页把 {domain, id, token} 写进本机 localStorage(最多 30 条,按 domain+id 去重)
- 再访该箱时显示一条不打扰的横条:「这台设备上有 N 条提问记录,其中 M 条已有回答」,
  点「查看」展开列表,每条可直接打开
- 新增 POST /api/v1/ask-status 批量回答「哪些已被回答」:一次 IN 查询,不逐条 N+1

它不能用来枚举:必须同时给出正确 token 才返回 ok=true,而「token 错」「id 不存在」
「不属于该箱主」三种情况的响应完全相同(ok=false / answered=false / excerpt 为空),
无法区分。token 比较走常数时间,单次最多 20 条,限流 30/分钟。用 POST 不用 GET,
因为 token 不能进 URL(会留在浏览器历史、代理日志、Referer 里)。

列表摘要先过 MarkdownPlainText 再按 rune 截断:直接截原文会露出 ![图片](https://...)
这种噪音,一条带图的提问几乎只剩链接。

验证:
- verify_ask_history.py 12/12(含「错误 token / 不存在的 id / 别人的提问」三者
  响应完全一致、缺 CSRF 被拒、超 20 条截断)
- verify_ask_history_ui.mjs 用 CDP 在真实 Chrome 里跑:无记录时提示条隐藏;
  注入记录后显示「1 条,其中 1 条已有回答」;列表渲染、链接带 token、点击展开正常
- 错误页单测 6 项(HTML/不泄露 panic 内容/已写出时不重复写头/语言选择/状态码/转义)
2026-10-29 17:18:39 +08:00

127 lines
4.3 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.
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package route
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/flamego/flamego"
"github.com/stretchr/testify/require"
)
// TestRecoveryMiddlewareRendersHTML 锁住 panic 的对外表现。
//
// 改动前用的是 flamego.Recovery():它直接写纯文本 "Internal Server Error",
// 用户看到白底黑字两行英文,没有返回入口,也拿不到可以报给我们的编号。
func TestRecoveryMiddlewareRendersHTML(t *testing.T) {
f := flamego.New()
f.Use(recoveryMiddleware())
f.Get("/boom", func() {
panic("boom")
})
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
resp := httptest.NewRecorder()
f.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
require.True(t, strings.HasPrefix(resp.Header().Get("Content-Type"), "text/html"),
"必须是 HTML,不能退回纯文本")
require.Equal(t, "no-store", resp.Header().Get("Cache-Control"))
body := resp.Body.String()
require.Contains(t, body, "<!DOCTYPE html>")
// 中文环境(default lang)下应给出可读文案与返回入口
require.Contains(t, body, "服务出了点问题")
require.Contains(t, body, `href="/"`, "错误页必须有返回首页的入口")
require.NotContains(t, body, "boom", "不能把 panic 内容暴露给用户")
}
// TestRecoveryMiddlewareDoesNotDoubleWrite 响应已写出时不能再写头。
//
// 场景:handler 先写了一部分内容再 panic。这时 HTTP 头已经发出去了,
// 再 WriteHeader 会被 net/http 忽略(并打 superfluous 警告),
// 强行写还会把已经发出去的响应体盖掉。
func TestRecoveryMiddlewareDoesNotDoubleWrite(t *testing.T) {
f := flamego.New()
f.Use(recoveryMiddleware())
f.Get("/half", func(ctx flamego.Context) {
_, _ = ctx.ResponseWriter().Write([]byte("partial"))
panic("boom after write")
})
req := httptest.NewRequest(http.MethodGet, "/half", nil)
resp := httptest.NewRecorder()
f.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code, "头已发出,状态码不能变")
require.Equal(t, "partial", resp.Body.String(), "不能把已写出的内容覆盖掉")
}
// TestPickErrorPageLang 错误页语言只能靠 Accept-Language 猜
// (sessioner 是组级中间件,NotFound 不经过它)。
func TestPickErrorPageLang(t *testing.T) {
cases := []struct {
name string
header string
want string
}{
{"空 header 用站点默认", "", ""},
{"英文", "en-US,en;q=0.9", "en"},
{"日文", "ja-JP,ja;q=0.9", "ja"},
{"繁体", "zh-TW,zh;q=0.9", "zh-TW"},
{"zh-Hant 也算繁体", "zh-Hant", "zh-TW"},
{"简体", "zh-CN,zh;q=0.9", "zh-CN"},
// 浏览器常把首选语言排在前面,这里按出现顺序取第一个能识别的
{"首选英文后跟日文", "en-GB,en;q=0.9,ja;q=0.5", "en"},
{"无法识别时回退默认", "de-DE,fr;q=0.8", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/nope", nil)
if c.header != "" {
req.Header.Set("Accept-Language", c.header)
}
got := pickErrorPageLang(req)
if c.want == "" {
// 回退到站点默认语言,不是空串
require.NotEmpty(t, got)
return
}
require.Equal(t, c.want, got)
})
}
}
// TestWriteErrorPageStatusCodes 404 与 500 各自的状态码与 noindex。
func TestWriteErrorPageStatusCodes(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
f := flamego.New()
f.Get("/x", func(ctx flamego.Context) {
writeErrorPage(ctx, status, "error.notFoundTitle", "error.notFoundDesc")
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
resp := httptest.NewRecorder()
f.ServeHTTP(resp, req)
require.Equal(t, status, resp.Code)
require.Contains(t, resp.Body.String(), `name="robots" content="noindex"`,
"错误页不该被搜索引擎收录")
}
}
// TestEscErrorPageText 字典文本里出现引号/尖括号时不能把页面结构弄坏。
func TestEscErrorPageText(t *testing.T) {
require.Equal(t, "a&quot;b", esc(`a"b`))
require.Equal(t, "&lt;script&gt;", esc("<script>"))
require.Equal(t, "a&amp;b", esc("a&b"))
require.Equal(t, "&#39;x&#39;", esc("'x'"))
}