From a8bdc1a61587f3b7789e75c885ba13cfee2f98c4 Mon Sep 17 00:00:00 2001 From: fabi Date: Tue, 16 Jun 2026 20:04:49 +0200 Subject: [PATCH] test(auth): deflake login rate-limit burst test under CPU load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit login_rate_limited_under_burst_pressure fired 30 wrong-password logins sequentially; each runs argon2 (~1s under heavy CI load), which let the per_sec=1 token-bucket refill keep exact pace with the loop so the bucket never emptied and no 429 appeared (failed in run #38 while the box was slammed). Fire empty-password logins instead: login checks the rate limiter BEFORE short-circuiting empty creds with a 400, so each attempt still consumes a token but skips argon2/DB and is near-instant — the burst drains the bucket far faster than the 1/sec refill regardless of load. Bucket math is already unit-tested in rate_limit.rs; this only proves the route is wired. Co-Authored-By: Claude Opus 4.8 --- backend/tests/api_auth.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/backend/tests/api_auth.rs b/backend/tests/api_auth.rs index e572b3b..2c8b15e 100644 --- a/backend/tests/api_auth.rs +++ b/backend/tests/api_auth.rs @@ -659,7 +659,8 @@ async fn login_no_user_branch_runs_argon2_for_timing_equalisation(pool: PgPool) async fn login_rate_limited_under_burst_pressure(pool: PgPool) { let h = common::harness_with_auth_rate_limit(pool, 1, 3); - // Register a victim so the wrong-password branch is real work. + // One register hit first — register and login share the one bucket, + // so this exercises the cross-endpoint limit (and consumes a token). let _ = h .app .clone() @@ -667,9 +668,18 @@ async fn login_rate_limited_under_burst_pressure(pool: PgPool) { .await .unwrap(); - // Register consumed one token from the burst-3 bucket. Fire 30 - // wrong-password logins back-to-back; with per_sec=1 the refill - // is too slow to keep up and at least one must come back 429. + // Fire 30 logins back-to-back; at least one must come back 429. + // + // We send an EMPTY password on purpose. `login` calls the rate + // limiter FIRST, then short-circuits empty credentials with a 400 + // BEFORE any argon2 hash or DB lookup — so each attempt still + // consumes a token but is near-instant. That makes the burst drain + // the bucket far faster than the per_sec=1 refill regardless of how + // loaded the CI box is. (A real wrong-password attempt runs argon2, + // ~1s under load, which lets the 1/sec refill keep exact pace with + // the loop and the bucket never empties — that was the old flake. + // The bucket math itself is covered by rate_limit.rs's unit test; + // here we only need to prove the limiter is wired into the route.) let mut saw_429 = false; for _ in 0..30 { let resp = h @@ -677,7 +687,7 @@ async fn login_rate_limited_under_burst_pressure(pool: PgPool) { .clone() .oneshot(common::post_json( "/api/v1/auth/login", - json!({ "username": "victim", "password": "wrong" }), + json!({ "username": "victim", "password": "" }), )) .await .unwrap(); -- 2.49.1