//! 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_eq!( resp.status, 200, "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_eq!( resp.status, 403, "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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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_eq!( resp.status, 200, "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" ); } // The revocation primitive the auth path sits on (test-fuzz Phase 2.3) // // Web auth re-checks the session every request via `touch_session` (behind a // short TTL cache), so once a session row is revoked the dangling cookie stops // authenticating as soon as the cache lapses. Driving that through HTTP is // cache-timing-dependent; this pins the primitive directly and deterministically: // a live session touches valid, a revoke deletes it (and a second revoke is an // idempotent no-op, the shape a concurrent double-revoke takes, where the loser // must not error), and a revoked session then touches invalid. #[tokio::test] async fn touch_session_and_revoke_primitive_is_idempotent() { use makenotwork::db::UserSessionId; use makenotwork::db::sessions::{delete_user_session, touch_session}; let mut h = TestHarness::new().await; let user_id = h .signup("sessprim", "sessprim@test.com", "password123") .await; let sid = UserSessionId::from(session_id_for(&h, user_id).await); // A live session touches as valid. assert!( touch_session(&h.db, sid).await.unwrap().valid, "a live session must touch as valid" ); // First revoke removes the row; the second is an idempotent no-op (a // concurrent double-revoke must not error, the loser just deletes nothing). assert!( delete_user_session(&h.db, sid, user_id).await.unwrap(), "first revoke deletes the row" ); assert!( !delete_user_session(&h.db, sid, user_id).await.unwrap(), "second revoke is an idempotent no-op" ); // A revoked session touches as invalid, the gate that ends the cookie's life // once the auth touch cache lapses. assert!( !touch_session(&h.db, sid).await.unwrap().valid, "a revoked session must touch as invalid" ); } // Per-user session cap: prune_user_sessions_over_cap bounds active-session growth, // keeping the newest `max` active sessions and never touching pending_2fa rows. #[tokio::test] async fn prune_user_sessions_over_cap_keeps_newest_and_spares_pending_2fa() { use makenotwork::db::sessions::prune_user_sessions_over_cap; let mut h = TestHarness::new().await; let user_id = h.signup("captest", "captest@test.com", "password123").await; // signup created one active session (last_active_at = now, the newest). Add // three ancient active sessions (oldest first) + one pending_2fa intermediate. let mut ancient = Vec::new(); for ts in [1i64, 2, 3] { let id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO user_sessions (user_id, kind, last_active_at) VALUES ($1, 'active', to_timestamp($2)) RETURNING id", ).bind(user_id).bind(ts).fetch_one(&h.db).await.unwrap(); ancient.push(id); } sqlx::query("INSERT INTO user_sessions (user_id, kind, last_active_at) VALUES ($1, 'pending_2fa', to_timestamp(5))") .bind(user_id).execute(&h.db).await.unwrap(); // 4 active + 1 pending_2fa. Cap to 2 active → the two oldest active are pruned. let pruned = prune_user_sessions_over_cap(&h.db, user_id, 2) .await .unwrap(); assert_eq!(pruned, 2, "the two oldest active sessions must be pruned"); let active: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM user_sessions WHERE user_id = $1 AND kind = 'active'", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(active, 2, "active sessions capped to the newest two"); let pending: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM user_sessions WHERE user_id = $1 AND kind = 'pending_2fa'", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( pending, 1, "a pending_2fa session must never be pruned by the cap" ); // The two oldest (ts=1, ts=2) were evicted; the newer ancient (ts=3) and the // signup session (now) are kept. assert!( !session_exists(&h, ancient[0]).await, "oldest active session must be pruned" ); assert!( !session_exists(&h, ancient[1]).await, "second-oldest active session must be pruned" ); assert!( session_exists(&h, ancient[2]).await, "the newer active session must survive" ); // Re-pruning at the same cap is a no-op. assert_eq!( prune_user_sessions_over_cap(&h.db, user_id, 2) .await .unwrap(), 0, "re-prune is idempotent" ); }