Skip to main content

max / makenotwork

6.9 KB · 180 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
15 .client
16 .delete("/api/users/me/sessions")
17 .await;
18 assert!(
19 resp.status.is_success(),
20 "Revoke all other sessions should succeed: {} {}",
21 resp.status, resp.text
22 );
23 }
24
25 #[tokio::test]
26 async fn revoke_all_sessions_requires_auth() {
27 let mut h = TestHarness::new().await;
28
29 // Not logged in — should be rejected
30 let resp = h
31 .client
32 .delete("/api/users/me/sessions")
33 .await;
34 assert!(
35 resp.status.is_client_error() || resp.status.is_redirection(),
36 "Unauthenticated session revocation should be rejected: {} {}",
37 resp.status, resp.text
38 );
39 }
40
41 #[tokio::test]
42 async fn revoke_nonexistent_session_succeeds_gracefully() {
43 let mut h = TestHarness::new().await;
44 let _user_id = h.signup("sessbad", "sessbad@test.com", "password123").await;
45
46 // The handler deletes 0 rows silently and re-renders the sessions page.
47 // Verify it doesn't panic or error.
48 let fake_id = uuid::Uuid::new_v4();
49 let resp = h
50 .client
51 .delete(&format!("/api/users/me/sessions/{}", fake_id))
52 .await;
53 assert!(
54 resp.status.is_success(),
55 "Nonexistent session revocation should succeed gracefully: {} {}",
56 resp.status, resp.text
57 );
58 }
59
60 // ---------------------------------------------------------------------------
61 // Cross-tenant & current-session negative paths (test-fuzz Phase 2.3)
62 //
63 // `delete_user_session` / `delete_other_sessions` are scoped to the caller's
64 // user_id by design (the documented footgun guard in db/sessions.rs). These pin
65 // that the scoping actually holds: one user cannot revoke another user's
66 // session even with that session's exact id, and "revoke others" never reaches
67 // across the user boundary or kills the caller's own current session.
68 // ---------------------------------------------------------------------------
69
70 /// Fetch the single session id the harness created for a freshly-signed-up user.
71 async fn session_id_for(h: &TestHarness, user_id: makenotwork::db::UserId) -> uuid::Uuid {
72 sqlx::query_scalar("SELECT id FROM user_sessions WHERE user_id = $1")
73 .bind(user_id)
74 .fetch_one(&h.db)
75 .await
76 .expect("signup must create a user_sessions row")
77 }
78
79 async fn session_exists(h: &TestHarness, session_id: uuid::Uuid) -> bool {
80 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_sessions WHERE id = $1)")
81 .bind(session_id)
82 .fetch_one(&h.db)
83 .await
84 .unwrap()
85 }
86
87 #[tokio::test]
88 async fn revoking_another_users_session_is_a_cross_tenant_noop() {
89 let mut h = TestHarness::new().await;
90
91 // Victim signs up; capture only their user_id (logout below would delete
92 // their login session row, so we mint a fresh, independent session row for
93 // them AFTER the login/logout churn).
94 let victim_id = h.signup("sessvictim", "sessvictim@test.com", "password123").await;
95
96 // Attacker signs up on the same client (overwrites the cookie).
97 h.client.post_form("/logout", "").await;
98 h.signup("sessattacker", "sessattacker@test.com", "password123").await;
99
100 // A live session row owned by the victim, independent of cookie state.
101 let victim_session: uuid::Uuid = sqlx::query_scalar(
102 "INSERT INTO user_sessions (user_id) VALUES ($1) RETURNING id",
103 )
104 .bind(victim_id)
105 .fetch_one(&h.db)
106 .await
107 .unwrap();
108
109 // Attacker tries to revoke the VICTIM's session by its exact id.
110 let resp = h
111 .client
112 .delete(&format!("/api/users/me/sessions/{}", victim_session))
113 .await;
114 // The endpoint returns 200 (re-renders the attacker's own session list) but
115 // the DELETE is scoped to the attacker's user_id, so it removes 0 rows.
116 assert!(resp.status.is_success(), "request itself should succeed: {} {}", resp.status, resp.text);
117
118 assert!(
119 session_exists(&h, victim_session).await,
120 "a user must NOT be able to revoke another user's session"
121 );
122 }
123
124 #[tokio::test]
125 async fn cannot_revoke_own_current_session_via_endpoint() {
126 let mut h = TestHarness::new().await;
127 let user_id = h.signup("sesscurrent", "sesscurrent@test.com", "password123").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!(resp.status.as_u16(), 400, "revoking your own current session must 400: {}", resp.text);
138 assert!(session_exists(&h, current).await, "current session must survive the rejected revoke");
139 }
140
141 #[tokio::test]
142 async fn revoke_other_sessions_preserves_current_and_ignores_other_users() {
143 let mut h = TestHarness::new().await;
144
145 // A bystander user — must be untouched. Capture only the id; mint their
146 // session row after the login/logout churn (logout would delete a real one).
147 let bystander_id = h.signup("sessbystander", "sessbystander@test.com", "password123").await;
148
149 // The actor signs up (current session) and gets a second, older session
150 // inserted directly (a second logged-in device).
151 h.client.post_form("/logout", "").await;
152 let actor_id = h.signup("sessactor", "sessactor@test.com", "password123").await;
153 let current = session_id_for(&h, actor_id).await;
154 let other: uuid::Uuid = sqlx::query_scalar(
155 "INSERT INTO user_sessions (user_id) VALUES ($1) RETURNING id",
156 )
157 .bind(actor_id)
158 .fetch_one(&h.db)
159 .await
160 .unwrap();
161 let bystander_session: uuid::Uuid = sqlx::query_scalar(
162 "INSERT INTO user_sessions (user_id) VALUES ($1) RETURNING id",
163 )
164 .bind(bystander_id)
165 .fetch_one(&h.db)
166 .await
167 .unwrap();
168
169 // "Sign out all other devices."
170 let resp = h.client.delete("/api/users/me/sessions").await;
171 assert!(resp.status.is_success(), "revoke others failed: {} {}", resp.status, resp.text);
172
173 assert!(session_exists(&h, current).await, "the caller's current session must be preserved");
174 assert!(!session_exists(&h, other).await, "the caller's other session must be revoked");
175 assert!(
176 session_exists(&h, bystander_session).await,
177 "another user's session must NOT be touched by revoke-others"
178 );
179 }
180