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 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
171 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|