diff --git a/crates/manager-core/tests/session_absolute_ttl.rs b/crates/manager-core/tests/session_absolute_ttl.rs new file mode 100644 index 0000000..3c33341 --- /dev/null +++ b/crates/manager-core/tests/session_absolute_ttl.rs @@ -0,0 +1,117 @@ +//! The admin-session ABSOLUTE TTL cap (audit 2026-07-11 C1, migration 0070). +//! +//! `PICLOUD_SESSION_ABSOLUTE_TTL_HOURS` is the whole mitigation for a stolen-but- +//! warm admin token — the highest-value credential in the system. A session's +//! sliding `expires_at` is bumped on every use, but it is clamped at +//! `login_time + absolute_ttl`, so even a continuously-used or stolen token self- +//! expires. Nothing tested it (grepping `absolute_expires_at` across all tests +//! returned zero hits). +//! +//! The AUTHORITATIVE enforcement is the lookup filter: `admin_session_repo`'s +//! `lookup` selects `... AND absolute_expires_at > NOW()`, so a row past its cap +//! is invisible no matter how far into the future `touch` has pushed `expires_at`. +//! The middleware's `sliding.min(absolute_expires_at)` clamp is defense-in-depth +//! on top; the lookup filter is what actually expires the token, and it is what +//! these tests pin. Deleting the `absolute_expires_at > NOW()` predicate — the +//! plausible regression — makes every past-cap assertion below fail. +//! +//! Skips cleanly when `DATABASE_URL` is unset. + +use chrono::{Duration, Utc}; +use picloud_manager_core::admin_session_repo::{ + AdminSessionRepository, PostgresAdminSessionRepository, +}; +use picloud_shared::AdminUserId; +use uuid::Uuid; + +async fn mk_admin(pool: &sqlx::PgPool) -> AdminUserId { + let uniq = Uuid::new_v4().simple().to_string(); + let (id,): (Uuid,) = sqlx::query_as( + "INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id", + ) + .bind(format!("sat-{uniq}")) + .fetch_one(pool) + .await + .expect("insert admin"); + id.into() +} + +/// A session within BOTH windows resolves; one past its absolute cap does NOT — +/// even though its sliding `expires_at` is still in the future. That gap (future +/// `expires_at`, past `absolute_expires_at`) is exactly the stolen-but-warm token +/// the cap exists to kill. +#[tokio::test] +async fn a_session_past_its_absolute_cap_does_not_resolve() { + let Some(pool) = picloud_test_support::test_pool("session_absolute_ttl").await else { + return; + }; + let repo = PostgresAdminSessionRepository::new(pool.clone()); + let user = mk_admin(&pool).await; + let now = Utc::now(); + + // Warm and within its cap — resolves. + repo.create( + user, + "hash-live", + now + Duration::hours(1), + now + Duration::hours(24), + ) + .await + .expect("create live"); + let live = repo.lookup("hash-live").await.expect("lookup live"); + assert!(live.is_some(), "a session within both windows must resolve"); + assert_eq!(live.unwrap().user_id, user); + + // Sliding window still open (expires_at in the future), but the ABSOLUTE cap + // has passed. Must NOT resolve — this is the whole guarantee. + repo.create( + user, + "hash-capped", + now + Duration::hours(1), + now - Duration::minutes(1), + ) + .await + .expect("create capped"); + assert!( + repo.lookup("hash-capped") + .await + .expect("lookup capped") + .is_none(), + "a warm session (future expires_at) past its absolute cap must self-expire" + ); +} + +/// `touch` cannot resurrect a past-cap session. Even after the sliding window is +/// bumped far into the future, the lookup's absolute filter keeps the row hidden — +/// so a continuously-used stolen token cannot slide past its cap. +#[tokio::test] +async fn touch_cannot_extend_a_session_past_its_absolute_cap() { + let Some(pool) = picloud_test_support::test_pool("session_absolute_ttl").await else { + return; + }; + let repo = PostgresAdminSessionRepository::new(pool.clone()); + let user = mk_admin(&pool).await; + let now = Utc::now(); + + // Absolute cap already passed; sliding window open. + repo.create( + user, + "hash-warm", + now + Duration::hours(1), + now - Duration::minutes(1), + ) + .await + .expect("create warm"); + + // Simulate the sliding-window bump with an UNCLAMPED value a year out — i.e. + // the exact thing the middleware clamp is meant to prevent. Even so: + repo.touch("hash-warm", now + Duration::days(365)) + .await + .expect("touch"); + + assert!( + repo.lookup("hash-warm").await.expect("lookup").is_none(), + "even an unclamped touch a year out cannot resurrect a past-cap session — \ + the lookup's absolute_expires_at filter is the backstop" + ); +}