Files
rond-with-you/internal/web/tx_atomicity_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

105 lines
4.7 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 (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// TestEditVisitUpdateAtomicOnClampFailure 证明「改到访」整组写入是一个事务:
// 其中夹行程那步失败时,前面已经写进去的到访时间必须一并回滚。
//
// 手法是给 movements 挂一个必然报错的 BEFORE UPDATE 触发器——夹行程(clampVisitMovements)
// 就是靠 UPDATE movements 落地的,触发器一炸它就返回错误。
// 回滚生效 ⇒ 到访时间保持原值;回滚失效 ⇒ 到访时间已被改掉(旧代码各写各的、没有事务,
// 到访那条 UPDATE 早就提交了)。
func TestEditVisitUpdateAtomicOnClampFailure(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
// 挑一条被行程引用的到访,连同它那条行程的开始时间
var visitSrc, movePK int
var mvSt, mvEn time.Time
if err := pg.QueryRowContext(ctx, `SELECT m.from_visit_src, m.src_pk, m.started_at, m.ended_at
FROM movements m WHERE m.dataset_id=$1 AND m.from_visit_src IS NOT NULL
AND m.started_at IS NOT NULL AND m.ended_at IS NOT NULL
ORDER BY m.src_pk LIMIT 1`, dsID).Scan(&visitSrc, &movePK, &mvSt, &mvEn); err != nil {
t.Fatalf("挑不出「被行程引用的到访」: %v", err)
}
var origArrival time.Time
var origDeparture sql.NullTime
if err := pg.QueryRowContext(ctx, `SELECT arrival, departure FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, dsID, visitSrc).Scan(&origArrival, &origDeparture); err != nil {
t.Fatalf("读取到访失败: %v", err)
}
// 把离开时间推到行程开始之后,夹行程就必然要动手改这条行程
newArrival := mvSt.Add(-2 * time.Hour).Truncate(time.Minute)
newDeparture := mvSt.Add(time.Hour).Truncate(time.Minute)
installMovementUpdateFailTrigger(t, ctx, pg)
form := url.Values{
"src_pk": {fmt.Sprint(visitSrc)},
"arrival": {newArrival.Local().Format("2006-01-02T15:04")},
"departure": {newDeparture.Local().Format("2006-01-02T15:04")},
}
r := httptest.NewRequest("POST", "/admin/edit/visit/update", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
f.srv.editVisitUpdate(w, r.WithContext(f.ctx))
if w.Code != http.StatusInternalServerError {
t.Fatalf("夹行程失败应报 500,实际 code=%d body=%s", w.Code, w.Body.String())
}
var gotArrival time.Time
var gotDeparture sql.NullTime
if err := pg.QueryRowContext(ctx, `SELECT arrival, departure FROM visits
WHERE dataset_id=$1 AND src_pk=$2`, dsID, visitSrc).Scan(&gotArrival, &gotDeparture); err != nil {
t.Fatalf("回读到访失败: %v", err)
}
same := gotArrival.Equal(origArrival) && gotDeparture.Valid == origDeparture.Valid &&
(!gotDeparture.Valid || gotDeparture.Time.Equal(origDeparture.Time))
if !same {
t.Errorf("夹行程失败后到访 #%d 的时间被改掉了:%v/%v → %v/%v(整组写入应当回滚)",
visitSrc, origArrival, origDeparture, gotArrival, gotDeparture)
}
// 行程那一端也不能被改
var gotSt, gotEn time.Time
if err := pg.QueryRowContext(ctx, `SELECT started_at, ended_at FROM movements
WHERE dataset_id=$1 AND src_pk=$2`, dsID, movePK).Scan(&gotSt, &gotEn); err != nil {
t.Fatalf("回读行程失败: %v", err)
}
if !gotSt.Equal(mvSt) || !gotEn.Equal(mvEn) {
t.Errorf("行程 #%d 的时间被改掉了:%v/%v → %v/%v", movePK, mvSt, mvEn, gotSt, gotEn)
}
t.Logf("夹行程失败后到访 #%d(%v ~ %v)与行程 #%d 均保持原值 ✓", visitSrc, origArrival, gotDeparture, movePK)
}
// installMovementUpdateFailTrigger 给 movements 挂一个必然报错的 BEFORE UPDATE 触发器。
// 名字带 e2e_ 前缀以免撞上别的对象;用完即弃(t.Cleanup 里 drop,且在连接关闭之前跑)。
func installMovementUpdateFailTrigger(t *testing.T, ctx context.Context, pg *sql.DB) {
t.Helper()
if _, err := pg.ExecContext(ctx, `CREATE OR REPLACE FUNCTION e2e_fail_movement_update() RETURNS trigger
LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'e2e: 事务原子性用例故意失败'; END $$`); err != nil {
t.Fatalf("建触发器函数失败: %v", err)
}
if _, err := pg.ExecContext(ctx, `DROP TRIGGER IF EXISTS e2e_fail_movement_update ON movements`); err != nil {
t.Fatalf("清理旧触发器失败: %v", err)
}
if _, err := pg.ExecContext(ctx, `CREATE TRIGGER e2e_fail_movement_update
BEFORE UPDATE ON movements FOR EACH ROW EXECUTE FUNCTION e2e_fail_movement_update()`); err != nil {
t.Fatalf("建触发器失败: %v", err)
}
t.Cleanup(func() {
pg.ExecContext(ctx, `DROP TRIGGER IF EXISTS e2e_fail_movement_update ON movements`)
pg.ExecContext(ctx, `DROP FUNCTION IF EXISTS e2e_fail_movement_update()`)
})
}