Files
rond-with-you/internal/web/place_activity_test.go
T
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
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 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

280 lines
11 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 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
}