Skip to main content

max / makenotwork

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