Files
tamabox/templates/render_test.go
T
tamakyi d690355cdc feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。

新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
  答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
  「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
  (一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
  才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
  钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
  每次运行精修到不同 IP,配额花在长尾上。

报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
  相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
  路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
  必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
  标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
  看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
  代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。

存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
  失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
  defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)

配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。

顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
  TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
2026-10-30 16:40:44 +08:00

451 lines
17 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 templates
import (
"bytes"
"fmt"
gotemplate "html/template"
"regexp"
"sort"
"strings"
"testing"
"time"
"github.com/flamego/template"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/security/moderation"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
"github.com/tamakyi/TamaBox/internal/theme"
)
// 审核严格度下拉框的解析:不依赖属性的书写顺序或是否存在额外属性
// (模板可能被页面编辑器注入 data-* 属性),因此用正则而不是精确子串匹配。
var (
strictnessOptionRe = regexp.MustCompile(`<option value="(lax|standard|strict)"([^>]*)>`)
selectedAttrRe = regexp.MustCompile(`\bselected\b`)
)
// selectedStrictnessOptions 返回渲染结果中被标记 selected 的档位值(去重后升序)。
// 三个面板各渲染一份下拉框,正常情况下应得到恰好一个值;返回多个说明面板之间不一致。
func selectedStrictnessOptions(out string) []string {
seen := map[string]struct{}{}
var values []string
for _, m := range strictnessOptionRe.FindAllStringSubmatch(out, -1) {
if !selectedAttrRe.MatchString(m[2]) {
continue
}
if _, ok := seen[m[1]]; ok {
continue
}
seen[m[1]] = struct{}{}
values = append(values, m[1])
}
sort.Strings(values)
return values
}
// renderWithData 用给定数据渲染模板(复刻生产环境 Templater + FuncMap)。
func renderWithData(t *testing.T, name string, data map[string]interface{}) error {
t.Helper()
_, err := renderToString(t, name, data)
return err
}
// renderToString 渲染模板并返回输出,供需要断言渲染结果的用例使用。
func renderToString(t *testing.T, name string, data map[string]interface{}) (string, error) {
t.Helper()
fs, err := template.EmbedFS(FS, ".", []string{".html"})
if err != nil {
t.Fatalf("EmbedFS: %v", err)
}
tpl := gotemplate.New("Flamego.Template")
for _, f := range fs.Files() {
tt := tpl.New(f.Name())
for _, funcMap := range templatepkg.FuncMap() {
tt.Funcs(funcMap)
}
dataBytes, err := f.Data()
if err != nil {
t.Fatalf("get data of %q: %v", f.Name(), err)
}
if _, err := tt.Parse(string(dataBytes)); err != nil {
t.Fatalf("parse %q: %v", f.Name(), err)
}
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return buf.String(), nil
}
// mockLoggedUser 覆盖 templates/user/profile.html 引用到的 LoggedUser 字段。
type mockLoggedUser struct {
Domain string
Name string
Email string
Avatar string
Background string
BackgroundImage string
Backgroundimagealpha string
Intro string
Introcolor string
Usernamecolor string
Qrcodebackcolor string
Qrcodecolor string
Qrcodepdpcolor string
Dotscale string
UseCustomQRColor bool
Notify string
Language string
HarassmentSetting string
BlockWords string
// 审核严格度档位(lax / standard / strict),设置页三个功能面板共用一份。
ModerationStrictness string
Tags string
ShowInHome bool
Acfunlink string
Bililink string
Weibolink string
Xiaohongshulink string
Bloglink string
ChatMaxMessages int
DailyMessageLimit int
// Telegram 通知的会话 ID(profile.html 的通知面板会回显)。
TelegramChatID string
}
// profileBaseData 构造 /user/profile 渲染所需的基础数据。
func profileBaseData() map[string]interface{} {
return map[string]interface{}{
"Title": "设置",
"LangCode": "zh-CN",
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}},
"IsLogged": true,
"LoggedUser": &mockLoggedUser{
Domain: "tester", Name: "测试箱主", Email: "[email protected]",
Usernamecolor: "#000", Introcolor: "#666", Qrcodecolor: "#000",
Qrcodepdpcolor: "#000", Qrcodebackcolor: "#fff", Dotscale: "0.35",
Backgroundimagealpha: "0.8", Notify: "email", Language: "zh-CN",
HarassmentSetting: "none", BlockWords: "广告,spam", Tags: "杂谈",
ModerationStrictness: "standard",
ChatMaxMessages: 20, DailyMessageLimit: 0,
},
"CSRFTokenHTML": gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`),
"IsUserPage": false,
"IsPixel": false,
"VConsole": false,
"HasPagePassword": false,
"HasAskPassword": true,
"ActiveProfileTab": "account",
"ICP": "",
// 标签 / 屏蔽词上限,供标签块输入组件做实时提示(生产由 route/user.Profile 注入,
// 值来自 conf.Limits)
"TagLimits": map[string]int{
"MaxTags": conf.Limits.MaxTags,
"MaxTagLen": conf.Limits.MaxTagLen,
"MaxBlockWords": conf.Limits.MaxBlockWords,
"MaxBlockWordLen": conf.Limits.MaxBlockWordLen,
},
// 私聊面板数值上下界(同样来自 conf.Limits)
"ChatLimits": map[string]int{
"MaxChatMessages": conf.Limits.ChatMaxMessages,
"DailyMessageLimitMax": conf.Limits.DailyMessageLimitMax,
},
}
}
// TestRenderUserProfileTabs 渲染整合后的账号设置页:用户设置 + 提问/私聊/投票 三个功能面板,
// 覆盖"开启 / 手动关闭 / 已达定量上限"三种功能状态。
func TestRenderUserProfileTabs(t *testing.T) {
type mockControl struct {
Enabled bool
OpenAt *time.Time
CloseAt *time.Time
QuotaLimit int64
QuotaCount int64
QuotaResetAt *time.Time
}
type mockState struct {
Enabled bool
Open bool
Reason string
QuotaLimit int64
QuotaCount int64
}
type mockFeature struct {
Key string
LangCode string
NameText string
StatusText string
CSRFTokenHTML interface{}
Control *mockControl
State *mockState
OpenAtStr string
CloseAtStr string
QuotaResetAtStr string
ScheduleDays string
ScheduleStart string
ScheduleEnd string
Mode string
}
resetAt := time.Now().Add(-24 * time.Hour)
csrf := gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`)
base := profileBaseData()
base["QuotaLimitMax"] = db.QuotaLimitMax
base["CSRFTokenHTML"] = csrf
base["FeatureQuestion"] = &mockFeature{
Key: "question", LangCode: "zh-CN", NameText: "提问", StatusText: "进行中", CSRFTokenHTML: csrf,
Control: &mockControl{Enabled: true},
State: &mockState{Enabled: true, Open: true},
Mode: "always",
}
// 手动关闭
base["FeatureChat"] = &mockFeature{
Key: "chat", LangCode: "zh-CN", NameText: "私聊", StatusText: "已关闭", CSRFTokenHTML: csrf,
Control: &mockControl{Enabled: false},
State: &mockState{Enabled: false, Reason: "disabled"},
Mode: "always",
}
// 定时 + 定量:已达上限(历史数据存在循环定时,模式推断为 recurring)
base["FeatureVote"] = &mockFeature{
Key: "vote", LangCode: "zh-CN", NameText: "投票", StatusText: "已达上限", CSRFTokenHTML: csrf,
Control: &mockControl{Enabled: true, QuotaLimit: 100, QuotaCount: 100, QuotaResetAt: &resetAt},
State: &mockState{Enabled: true, Reason: "quota_reached", QuotaLimit: 100, QuotaCount: 100},
OpenAtStr: "2026-09-01T09:00", CloseAtStr: "2026-09-30T18:00",
QuotaResetAtStr: resetAt.Format("2006-01-02 15:04"),
ScheduleDays: "1,3,5", ScheduleStart: "09:00", ScheduleEnd: "18:00",
Mode: "recurring",
}
// 逐个面板渲染:模板能正常执行,且只有目标面板可见、其余面板带 hidden
all := []string{"account", "question", "chat", "vote", "security"}
for _, tab := range all {
base["ActiveProfileTab"] = tab
out, err := renderToString(t, "user/profile", base)
if err != nil {
t.Fatalf("render user/profile (tab=%s) failed: %v", tab, err)
}
if tab == "account" {
// 标签块组件的上限常量已注入(缺注入时 with 会静默跳过,页面失去计数/锁定能力)
wantMax := fmt.Sprintf(`data-tb-max="%d" data-tb-max-len="%d"`, conf.Limits.MaxTags, conf.Limits.MaxTagLen)
if !strings.Contains(out, wantMax) {
t.Fatalf("account: 标签输入缺 data-tb-max 注入,want %q", wantMax)
}
wantBlock := fmt.Sprintf(`data-tb-max="%d" data-tb-max-len="%d"`, conf.Limits.MaxBlockWords, conf.Limits.MaxBlockWordLen)
if !strings.Contains(out, wantBlock) {
t.Fatalf("account: 屏蔽词输入缺 data-tb-max 注入,want %q", wantBlock)
}
}
// 审核严格度:提问 / 私聊 / 投票三个面板都必须渲染同一份设置,且把每个档位的
// 拦截范围平铺展示出来 —— 要求是用户按下保存前就知道哪些内容会被拦、哪些会放行。
if tab == "question" || tab == "chat" || tab == "vote" {
if got := selectedStrictnessOptions(out); len(got) != 1 || got[0] != "standard" {
t.Fatalf("%s: 审核严格度未按当前档位渲染 selected 状态,got %v", tab, got)
}
for _, key := range []string{
"admin.moderationStrictnessScope.lax",
"admin.moderationStrictnessScope.standard",
"admin.moderationStrictnessScope.strict",
} {
if want := i18n.T("zh-CN", key); !strings.Contains(out, want) {
t.Fatalf("%s: 档位拦截范围缺失 %s,want %q", tab, key, want)
}
}
// 底线拦截是强制项、任何档位不可关闭,界面上必须明确告知。
if want := i18n.T("zh-CN", "admin.moderationStrictnessBaseline"); !strings.Contains(out, want) {
t.Fatalf("%s: 缺少底线拦截说明,want %q", tab, want)
}
}
if tab == "chat" {
// 私聊面板的 number 输入框 min/max 与 conf.Limits 同源
wantChat := fmt.Sprintf(`name="chat_max_messages" class="uk-input uk-width-small" type="number" min="1" max="%d"`, conf.Limits.ChatMaxMessages)
if !strings.Contains(out, wantChat) {
t.Fatalf("chat: 会话条数输入缺 conf 上限,want %q", wantChat)
}
wantDaily := fmt.Sprintf(`name="chat_daily_limit" class="uk-input uk-width-small" type="number" min="0" max="%d"`, conf.Limits.DailyMessageLimitMax)
if !strings.Contains(out, wantDaily) {
t.Fatalf("chat: 每日条数输入缺 conf 上限,want %q", wantDaily)
}
}
for _, other := range all {
want := fmt.Sprintf(`data-profile-panel="%s"`, other)
visible := strings.Contains(out, want+">")
hidden := strings.Contains(out, want+" hidden>")
if other == tab && !visible {
t.Fatalf("tab=%s: 面板 %s 未渲染为可见", tab, other)
}
if other != tab && !hidden {
t.Fatalf("tab=%s: 面板 %s 未隐藏", tab, other)
}
}
}
}
// TestRenderProfileStrictnessReflectsEffectiveTier 覆盖「档位回显」这个易错点。
//
// 存量账号的 moderation_strictness 为空值(语义是"跟随站点默认")。若模板直接拿该字段值
// 去和三个 <option> 逐个比较,会全部不匹配 —— 浏览器随后默认选中第一项「宽松」,
// 界面显示的档位就与真实生效的档位(站点默认,通常为严格)相反,把箱主误导到最松的一档。
// 因此回显必须走「最终生效档位」。
func TestRenderProfileStrictnessReflectsEffectiveTier(t *testing.T) {
for _, tc := range []struct {
name string
stored string
want []string
}{
{name: "未设置:回显生效档位,而非浏览器默认选中的宽松", stored: "", want: []string{string(moderation.StrictnessFor(""))}},
{name: "宽松", stored: "lax", want: []string{"lax"}},
{name: "标准", stored: "standard", want: []string{"standard"}},
{name: "严格", stored: "strict", want: []string{"strict"}},
{name: "非法值:回退严格", stored: "bogus", want: []string{string(moderation.StrictnessFor("bogus"))}},
} {
t.Run(tc.name, func(t *testing.T) {
base := profileBaseData()
base["ActiveProfileTab"] = "question"
base["LoggedUser"].(*mockLoggedUser).ModerationStrictness = tc.stored
out, err := renderToString(t, "user/profile", base)
if err != nil {
t.Fatalf("render user/profile failed: %v", err)
}
got := selectedStrictnessOptions(out)
if fmt.Sprint(got) != fmt.Sprint(tc.want) {
t.Fatalf("档位回显不符:stored=%q got=%v want=%v", tc.stored, got, tc.want)
}
// 核心回归点:未设置时绝不能显示为「宽松」,那是浏览器默认选中首项的陷阱。
if tc.stored == "" && len(got) == 1 && got[0] == "lax" {
t.Fatal("未设置档位时回显为「宽松」—— 与真实生效档位相反")
}
})
}
}
// TestRenderUserQuestionList 模拟 /user/questions 的数据渲染,复现 500 错误。
func TestRenderUserQuestionList(t *testing.T) {
type mockItem struct {
Type string
CreatedAt time.Time
Content string
Link string
IsAnswered bool
IsRead bool
IsPrivate bool
IsBlocked bool
QuestionID uint
IsPinned bool
PinLink string
// ReportCount 对应线上的 receivedItem(箱主列表的「被举报 N 次」标记)。
// 这里漏掉会让 question-list 模板在 range 时取字段失败。
ReportCount int64
}
type mockConv struct {
Token string
CreatedAt time.Time
}
type mockConvDetail struct {
Conversation *mockConv
OwnerDomain string
OwnerName string
FirstMessage string
IsBlocked bool
}
base := map[string]interface{}{
"Title": "我的提问",
"LangCode": "zh-CN",
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}},
"IsLogged": true,
"LoggedUser": map[string]interface{}{"Domain": "test", "Name": "tester"},
"LoggedUserName": "tester",
"CurrentURI": "/user/questions",
"IsUserPage": true,
"IsPixel": false,
"VConsole": false,
"CSRFTokenHTML": gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`),
"RecaptchaSiteKey": "",
"CaptchaType": "recaptcha",
"PageUser": map[string]interface{}{"Domain": "test", "Name": "tester", "Avatar": ""},
"ICP": "",
"Stats": map[string]int64{
"total": 3, "answered": 2, "unanswered": 1, "read": 2, "unread": 1, "private": 1, "pinned": 1,
},
"ChatStats": map[string]int64{
"total": 2, "received": 1, "initiated": 1, "closed": 0,
},
"Keyword": "",
"SelectedSort": "latest",
"SelectedStatus": "all",
"SelectedRead": "all",
"SelectedType": "all",
"ReceivedItems": []interface{}{
mockItem{Type: "question_public", CreatedAt: time.Now(), Content: "你好吗?", Link: "/_/test/1",
IsAnswered: true, IsRead: true, QuestionID: 1, IsPinned: true, PinLink: "/_/test/1/pin"},
mockItem{Type: "question_private", CreatedAt: time.Now(), Content: "悄悄话", Link: "/_/test/2",
IsAnswered: false, IsPrivate: true, QuestionID: 2, PinLink: "/_/test/2/pin"},
},
"ReceivedChats": []interface{}{
mockItem{Type: "chat", CreatedAt: time.Now(), Content: "你好", Link: "/_/test/chat/abc", IsBlocked: false},
},
"ConversationDetails": []interface{}{
mockConvDetail{Conversation: &mockConv{Token: "abc", CreatedAt: time.Now()},
OwnerDomain: "other", OwnerName: "别人", FirstMessage: "hi", IsBlocked: false},
},
}
if err := renderWithData(t, "user/question-list", base); err != nil {
t.Fatalf("render user/question-list failed: %v", err)
}
// 带搜索关键词的场景
base["Keyword"] = "hello"
if err := renderWithData(t, "user/question-list", base); err != nil {
t.Fatalf("render user/question-list (keyword) failed: %v", err)
}
// 处置期条幅:必须显示「人话」级别名而不是裸代码(裸 L2 对箱主没有意义)。
base["Keyword"] = ""
base["RestrictionLevel"] = "L2"
base["RestrictionLevelName"] = i18n.T("zh-CN", "restriction.level.L2")
base["RestrictionExpiresAt"] = "2026-11-01"
out, err := renderToString(t, "user/question-list", base)
if err != nil {
t.Fatalf("render user/question-list (restriction banner) failed: %v", err)
}
if !strings.Contains(out, "收窄公开") {
t.Fatal("restriction banner should show level name (收窄公开), got none")
}
if !strings.Contains(out, "2026-11-01") {
t.Fatal("restriction banner should show expiry date")
}
}
// TestThemeSwatchRenders 主题色块走的是 inline style="background: <gradient>"。
// html/template 对 style 属性做 CSS 过滤,未标记安全的 linear-gradient(...) 会被换成
// ZgotmplZ —— 设置页每个主题的色块都变成灰底。这个用例钉住「色块真的渲染出来」。
func TestThemeSwatchRenders(t *testing.T) {
base := profileBaseData()
base["Themes"] = []theme.Theme{
{Slug: "sakura", Swatch: "linear-gradient(135deg, #fceef3 0 45%, #ba3e66 45% 100%)"},
}
base["CurrentTheme"] = "sakura"
out, err := renderToString(t, "user/profile", base)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "ZgotmplZ") {
t.Fatal("色块被 html/template 过滤成 ZgotmplZ(检查是否漏了 safeCSS)")
}
if !strings.Contains(out, "linear-gradient(135deg, #fceef3 0 45%, #ba3e66 45% 100%)") {
t.Fatal("渲染结果里找不到主题色块的渐变")
}
}