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 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
280 lines
11 KiB
Go
280 lines
11 KiB
Go
package web
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
|
||
"rond-with-you/internal/rond"
|
||
)
|
||
|
||
// TestPlaceUpdateBatchActivity 盯住「地点行内编辑里的活动」这条路:地点自己没有活动列
|
||
// (活动挂在到访上),所以那个下拉是「把该地点名下全部到访一起换类型」。
|
||
// 写错的两种后果都很安静:空值被当成「改成无活动」→ 历史到访的类型被清掉;
|
||
// 不校验归属 → 能把别的数据集的活动塞进来。
|
||
//
|
||
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run BatchActivity -v
|
||
func TestPlaceUpdateBatchActivity(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
ctx, pg, dsID := f.ctx, f.pg, f.dsID
|
||
|
||
// 挑到访最多的那个地点:批量的效果才看得出来
|
||
var pk int
|
||
var placeID int64
|
||
var n int
|
||
if err := pg.QueryRowContext(ctx, `SELECT p.id, p.src_pk, count(v.id) AS c FROM places p
|
||
JOIN visits v ON v.place_id = p.id
|
||
WHERE p.dataset_id=$1 GROUP BY p.id, p.src_pk ORDER BY c DESC, p.src_pk LIMIT 1`,
|
||
dsID).Scan(&placeID, &pk, &n); err != nil {
|
||
t.Fatalf("找不到带有访次的地点: %v", err)
|
||
}
|
||
if n < 2 {
|
||
t.Fatalf("这个数据集里没有多于一条到访的地点(最多 %d 条),验不出「批量」", n)
|
||
}
|
||
var name string
|
||
var lat, lon float64
|
||
if err := pg.QueryRowContext(ctx, `SELECT COALESCE(name,''), COALESCE(lat,0), COALESCE(lon,0)
|
||
FROM places WHERE dataset_id=$1 AND src_pk=$2`, dsID, pk).Scan(&name, &lat, &lon); err != nil {
|
||
t.Fatalf("读地点失败: %v", err)
|
||
}
|
||
// 挑一个该地点还没用过的活动,否则「改了」和「没改」分不出来
|
||
var actID int64
|
||
var actName string
|
||
var actHome, actWork bool
|
||
if err := pg.QueryRowContext(ctx, `SELECT a.id, COALESCE(a.name,''), a.is_home, a.is_work FROM activities a
|
||
WHERE a.dataset_id=$1 AND NOT EXISTS (SELECT 1 FROM visits v
|
||
WHERE v.place_id=$2 AND v.activity_id=a.id)
|
||
ORDER BY a.id LIMIT 1`, dsID, placeID).Scan(&actID, &actName, &actHome, &actWork); err != nil {
|
||
t.Fatalf("找不到没被这个地点用过的活动: %v", err)
|
||
}
|
||
|
||
postRaw := func(activity string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
form := url.Values{
|
||
"src_pk": {fmt.Sprint(pk)}, "name": {name},
|
||
"lat": {fmt.Sprintf("%.6f", lat)}, "lon": {fmt.Sprintf("%.6f", lon)},
|
||
"province": {""}, "city": {""}, "district": {""},
|
||
}
|
||
if activity != "" {
|
||
form.Set("activity_id", activity)
|
||
}
|
||
req := httptest.NewRequest("POST", "/admin/edit/place/update", strings.NewReader(form.Encode()))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req = req.WithContext(ctx)
|
||
rec := httptest.NewRecorder()
|
||
f.srv.editPlaceUpdate(rec, req)
|
||
if rec.Code >= 500 {
|
||
t.Fatalf("处理失败: code=%d", rec.Code)
|
||
}
|
||
return rec
|
||
}
|
||
// is_home / is_work 是从活动上抄到到访的冗余标记,换活动时要跟着一起走
|
||
matched := func() int {
|
||
t.Helper()
|
||
var c int
|
||
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
|
||
WHERE dataset_id=$1 AND place_id=$2 AND activity_id=$3 AND is_home=$4 AND is_work=$5`,
|
||
dsID, placeID, actID, actHome, actWork).Scan(&c); err != nil {
|
||
t.Fatalf("统计失败: %v", err)
|
||
}
|
||
return c
|
||
}
|
||
var othersBefore int
|
||
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
|
||
WHERE dataset_id=$1 AND place_id<>$2 AND activity_id=$3`, dsID, placeID, actID).Scan(&othersBefore); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 1) 选中一个活动:该地点名下全部到访一起换过去
|
||
rec := postRaw(fmt.Sprint(actID))
|
||
loc := rec.Header().Get("Location")
|
||
if msg, _ := url.QueryUnescape(loc); !strings.Contains(msg, "ok=") || !strings.Contains(msg, "条到访") {
|
||
t.Fatalf("提示里要说清改了几条到访,实际 %q", loc)
|
||
}
|
||
if got := matched(); got != n {
|
||
t.Fatalf("该地点有 %d 条到访,只有 %d 条换成了「%s」(含 is_home/is_work)", n, got, actName)
|
||
}
|
||
var othersAfter int
|
||
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
|
||
WHERE dataset_id=$1 AND place_id<>$2 AND activity_id=$3`, dsID, placeID, actID).Scan(&othersAfter); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if othersAfter != othersBefore {
|
||
t.Fatalf("改一个地点的活动,别的地点的到访也变了:%d → %d", othersBefore, othersAfter)
|
||
}
|
||
|
||
// 2) 「(不修改)」:行内表单每次都会提交这一项,空值必须一个到访都不动
|
||
postRaw("")
|
||
if got := matched(); got != n {
|
||
t.Fatalf("没选活动却动了到访:只剩 %d/%d 条还是「%s」", got, n, actName)
|
||
}
|
||
|
||
// 3) 哨兵值「+ 新增活动…」:前端有守卫,服务端也要拦(当 id 会解析成「无活动」)
|
||
for _, raw := range []string{"__new__", "999999"} {
|
||
rec = postRaw(raw)
|
||
if l := rec.Header().Get("Location"); !strings.Contains(l, "err=") {
|
||
t.Fatalf("提交 activity_id=%q 应当被拒绝,实际 %q", raw, l)
|
||
}
|
||
if got := matched(); got != n {
|
||
t.Fatalf("提交 activity_id=%q 之后到访被改了:只剩 %d/%d 条还是「%s」", raw, got, n, actName)
|
||
}
|
||
}
|
||
// 3b) 别的数据集的活动:越权也得拒(resolveActivity 带 dataset_id 条件就是为了这个)
|
||
var foreign int64
|
||
if err := pg.QueryRowContext(ctx, `SELECT id FROM activities WHERE dataset_id<>$1 ORDER BY id LIMIT 1`,
|
||
dsID).Scan(&foreign); err == nil {
|
||
rec = postRaw(fmt.Sprint(foreign))
|
||
if l := rec.Header().Get("Location"); !strings.Contains(l, "err=") {
|
||
t.Fatalf("提交别的数据集的活动 #%d 应当被拒绝,实际 %q", foreign, l)
|
||
}
|
||
if got := matched(); got != n {
|
||
t.Fatalf("越权提交之后到访被改了:只剩 %d/%d 条还是「%s」", got, n, actName)
|
||
}
|
||
} else if err != sql.ErrNoRows {
|
||
t.Fatalf("查别的数据集的活动失败: %v", err)
|
||
}
|
||
|
||
// 4) 模板:行内表单必须带上活动下拉 + 新建框,且下拉里三样齐全
|
||
// (JS 靠 .activity-id-inline / .visit-act-new / .visit-act-add 三个类名找同伴元素)
|
||
req := httptest.NewRequest("GET", "/admin/edit", nil)
|
||
req = req.WithContext(ctx)
|
||
w := httptest.NewRecorder()
|
||
f.srv.editPage(w, req)
|
||
if w.Code != 200 {
|
||
t.Fatalf("编辑页渲染失败: code=%d", w.Code)
|
||
}
|
||
body := w.Body.String()
|
||
for _, want := range []string{
|
||
`action="/admin/edit/place/update"`, `select name="activity_id" class="activity-id-inline"`,
|
||
`class="f visit-act-new"`, `class="btn visit-act-add"`, `(不修改)`, `+ 新增活动…`,
|
||
} {
|
||
if !strings.Contains(body, want) {
|
||
t.Fatalf("编辑页里找不到 %q:模板与 JS 的契约断了", want)
|
||
}
|
||
}
|
||
// 这一行的提示要落在它自己身上:地点按到访数倒序排,246 条的那个在第 1 页
|
||
i := strings.Index(body, fmt.Sprintf(`id="place-row-%d"`, pk))
|
||
if i < 0 {
|
||
t.Fatalf("编辑页第 1 页找不到 #%d 这一行", pk)
|
||
}
|
||
row := body[i:]
|
||
if j := strings.Index(row, `<tr id="place-row-`); j > 0 {
|
||
row = row[:j]
|
||
}
|
||
if !strings.Contains(row, "该地点到访当前类型:") ||
|
||
!strings.Contains(row, actName+" "+fmt.Sprint(n)) {
|
||
t.Fatalf("这一行的「当前类型」提示不对(期望 %s %d):%s", actName, n, row[:min(len(row), 320)])
|
||
}
|
||
|
||
// 5) 导出链路:站点改的是 visits.activity_id,导出的包里对应到访必须换成新活动的 **src_pk**
|
||
// —— 站点内部 id 与 rond 编号是两套号,写错就会「网页上改了、rond 里还是旧活动」。
|
||
var actSrc int
|
||
if err := pg.QueryRowContext(ctx, `SELECT src_pk FROM activities WHERE id=$1`, actID).Scan(&actSrc); err != nil {
|
||
t.Fatalf("读活动编号失败: %v", err)
|
||
}
|
||
visitRows, err := pg.QueryContext(ctx, `SELECT src_pk FROM visits WHERE dataset_id=$1 AND place_id=$2`, dsID, placeID)
|
||
if err != nil {
|
||
t.Fatalf("读到访编号失败: %v", err)
|
||
}
|
||
var pks []int
|
||
for visitRows.Next() {
|
||
var v int
|
||
if err := visitRows.Scan(&v); err != nil {
|
||
visitRows.Close()
|
||
t.Fatalf("扫到访编号失败: %v", err)
|
||
}
|
||
pks = append(pks, v)
|
||
}
|
||
visitRows.Close()
|
||
|
||
blob := f.export(t)
|
||
out := filepath.Join(t.TempDir(), "activity-export.rondbackup")
|
||
if err := os.WriteFile(out, blob, 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
edb, err := rond.Open(out, t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("打开导出包失败: %v", err)
|
||
}
|
||
defer edb.Close()
|
||
ph := make([]string, 0, len(pks))
|
||
args := make([]any, 0, len(pks)+1)
|
||
// ⚠️ 参数顺序必须与 SQL 里占位符的出现顺序一致:`FILTER (WHERE ZACTIVITY_=?)` 在
|
||
// SELECT 列表里,它的 `?` 是整个语句的第一个,所以 actSrc 要排在所有 pks 之前。
|
||
// 曾经写成先追加 pks、最后追加 actSrc,于是这个 `?` 绑到了 pks[0] 上,
|
||
// 同时 IN 列表被顶掉一位、变成 pks[1:] ∪ {actSrc}:条数偶尔少 1(正是历史 flaky 的来源),
|
||
// 而 converted 只有碰巧 pks[0] == actSrc 时才等于 246。
|
||
args = append(args, actSrc)
|
||
for _, v := range pks {
|
||
ph = append(ph, "?")
|
||
args = append(args, v)
|
||
}
|
||
var inPackage, converted int
|
||
q := `SELECT count(*), count(*) FILTER (WHERE ZACTIVITY_=?) FROM ZVISIT WHERE Z_PK IN (` +
|
||
strings.Join(ph, ",") + `)`
|
||
if strings.Count(q, "?") != len(args) {
|
||
t.Fatalf("占位符 %d 个、参数 %d 个,绑定位错位了", strings.Count(q, "?"), len(args))
|
||
}
|
||
if err := edb.QueryRow(q, args...).Scan(&inPackage, &converted); err != nil {
|
||
t.Fatalf("读导出包失败: %v", err)
|
||
}
|
||
if inPackage != len(pks) {
|
||
// 两种成因的修法完全不同,所以失败时直接把证据打出来:
|
||
// ① pks 自身有重复 —— IN 列表里有 246 个值、只命中 245 个不同的行
|
||
// ② pks 唯一但包里真缺行 —— 导出/覆盖那条路把某一行弄丢了
|
||
missing, dup, total := diffVisitPKs(t, edb, pks)
|
||
t.Fatalf("导出包里只有 %d/%d 条到访(包内 ZVISIT 共 %d 行);缺 %v,pks 内重复 %v",
|
||
inPackage, len(pks), total, missing, dup)
|
||
}
|
||
if converted != len(pks) {
|
||
t.Fatalf("导出包里只有 %d/%d 条到访的活动换成了 #%d(站点内部 id %d)", converted, len(pks), actSrc, actID)
|
||
}
|
||
}
|
||
|
||
// diffVisitPKs 说清「包里的到访为什么比预期少」:返回包里缺的编号、pks 里重复的编号、
|
||
// 以及包内 ZVISIT 的总行数。总行数能立刻区分「少写了一行」和「两行撞成一个 Z_PK」。
|
||
func diffVisitPKs(t *testing.T, edb *sql.DB, pks []int) (missing, dup []int, total int) {
|
||
t.Helper()
|
||
have := map[int]bool{}
|
||
rows, err := edb.Query(`SELECT Z_PK FROM ZVISIT`)
|
||
if err != nil {
|
||
t.Fatalf("读导出包的到访编号失败: %v", err)
|
||
}
|
||
for rows.Next() {
|
||
var pk int
|
||
if err := rows.Scan(&pk); err != nil {
|
||
rows.Close()
|
||
t.Fatalf("扫导出包到访编号失败: %v", err)
|
||
}
|
||
have[pk] = true
|
||
total++
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
rows.Close()
|
||
t.Fatalf("读导出包到访编号中断: %v", err)
|
||
}
|
||
rows.Close()
|
||
|
||
want := map[int]int{}
|
||
for _, v := range pks {
|
||
want[v]++
|
||
}
|
||
for v, c := range want {
|
||
if c > 1 {
|
||
dup = append(dup, v)
|
||
}
|
||
if !have[v] {
|
||
missing = append(missing, v)
|
||
}
|
||
}
|
||
sort.Ints(missing)
|
||
sort.Ints(dup)
|
||
return missing, dup, total
|
||
}
|