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

171 lines
5.6 KiB
Go

package web
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/config"
"rond-with-you/internal/db"
)
// loginFixture 是一个只有账号、没有业务数据的测试库,专测登录这条路径。
//
// ROND_TEST_CONF=conf/app-test.ini go test ./internal/web -run Login -v
//
// 不需要 ROND_SRC:登录用不到备份包,省掉那次几十兆的导入。
type loginFixture struct {
pg *sql.DB
srv *Server
uid int64
user string
pass string
}
func newLoginFixture(t *testing.T) *loginFixture {
t.Helper()
confPath := os.Getenv("ROND_TEST_CONF")
if confPath == "" {
t.Skip("设置 ROND_TEST_CONF 后运行登录校验")
}
chdirModuleRoot(t)
cfg, err := config.Load(confPath)
if err != nil {
t.Fatalf("加载配置 %s 失败: %v", confPath, err)
}
ctx := context.Background()
pg, err := db.Open(cfg.DSN)
if err != nil {
t.Fatalf("连接数据库失败: %v", err)
}
t.Cleanup(func() { pg.Close() })
if err := db.Migrate(ctx, pg); err != nil {
t.Fatalf("初始化数据库失败: %v", err)
}
user := fmt.Sprintf("e2e-login-%d", time.Now().UnixNano())
pass := "e2e-correct-horse"
hash, err := auth.HashPassword(pass)
if err != nil {
t.Fatalf("生成口令哈希失败: %v", err)
}
var uid int64
if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
VALUES ($1,$2,'e2e') RETURNING id`, user, hash).Scan(&uid); err != nil {
t.Fatalf("创建测试账号失败: %v", err)
}
t.Cleanup(func() { pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid) })
srv, err := New(cfg, pg)
if err != nil {
t.Fatalf("初始化服务失败: %v", err)
}
return &loginFixture{pg: pg, srv: srv, uid: uid, user: user, pass: pass}
}
// attempt 走一次真实的登录提交:每次配一张新验证码(验证码是一次性的,
// 不复用才能保证测到的是「口令这一环」,而不是被验证码拦下)。
func (f *loginFixture) attempt(t *testing.T, username, password string) *httptest.ResponseRecorder {
t.Helper()
id, _, err := f.srv.captcha.Issue()
if err != nil {
t.Fatalf("签发验证码失败: %v", err)
}
code, ok := f.srv.captcha.Answer(id)
if !ok {
t.Fatal("刚签发的验证码取不到答案")
}
form := url.Values{"username": {username}, "password": {password}, "captcha": {code}}
r := httptest.NewRequest("POST", "/admin/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: captchaCookie, Value: id})
w := httptest.NewRecorder()
f.srv.loginSubmit(w, r)
return w
}
// TestLoginSubmitLocksAfterRepeatedFailures 证明登录失败会按用户名上锁:
// 连错到阈值后,**即使口令是对的**也进不去,直到冷却结束。
//
// 这条链路以前完全没有限速——验证码是唯一的阻力,而验证码本身可以无限重取。
func TestLoginSubmitLocksAfterRepeatedFailures(t *testing.T) {
f := newLoginFixture(t)
f.srv.loginGuard.Reset()
t.Cleanup(func() { f.srv.loginGuard.Reset() })
// 阈值内的失败仍是普通的「用户名或密码错误」(200 重新渲染表单)
for i := 1; i < 5; i++ {
w := f.attempt(t, f.user, "wrong-password")
if w.Code != http.StatusOK {
t.Fatalf("第 %d 次失败的状态码应为 200,实际 %d", i, w.Code)
}
if body := w.Body.String(); !strings.Contains(body, "用户名或密码错误") {
t.Fatalf("第 %d 次失败应提示口令错误,实际: %s", i, firstLine(body))
}
}
t.Logf("前 4 次失败:普通提示、未上锁")
// 第 5 次失败触发锁定
w := f.attempt(t, f.user, "wrong-password")
if w.Code != http.StatusTooManyRequests {
t.Fatalf("触发阈值后应返回 429,实际 %d", w.Code)
}
if body := w.Body.String(); !strings.Contains(body, "过于频繁") {
t.Errorf("应提示尝试过于频繁,实际: %s", firstLine(body))
}
// 锁定期间**正确口令**也进不去——这正是限速的意义
w = f.attempt(t, f.user, f.pass)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("锁定期间即使口令正确也应 429,实际 %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != "" {
t.Errorf("锁定期间不该签发会话(不该重定向),实际 Location=%s", loc)
}
// 冷却结束后正常登录
f.srv.loginGuard.Reset()
w = f.attempt(t, f.user, f.pass)
if w.Code != http.StatusSeeOther {
t.Fatalf("口令正确时应 303 跳转,实际 %d (%s)", w.Code, firstLine(w.Body.String()))
}
if loc := w.Header().Get("Location"); loc != "/admin" {
t.Errorf("应跳到 /admin,实际 %s", loc)
}
if c := w.Result().Cookies(); len(c) == 0 {
t.Error("登录成功没有下发会话 cookie")
}
}
// TestLoginSubmitSuccessClearsFailures 登录成功要把失败计数清零,
// 否则「偶发手滑几次 + 一次成功」会一直攒着,迟早把站长自己挡在门外。
func TestLoginSubmitSuccessClearsFailures(t *testing.T) {
f := newLoginFixture(t)
f.srv.loginGuard.Reset()
t.Cleanup(func() { f.srv.loginGuard.Reset() })
for i := 0; i < 4; i++ {
f.attempt(t, f.user, "wrong-password")
}
if d := f.srv.loginGuard.RetryAfter(f.user); d != 0 {
t.Fatalf("4 次失败不该上锁,实际还要等 %v", d)
}
if w := f.attempt(t, f.user, f.pass); w.Code != http.StatusSeeOther {
t.Fatalf("口令正确时应 303,实际 %d", w.Code)
}
// 成功后计数清零:再连错 4 次仍然不该锁
for i := 0; i < 4; i++ {
if w := f.attempt(t, f.user, "wrong-password"); w.Code != http.StatusOK {
t.Fatalf("成功清零后第 %d 次失败不该被锁,实际 %d", i+1, w.Code)
}
}
}