audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证 (注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。 安全 - CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板 - 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性) - 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段 - GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition - 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品 数据一致性 - 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐) - Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error - 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致 - 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows - 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全 性能 - placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛 - 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍 另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
134 lines
5.2 KiB
Go
134 lines
5.2 KiB
Go
package web
|
||
|
||
import (
|
||
"html/template"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 数据编辑页的翻页链接必须「带条件、不双重转义」。这两件事都踩过坑:
|
||
//
|
||
// - 等号被 URL 上下文转义成 %3d:`mt%3d4` 在服务端解出来是「键为 mt=4」的参数,
|
||
// 筛选条件翻页时静默失效 —— 所以 PageLink 返回 template.URL;
|
||
// - & 被手工写成 &:template.URL 在 href 里仍会过一次 urlNormalizer,它自己会做
|
||
// HTML 属性转义,手工写一遍的结果是 `&`,浏览器解出来是「字面的 &」,
|
||
// 参数名变成 `amp;vpage`,表现为「带筛选时翻页没反应、筛选还丢了」(实测确认过)。
|
||
func TestEditPageLink(t *testing.T) {
|
||
d := EditData{Page: Page{Back: template.URL("/places?city=河池市")}}
|
||
d.Query = "家"
|
||
d.VisitQuery = "咖啡"
|
||
got := string(d.PageLink("vpage", 2))
|
||
|
||
if strings.Contains(got, "&") {
|
||
t.Errorf("含 & 会被二次转义成 &:%s", got)
|
||
}
|
||
for _, want := range []string{"q=%E5%AE%B6", "vq=", "back=%2Fplaces", "vpage=2"} {
|
||
if !strings.Contains(got, want) {
|
||
t.Errorf("翻页链接丢了 %q:%s", want, got)
|
||
}
|
||
}
|
||
// 键值之间的等号必须是裸的:写成 %3d 时服务端解出来是「键为 vpage=2」的参数,
|
||
// 翻页静默失效。上面逐项断言 "vpage=2" 就是在守这条。
|
||
// (back 的值里出现 %3D 是正常的 —— 那是内层地址自己的编码,必须保持。)
|
||
if !strings.HasPrefix(got, "/admin/edit?") {
|
||
t.Errorf("链接开头不对:%s", got)
|
||
}
|
||
// 分隔符必须是裸的 &(交给模板那层做属性转义)
|
||
if strings.Count(got, "&") != 3 {
|
||
t.Errorf("参数分隔符数量不对(期望 3 个裸 &):%s", got)
|
||
}
|
||
|
||
// 没有 back 时不该凭空多出参数
|
||
bare := string(EditData{}.PageLink("page", 3))
|
||
if bare != "/admin/edit?page=3" {
|
||
t.Errorf("无筛选时链接不对:%s", bare)
|
||
}
|
||
}
|
||
|
||
// 「在地图中查看」把地点名当查询参数拼进地址,名字里的 & / # / 空格都必须编码:
|
||
// 直接内插的话 & 会把参数拆断、# 会把后面整段当 fragment 吃掉。
|
||
func TestMapLink(t *testing.T) {
|
||
got := string(Page{}.MapLink("A&B #咖啡 100%"))
|
||
want := "/map?q=A%26B+%23%E5%92%96%E5%95%A1+100%25"
|
||
if got != want {
|
||
t.Errorf("MapLink = %q,期望 %q", got, want)
|
||
}
|
||
if got := string(Page{}.MapLink(" 家 ")); got != "/map?q=%E5%AE%B6" {
|
||
t.Errorf("首尾空白应去掉:%q", got)
|
||
}
|
||
}
|
||
|
||
// EditPlaceLink 是「在数据编辑页打开这个地点」,它把返回地址挂在 ?back= 上。
|
||
//
|
||
// 这里只允许编码**一次**。退回当前页时以前用的是 BackQS(已经 QueryEscape 过),
|
||
// 再转一次就成了 %252F…,服务端解一次拿到 %2F… 不以 / 开头,被 backFromRequest
|
||
// 当站外地址丢掉 —— 直接打开 /places/1 再点「编辑」,编辑页的返回按钮就凭空消失。
|
||
func TestEditPlaceLinkBackIsEncodedOnce(t *testing.T) {
|
||
const self = "/places/1?city=河池市&sort=dwell"
|
||
cases := []struct {
|
||
name string
|
||
p Page
|
||
want string // back 参数解码一次后的原值
|
||
}{
|
||
{"列表页给的 back", Page{
|
||
Back: template.URL("/places?city=河池市"),
|
||
SelfPath: self,
|
||
BackQS: template.URL(url.QueryEscape(self)),
|
||
}, "/places?city=河池市"},
|
||
{"back 为空时退回当前页", Page{
|
||
SelfPath: self,
|
||
BackQS: template.URL(url.QueryEscape(self)),
|
||
}, self},
|
||
}
|
||
for _, c := range cases {
|
||
got := string(c.p.EditPlaceLink(2141))
|
||
i := strings.Index(got, "&back=")
|
||
if i < 0 {
|
||
t.Fatalf("%s:链接里没有 back 参数:%s", c.name, got)
|
||
}
|
||
encoded := got[i+len("&back="):]
|
||
raw, err := url.QueryUnescape(encoded)
|
||
if err != nil {
|
||
t.Fatalf("%s:back 值解不出来:%v", c.name, err)
|
||
}
|
||
if raw != c.want {
|
||
t.Errorf("%s:back 解码一次 = %q,期望 %q(链接 %s)", c.name, raw, c.want, got)
|
||
}
|
||
// 服务端真的会收这一份:解出来的必须还能通过站内校验,否则返回按钮是空的
|
||
r := httptest.NewRequest("GET", "/admin/edit?open=2141&back="+encoded, nil)
|
||
if bk := backFromRequest(r); string(bk) != c.want {
|
||
t.Errorf("%s:服务端解出 back=%q,期望 %q —— 返回按钮会消失", c.name, bk, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 返回按钮的文案要从 back 里认出来源。踩过的坑:先取「第一个路径段」但没剥查询串,
|
||
// 于是 `/places?city=X` 这种「带筛选、没有下级路径」的整串都是第一段,谁都匹配不上,
|
||
// 按钮退化成光秃秃的「返回」。
|
||
func TestBackLabel(t *testing.T) {
|
||
cases := []struct{ back, want string }{
|
||
{"/places?city=河池市&sort=dwell", "返回地点库"},
|
||
{"/places/2141", "返回地点库"},
|
||
{"/timeline?from=2026-01-01", "返回时间线"},
|
||
{"/map?q=家", "返回地图"},
|
||
{"/stats", "返回数据统计"},
|
||
{"/topics/3", "返回专题"},
|
||
{"/admin/edit?q=家", "返回数据编辑"},
|
||
{"/admin", "返回后台"},
|
||
{"/", "返回首页"},
|
||
{"/something-else", "返回"},
|
||
{"", ""},
|
||
}
|
||
for _, c := range cases {
|
||
p := Page{}
|
||
if c.back != "" {
|
||
p.Back = template.URL(c.back)
|
||
}
|
||
if got := p.BackLabel(); got != c.want {
|
||
t.Errorf("BackLabel(%q) = %q,期望 %q", c.back, got, c.want)
|
||
}
|
||
}
|
||
}
|