Skip to main content

max / makenotwork

11.3 KB · 324 lines History Blame Raw
1 //! Session revocation workflow tests.
2 //!
3 //! Tests the ability to revoke individual sessions and all other sessions.
4
5 use crate::harness::TestHarness;
6
7 #[tokio::test]
8 async fn revoke_all_other_sessions() {
9 let mut h = TestHarness::new().await;
10 let _user_id = h.signup("sessrev", "sessrev@test.com", "password123").await;
11
12 // The user is now logged in with one session.
13 // Revoking all other sessions should succeed (even if there are no others).
14 let resp = h.client.delete("/api/users/me/sessions").await;
15 assert!(
16 resp.status.is_success(),
17 "Revoke all other sessions should succeed: {} {}",
18 resp.status,
19 resp.text
20 );
21 }
22
23 #[tokio::test]
24 async fn revoke_all_sessions_requires_auth() {
25 let mut h = TestHarness::new().await;
26
27 // Not logged in, should be rejected
28 let resp = h.client.delete("/api/users/me/sessions").await;
29 assert!(
30 resp.status.is_client_error() || resp.status.is_redirection(),
31 "Unauthenticated session revocation should be rejected: {} {}",
32 resp.status,
33 resp.text
34 );
35 }
36
37 #[tokio::test]
38 async fn revoke_nonexistent_session_succeeds_gracefully() {
39 let mut h = TestHarness::new().await;
40 let _user_id = h.signup("sessbad", "sessbad@test.com", "password123").await;
41
42 // The handler deletes 0 rows silently and re-renders the sessions page.
43 // Verify it doesn't panic or error.
44 let fake_id = uuid::Uuid::new_v4();
45 let resp = h
46 .client
47 .delete(&format!("/api/users/me/sessions/{fake_id}"))
48 .await;
49 assert!(
50 resp.status.is_success(),
51 "Nonexistent session revocation should succeed gracefully: {} {}",
52 resp.status,
53 resp.text
54 );
55 }
56
57 // Cross-tenant & current-session negative paths (test-fuzz Phase 2.3)
58 //
59 // `delete_user_session` / `delete_other_sessions` are scoped to the caller's
60 // user_id by design (the documented footgun guard in db/sessions.rs). These pin
61 // that the scoping actually holds: one user cannot revoke another user's
62 // session even with that session's exact id, and "revoke others" never reaches
63 // across the user boundary or kills the caller's own current session.
64
65 /// Fetch the single session id the harness created for a freshly-signed-up user.
66 async fn session_id_for(h: &TestHarness, user_id: makenotwork::db::UserId) -> uuid::Uuid {
67 sqlx::query_scalar("SELECT id FROM user_sessions WHERE user_id = $1")
68 .bind(user_id)
69 .fetch_one(&h.db)
70 .await
71 .expect("signup must create a user_sessions row")
72 }
73
74 async fn session_exists(h: &TestHarness, session_id: uuid::Uuid) -> bool {
75 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_sessions WHERE id = $1)")
76 .bind(session_id)
77 .fetch_one(&h.db)
78 .await
79 .unwrap()
80 }
81
82 #[tokio::test]
83 async fn revoking_another_users_session_is_a_cross_tenant_noop() {
84 let mut h = TestHarness::new().await;
85
86 // Victim signs up; capture only their user_id (logout below would delete
87 // their login session row, so we mint a fresh, independent session row for
88 // them AFTER the login/logout churn).
89 let victim_id = h
90 .signup("sessvictim", "sessvictim@test.com", "password123")
91 .await;
92
93 // Attacker signs up on the same client (overwrites the cookie).
94 h.client.post_form("/logout", "").await;
95 h.signup("sessattacker", "sessattacker@test.com", "password123")
96 .await;
97
98 // A live session row owned by the victim, independent of cookie state.
99 let victim_session: uuid::Uuid =
100 sqlx::query_scalar("INSERT INTO user_sessions (user_id) VALUES ($1) RETURNING id")
101 .bind(victim_id)
102 .fetch_one(&h.db)
103 .await
104 .unwrap();
105
106 // Attacker tries to revoke the VICTIM's session by its exact id.
107 let resp = h
108 .client
109 .delete(&format!("/api/users/me/sessions/{victim_session}"))
110 .await;
111 // The endpoint returns 200 (re-renders the attacker's own session list) but
112 // the DELETE is scoped to the attacker's user_id, so it removes 0 rows.
113 assert!(
114 resp.status.is_success(),
115 "request itself should succeed: {} {}",
116 resp.status,
117 resp.text
118 );
119
120 assert!(
121 session_exists(&h, victim_session).await,
122 "a user must NOT be able to revoke another user's session"
123 );
124 }
125
126 #[tokio::test]
127 async fn cannot_revoke_own_current_session_via_endpoint() {
128 let mut h = TestHarness::new().await;
129 let user_id = h
130 .signup("sesscurrent", "sesscurrent@test.com", "password123")
131 .await;
132 let current = session_id_for(&h, user_id).await;
133
134 // The per-session revoke endpoint refuses the caller's CURRENT session,
135 // you must use logout for that (otherwise you'd strand a live cookie with
136 // no backing row).
137 let resp = h
138 .client
139 .delete(&format!("/api/users/me/sessions/{current}"))
140 .await;
141 assert_eq!(
142 resp.status.as_u16(),
143 400,
144 "revoking your own current session must 400: {}",
145 resp.text
146 );
147 assert!(
148 session_exists(&h, current).await,
149 "current session must survive the rejected revoke"
150 );
151 }
152
153 #[tokio::test]
154 async fn revoke_other_sessions_preserves_current_and_ignores_other_users() {
155 let mut h = TestHarness::new().await;
156
157 // A bystander user, must be untouched. Capture only the id; mint their
158 // session row after the login/logout churn (logout would delete a real one).
159 let bystander_id = h
160 .signup("sessbystander", "sessbystander@test.com", "password123")
161 .await;
162
163 // The actor signs up (current session) and gets a second, older session
164 // inserted directly (a second logged-in device).
165 h.client.post_form("/logout", "").await;
166 let actor_id = h
167 .signup("sessactor", "sessactor@test.com", "password123")
168 .await;
169 let current = session_id_for(&h, actor_id).await;
170 let other: uuid::Uuid =
171 sqlx::query_scalar("INSERT INTO user_sessions (user_id) VALUES ($1) RETURNING id")
172 .bind(actor_id)
173 .fetch_one(&h.db)
174 .await
175 .unwrap();
176 let bystander_session: uuid::Uuid =
177 sqlx::query_scalar("INSERT INTO user_sessions (user_id) VALUES ($1) RETURNING id")
178 .bind(bystander_id)
179 .fetch_one(&h.db)
180 .await
181 .unwrap();
182
183 // "Sign out all other devices."
184 let resp = h.client.delete("/api/users/me/sessions").await;
185 assert!(
186 resp.status.is_success(),
187 "revoke others failed: {} {}",
188 resp.status,
189 resp.text
190 );
191
192 assert!(
193 session_exists(&h, current).await,
194 "the caller's current session must be preserved"
195 );
196 assert!(
197 !session_exists(&h, other).await,
198 "the caller's other session must be revoked"
199 );
200 assert!(
201 session_exists(&h, bystander_session).await,
202 "another user's session must NOT be touched by revoke-others"
203 );
204 }
205
206 // The revocation primitive the auth path sits on (test-fuzz Phase 2.3)
207 //
208 // Web auth re-checks the session every request via `touch_session` (behind a
209 // short TTL cache), so once a session row is revoked the dangling cookie stops
210 // authenticating as soon as the cache lapses. Driving that through HTTP is
211 // cache-timing-dependent; this pins the primitive directly and deterministically:
212 // a live session touches valid, a revoke deletes it (and a second revoke is an
213 // idempotent no-op, the shape a concurrent double-revoke takes, where the loser
214 // must not error), and a revoked session then touches invalid.
215
216 #[tokio::test]
217 async fn touch_session_and_revoke_primitive_is_idempotent() {
218 use makenotwork::db::UserSessionId;
219 use makenotwork::db::sessions::{delete_user_session, touch_session};
220
221 let mut h = TestHarness::new().await;
222 let user_id = h
223 .signup("sessprim", "sessprim@test.com", "password123")
224 .await;
225 let sid = UserSessionId::from(session_id_for(&h, user_id).await);
226
227 // A live session touches as valid.
228 assert!(
229 touch_session(&h.db, sid).await.unwrap().valid,
230 "a live session must touch as valid"
231 );
232
233 // First revoke removes the row; the second is an idempotent no-op (a
234 // concurrent double-revoke must not error, the loser just deletes nothing).
235 assert!(
236 delete_user_session(&h.db, sid, user_id).await.unwrap(),
237 "first revoke deletes the row"
238 );
239 assert!(
240 !delete_user_session(&h.db, sid, user_id).await.unwrap(),
241 "second revoke is an idempotent no-op"
242 );
243
244 // A revoked session touches as invalid, the gate that ends the cookie's life
245 // once the auth touch cache lapses.
246 assert!(
247 !touch_session(&h.db, sid).await.unwrap().valid,
248 "a revoked session must touch as invalid"
249 );
250 }
251
252 // Per-user session cap: prune_user_sessions_over_cap bounds active-session growth,
253 // keeping the newest `max` active sessions and never touching pending_2fa rows.
254 #[tokio::test]
255 async fn prune_user_sessions_over_cap_keeps_newest_and_spares_pending_2fa() {
256 use makenotwork::db::sessions::prune_user_sessions_over_cap;
257
258 let mut h = TestHarness::new().await;
259 let user_id = h.signup("captest", "captest@test.com", "password123").await;
260
261 // signup created one active session (last_active_at = now, the newest). Add
262 // three ancient active sessions (oldest first) + one pending_2fa intermediate.
263 let mut ancient = Vec::new();
264 for ts in [1i64, 2, 3] {
265 let id: uuid::Uuid = sqlx::query_scalar(
266 "INSERT INTO user_sessions (user_id, kind, last_active_at) VALUES ($1, 'active', to_timestamp($2)) RETURNING id",
267 ).bind(user_id).bind(ts).fetch_one(&h.db).await.unwrap();
268 ancient.push(id);
269 }
270 sqlx::query("INSERT INTO user_sessions (user_id, kind, last_active_at) VALUES ($1, 'pending_2fa', to_timestamp(5))")
271 .bind(user_id).execute(&h.db).await.unwrap();
272
273 // 4 active + 1 pending_2fa. Cap to 2 active → the two oldest active are pruned.
274 let pruned = prune_user_sessions_over_cap(&h.db, user_id, 2)
275 .await
276 .unwrap();
277 assert_eq!(pruned, 2, "the two oldest active sessions must be pruned");
278
279 let active: i64 = sqlx::query_scalar(
280 "SELECT COUNT(*) FROM user_sessions WHERE user_id = $1 AND kind = 'active'",
281 )
282 .bind(user_id)
283 .fetch_one(&h.db)
284 .await
285 .unwrap();
286 assert_eq!(active, 2, "active sessions capped to the newest two");
287
288 let pending: i64 = sqlx::query_scalar(
289 "SELECT COUNT(*) FROM user_sessions WHERE user_id = $1 AND kind = 'pending_2fa'",
290 )
291 .bind(user_id)
292 .fetch_one(&h.db)
293 .await
294 .unwrap();
295 assert_eq!(
296 pending, 1,
297 "a pending_2fa session must never be pruned by the cap"
298 );
299
300 // The two oldest (ts=1, ts=2) were evicted; the newer ancient (ts=3) and the
301 // signup session (now) are kept.
302 assert!(
303 !session_exists(&h, ancient[0]).await,
304 "oldest active session must be pruned"
305 );
306 assert!(
307 !session_exists(&h, ancient[1]).await,
308 "second-oldest active session must be pruned"
309 );
310 assert!(
311 session_exists(&h, ancient[2]).await,
312 "the newer active session must survive"
313 );
314
315 // Re-pruning at the same cap is a no-op.
316 assert_eq!(
317 prune_user_sessions_over_cap(&h.db, user_id, 2)
318 .await
319 .unwrap(),
320 0,
321 "re-prune is idempotent"
322 );
323 }
324