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) } } }