//! Session revocation workflow tests. //! //! Tests the ability to revoke individual sessions and all other sessions. use crate::harness::TestHarness; #[tokio::test] async fn revoke_all_other_sessions() { let mut h = TestHarness::new().await; let _user_id = h.signup("sessrev", "sessrev@test.com", "password123").await; // The user is now logged in with one session. // Revoking all other sessions should succeed (even if there are no others). let resp = h .client .delete("/api/users/me/sessions") .await; assert!( resp.status.is_success(), "Revoke all other sessions should succeed: {} {}", resp.status, resp.text ); } #[tokio::test] async fn revoke_all_sessions_requires_auth() { let mut h = TestHarness::new().await; // Not logged in — should be rejected let resp = h .client .delete("/api/users/me/sessions") .await; assert!( resp.status.is_client_error() || resp.status.is_redirection(), "Unauthenticated session revocation should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn revoke_nonexistent_session_succeeds_gracefully() { let mut h = TestHarness::new().await; let _user_id = h.signup("sessbad", "sessbad@test.com", "password123").await; // The handler deletes 0 rows silently and re-renders the sessions page. // Verify it doesn't panic or error. let fake_id = uuid::Uuid::new_v4(); let resp = h .client .delete(&format!("/api/users/me/sessions/{}", fake_id)) .await; assert!( resp.status.is_success(), "Nonexistent session revocation should succeed gracefully: {} {}", resp.status, resp.text ); } // --------------------------------------------------------------------------- // Cross-tenant & current-session negative paths (test-fuzz Phase 2.3) // // `delete_user_session` / `delete_other_sessions` are scoped to the caller's // user_id by design (the documented footgun guard in db/sessions.rs). These pin // that the scoping actually holds: one user cannot revoke another user's // session even with that session's exact id, and "revoke others" never reaches // across the user boundary or kills the caller's own current session. // --------------------------------------------------------------------------- /// Fetch the single session id the harness created for a freshly-signed-up user. async fn session_id_for(h: &TestHarness, user_id: makenotwork::db::UserId) -> uuid::Uuid { sqlx::query_scalar("SELECT id FROM user_sessions WHERE user_id = $1") .bind(user_id) .fetch_one(&h.db) .await .expect("signup must create a user_sessions row") } async fn session_exists(h: &TestHarness, session_id: uuid::Uuid) -> bool { sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_sessions WHERE id = $1)") .bind(session_id) .fetch_one(&h.db) .await .unwrap() } #[tokio::test] async fn revoking_another_users_session_is_a_cross_tenant_noop() { let mut h = TestHarness::new().await; // Victim signs up; capture only their user_id (logout below would delete // their login session row, so we mint a fresh, independent session row for // them AFTER the login/logout churn). let victim_id = h.signup("sessvictim", "sessvictim@test.com", "password123").await; // Attacker signs up on the same client (overwrites the cookie). h.client.post_form("/logout", "").await; h.signup("sessattacker", "sessattacker@test.com", "password123").await; // A live session row owned by the victim, independent of cookie state. let victim_session: uuid::Uuid = sqlx::query_scalar( "INSERT INTO user_sessions (user_id) VALUES ($1) RETURNING id", ) .bind(victim_id) .fetch_one(&h.db) .await .unwrap(); // Attacker tries to revoke the VICTIM's session by its exact id. let resp = h .client .delete(&format!("/api/users/me/sessions/{}", victim_session)) .await; // The endpoint returns 200 (re-renders the attacker's own session list) but // the DELETE is scoped to the attacker's user_id, so it removes 0 rows. assert!(resp.status.is_success(), "request itself should succeed: {} {}", resp.status, resp.text); assert!( session_exists(&h, victim_session).await, "a user must NOT be able to revoke another user's session" ); } #[tokio::test] async fn cannot_revoke_own_current_session_via_endpoint() { let mut h = TestHarness::new().await; let user_id = h.signup("sesscurrent", "sesscurrent@test.com", "password123").await; let current = session_id_for(&h, user_id).await; // The per-session revoke endpoint refuses the caller's CURRENT session — // you must use logout for that (otherwise you'd strand a live cookie with // no backing row). let resp = h .client .delete(&format!("/api/users/me/sessions/{}", current)) .await; assert_eq!(resp.status.as_u16(), 400, "revoking your own current session must 400: {}", resp.text); assert!(session_exists(&h, current).await, "current session must survive the rejected revoke"); } #[tokio::test] async fn revoke_other_sessions_preserves_current_and_ignores_other_users() { let mut h = TestHarness::new().await; // A bystander user — must be untouched. Capture only the id; mint their // session row after the login/logout churn (logout would delete a real one). let bystander_id = h.signup("sessbystander", "sessbystander@test.com", "password123").await; // The actor signs up (current session) and gets a second, older session // inserted directly (a second logged-in device). h.client.post_form("/logout", "").await; let actor_id = h.signup("sessactor", "sessactor@test.com", "password123").await; let current = session_id_for(&h, actor_id).await; let other: uuid::Uuid = sqlx::query_scalar( "INSERT INTO user_sessions (user_id) VALUES ($1) RETURNING id", ) .bind(actor_id) .fetch_one(&h.db) .await .unwrap(); let bystander_session: uuid::Uuid = sqlx::query_scalar( "INSERT INTO user_sessions (user_id) VALUES ($1) RETURNING id", ) .bind(bystander_id) .fetch_one(&h.db) .await .unwrap(); // "Sign out all other devices." let resp = h.client.delete("/api/users/me/sessions").await; assert!(resp.status.is_success(), "revoke others failed: {} {}", resp.status, resp.text); assert!(session_exists(&h, current).await, "the caller's current session must be preserved"); assert!(!session_exists(&h, other).await, "the caller's other session must be revoked"); assert!( session_exists(&h, bystander_session).await, "another user's session must NOT be touched by revoke-others" ); }