Fix auth rate limiter hitting GET /login, add unique test IPs
GET /login was incorrectly rate-limited because route_layer applied
the governor to both GET and POST on /login. Move rate limiter to
per-handler .layer() so only POST /login, passkey start, and passkey
finish are rate-limited. Also assign unique IPs per TestClient via
atomic counter to prevent cross-test rate limit interference. Fix
UUID type in sandbox subscription test.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3 files changed,
+19 insertions,
-10 deletions
| 2 |
2 |
|
|
| 3 |
3 |
|
use axum::{
|
| 4 |
4 |
|
extract::State,
|
|
5 |
+ |
handler::Handler,
|
| 5 |
6 |
|
http::{header::HeaderMap, StatusCode},
|
| 6 |
7 |
|
response::{Html, IntoResponse, Redirect, Response},
|
| 7 |
8 |
|
routing::{get, post},
|
| 35 |
36 |
|
let validate_rate_limit = rate_limiter_per_sec(constants::VALIDATE_RATE_LIMIT_PER_SEC, constants::VALIDATE_RATE_LIMIT_BURST);
|
| 36 |
37 |
|
|
| 37 |
38 |
|
Router::new()
|
| 38 |
|
- |
// Auth routes with rate limiting
|
| 39 |
|
- |
.route("/login", post(login_handler))
|
| 40 |
|
- |
.route("/auth/passkey/start", post(passkey_auth_start))
|
| 41 |
|
- |
.route("/auth/passkey/finish", post(passkey_auth_finish))
|
| 42 |
|
- |
.route_layer(GovernorLayer {
|
| 43 |
|
- |
config: auth_rate_limit,
|
| 44 |
|
- |
})
|
|
39 |
+ |
// GET /login is NOT rate-limited (page render for CSRF tokens).
|
|
40 |
+ |
// POST /login and passkey routes ARE rate-limited.
|
|
41 |
+ |
.route("/login", get(crate::routes::pages::public::landing::login_page)
|
|
42 |
+ |
.post(login_handler.layer(GovernorLayer { config: auth_rate_limit.clone() })))
|
|
43 |
+ |
.route("/auth/passkey/start", post(passkey_auth_start)
|
|
44 |
+ |
.layer(GovernorLayer { config: auth_rate_limit.clone() }))
|
|
45 |
+ |
.route("/auth/passkey/finish", post(passkey_auth_finish)
|
|
46 |
+ |
.layer(GovernorLayer { config: auth_rate_limit }))
|
| 45 |
47 |
|
// Routes without auth rate limiting
|
| 46 |
48 |
|
.route("/logout", post(logout_handler))
|
| 47 |
49 |
|
.route("/auth/me", get(me_handler))
|
| 8 |
8 |
|
use axum::Router;
|
| 9 |
9 |
|
use http_body_util::BodyExt;
|
| 10 |
10 |
|
use std::collections::HashMap;
|
|
11 |
+ |
use std::sync::atomic::{AtomicU32, Ordering};
|
| 11 |
12 |
|
use tower::ServiceExt;
|
| 12 |
13 |
|
|
|
14 |
+ |
/// Monotonic counter for unique per-test IPs (10.1.x.y).
|
|
15 |
+ |
static IP_COUNTER: AtomicU32 = AtomicU32::new(1);
|
|
16 |
+ |
|
| 13 |
17 |
|
/// A test HTTP client that talks to the app in-process.
|
| 14 |
18 |
|
pub struct TestClient {
|
| 15 |
19 |
|
app: Router,
|
| 21 |
25 |
|
|
| 22 |
26 |
|
impl TestClient {
|
| 23 |
27 |
|
pub fn new(app: Router) -> Self {
|
|
28 |
+ |
let n = IP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
29 |
+ |
let octet3 = (n / 256) % 256;
|
|
30 |
+ |
let octet4 = n % 256;
|
| 24 |
31 |
|
TestClient {
|
| 25 |
32 |
|
app,
|
| 26 |
33 |
|
cookies: HashMap::new(),
|
| 27 |
34 |
|
csrf_token: None,
|
| 28 |
|
- |
forwarded_ip: "127.0.0.1".to_string(),
|
|
35 |
+ |
forwarded_ip: format!("10.1.{}.{}", octet3, octet4),
|
| 29 |
36 |
|
bearer_token: None,
|
| 30 |
37 |
|
}
|
| 31 |
38 |
|
}
|
| 496 |
496 |
|
h.client.post_form("/logout", "").await;
|
| 497 |
497 |
|
|
| 498 |
498 |
|
// ── Visit the public project page as anonymous user ──
|
| 499 |
|
- |
let resp = h.client.get("/@tiervis/tier-vis").await;
|
|
499 |
+ |
let resp = h.client.get("/p/tier-vis").await;
|
| 500 |
500 |
|
assert_eq!(resp.status, 200, "Public project page should render, got {}", resp.status);
|
| 501 |
501 |
|
// The project page template should include the tier name
|
| 502 |
502 |
|
assert!(
|
| 522 |
522 |
|
h.client.fetch_csrf_token().await;
|
| 523 |
523 |
|
|
| 524 |
524 |
|
// Find the sandbox user
|
| 525 |
|
- |
let (sandbox_user_id, is_sandbox): (i32, bool) = sqlx::query_as(
|
|
525 |
+ |
let (sandbox_user_id, is_sandbox): (uuid::Uuid, bool) = sqlx::query_as(
|
| 526 |
526 |
|
"SELECT id, is_sandbox FROM users WHERE username LIKE 'sandbox_%' ORDER BY created_at DESC LIMIT 1",
|
| 527 |
527 |
|
)
|
| 528 |
528 |
|
.fetch_one(&h.db)
|