Skip to main content

max / makenotwork

8.0 KB · 228 lines History Blame Raw
1 //! Rate limiting workflow tests.
2 //!
3 //! Verifies that tower_governor rate limiters enforce per-IP burst limits on
4 //! auth, sandbox, and API write endpoints. The TestClient sets X-Forwarded-For
5 //! on every request, and SmartIpKeyExtractor (fallback from CloudflareIpKeyExtractor)
6 //! uses that header for keying, so rate limiting works in-process.
7 //!
8 //! ## Flakiness on CI (astra)
9 //!
10 //! Under `--features fast-tests` the token bucket refills at 100/sec (burst
11 //! 20). On a fast Mac this is easy to deplete sequentially, but astra under
12 //! `--test-threads=8` + postgres contention slows per-request execution past
13 //! the refill rate, the bucket never empties and the test fails. Tests
14 //! tagged `#[cfg_attr(feature = "fast-tests", ignore)]` for that reason.
15 //!
16 //! Run them locally with:
17 //!
18 //! ```sh
19 //! TEST_DATABASE_URL="postgres:///postgres" \
20 //! cargo test --features fast-tests --test integration \
21 //! -- --ignored --test-threads=1 rate_limit
22 //! ```
23
24 use crate::harness::TestHarness;
25 use makenotwork::constants::{
26 API_WRITE_RATE_LIMIT_BURST, AUTH_RATE_LIMIT_BURST, SANDBOX_RATE_LIMIT_BURST,
27 };
28
29 // Auth rate limiting
30
31 /// Send AUTH_RATE_LIMIT_BURST + 1 login attempts rapidly and verify the last
32 /// one returns 429 Too Many Requests.
33 #[tokio::test]
34 #[cfg_attr(feature = "fast-tests", ignore)]
35 async fn auth_rate_limit_triggers_on_burst() {
36 let mut h = TestHarness::new().await;
37
38 // Use a distinct IP so we don't collide with other tests
39 h.client.set_forwarded_ip("10.0.0.1");
40
41 let mut got_429 = false;
42 // Send many rapid requests. The burst limit is AUTH_RATE_LIMIT_BURST with
43 // refill at 2/sec (per_millisecond(500)). Argon2 hashing can be slow, so
44 // we send a generous multiple of the burst to outpace refill.
45 // Login is CSRF-exempt so no CSRF token needed.
46 // Test auth rate limiting via the passkey endpoint which shares the auth
47 // rate limiter but doesn't invoke Argon2 password hashing. The login
48 // handler's ~600ms Argon2 cost per request lets the token bucket refill
49 // fast enough to prevent 429 in serial tests.
50 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
51 let resp = h
52 .client
53 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
54 .await;
55 if resp.status == 429 {
56 got_429 = true;
57 break;
58 }
59 }
60
61 assert!(
62 got_429,
63 "Expected 429 after bursting auth requests but never got one (burst={AUTH_RATE_LIMIT_BURST})",
64 );
65 }
66
67 // Retry-After header
68
69 /// After triggering a rate limit, the 429 response must include a `retry-after`
70 /// header so clients know when to retry.
71 #[tokio::test]
72 #[cfg_attr(feature = "fast-tests", ignore)]
73 async fn rate_limit_returns_retry_after_header() {
74 let mut h = TestHarness::new().await;
75 h.client.set_forwarded_ip("10.0.1.1");
76
77 let mut last_resp = None;
78 // Use passkey endpoint (fast, no Argon2) to trigger rate limit.
79 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
80 let resp = h
81 .client
82 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
83 .await;
84 if resp.status == 429 {
85 last_resp = Some(resp);
86 break;
87 }
88 }
89
90 let resp = last_resp.expect("Never got 429, cannot check retry-after header");
91 assert_eq!(resp.status, 429);
92 assert!(
93 resp.header("retry-after").is_some(),
94 "429 response should include retry-after header, headers: {:?}",
95 resp.headers
96 );
97 }
98
99 // Per-IP independence
100
101 /// Exhaust the rate limit from one IP, then verify a different IP is not
102 /// affected. Uses X-Forwarded-For to distinguish IPs.
103 #[tokio::test]
104 #[cfg_attr(feature = "fast-tests", ignore)]
105 async fn rate_limit_different_ips_independent() {
106 let mut h = TestHarness::new().await;
107
108 // Exhaust burst from IP "1.2.3.4" using passkey endpoint (fast, no Argon2)
109 h.client.set_forwarded_ip("1.2.3.4");
110 let mut ip1_got_429 = false;
111 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
112 let resp = h
113 .client
114 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
115 .await;
116 if resp.status == 429 {
117 ip1_got_429 = true;
118 break;
119 }
120 }
121 assert!(ip1_got_429, "IP 1.2.3.4 should be rate-limited");
122
123 // Switch to a fresh IP, should NOT be rate-limited
124 h.client.set_forwarded_ip("5.6.7.8");
125 let resp = h
126 .client
127 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
128 .await;
129 assert_ne!(
130 resp.status, 429,
131 "IP 5.6.7.8 should not be rate-limited (got 429)"
132 );
133 // Accept any non-429 status (likely 400 or 404)
134 }
135
136 // Sandbox rate limiting
137
138 /// Send SANDBOX_RATE_LIMIT_BURST + 1 POST /sandbox requests and verify 429.
139 /// Skipped with `fast-tests`, relaxed rate limits make this test meaningless.
140 #[tokio::test]
141 #[cfg_attr(feature = "fast-tests", ignore)]
142 async fn sandbox_rate_limit_triggers() {
143 let mut h = TestHarness::new().await;
144 h.client.set_forwarded_ip("10.0.2.1");
145
146 let mut got_429 = false;
147 // Send well beyond burst to account for token refill during slow test execution
148 for _ in 0..=(SANDBOX_RATE_LIMIT_BURST as usize + 5) {
149 // Each POST /sandbox needs a CSRF token; GET /sandbox provides one
150 let _page = h.client.get("/sandbox").await;
151 let resp = h.client.post_form("/sandbox", "").await;
152 if resp.status == 429 {
153 got_429 = true;
154 break;
155 }
156 }
157
158 assert!(
159 got_429,
160 "Expected 429 after bursting sandbox requests but never got one (burst={SANDBOX_RATE_LIMIT_BURST})"
161 );
162 }
163
164 // API write rate limiting
165
166 /// As a logged-in creator, send API_WRITE_RATE_LIMIT_BURST + 1 POST requests
167 /// to a write endpoint and verify 429.
168 #[tokio::test]
169 #[cfg_attr(feature = "fast-tests", ignore)]
170 async fn api_write_rate_limit_triggers() {
171 let mut h = TestHarness::new().await;
172 h.client.set_forwarded_ip("10.0.3.1");
173 let _user_id = h.create_creator("ratelimiter").await;
174
175 let mut got_429 = false;
176 // Send well beyond burst to account for token refill during slow test execution
177 for _ in 0..=(API_WRITE_RATE_LIMIT_BURST as usize + 15) {
178 // POST /api/projects is a write endpoint under the write rate limiter.
179 // Most requests will fail (duplicate slug, validation) but they still
180 // count toward the rate limit bucket.
181 let resp = h
182 .client
183 .post_form("/api/projects", "slug=rl-test&title=Rate+Limit+Test")
184 .await;
185 if resp.status == 429 {
186 got_429 = true;
187 break;
188 }
189 }
190
191 assert!(
192 got_429,
193 "Expected 429 after bursting API write requests but never got one (burst={API_WRITE_RATE_LIMIT_BURST})"
194 );
195 }
196
197 // Email-action routes (Run #11 Security fix)
198
199 /// All email-action routes share one per-IP auth rate limiter applied at the
200 /// router (`email_action_routes`). Run #11 found `/login-link`, `/reset-password`,
201 /// `/verify-email`, `/confirm-delete`, and `/unsubscribe` uncapped while only
202 /// `/forgot-password` was limited. This pins that the cap now fires on the
203 /// previously-uncapped routes. Ignored under fast-tests for the same
204 /// token-bucket-refill-vs-request-rate flakiness as the other rate-limit tests;
205 /// run with `--ignored --test-threads=1`.
206 #[tokio::test]
207 #[cfg_attr(feature = "fast-tests", ignore)]
208 async fn email_action_routes_are_rate_limited() {
209 let mut h = TestHarness::new().await;
210 h.client.set_forwarded_ip("10.0.7.7");
211
212 let mut got_429 = false;
213 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
214 // GET so no CSRF token is needed; the governor layer runs before the
215 // handler, so the missing/invalid token doesn't matter for this assertion.
216 let resp = h.client.get("/login-link?token=nope").await;
217 if resp.status == 429 {
218 got_429 = true;
219 break;
220 }
221 }
222
223 assert!(
224 got_429,
225 "Expected 429 after bursting /login-link (burst={AUTH_RATE_LIMIT_BURST})",
226 );
227 }
228