//! DB-layer contract tests for `db::synckit::security`, the audit trail and the //! sync-token revocation line. //! //! An audit log that dedupes reports a repeated attack as one attempt, and a //! revocation that misses is a stolen token that keeps working. Pinned here: //! the trail is append-only under an argument-for-argument repeat, it is scoped //! to one app, a row outlives the user it names (the subject is nulled rather //! than the row cascaded away with the account, which is what would erase the //! evidence), revoking sync tokens leaves the website session's own stamp //! alone, and a second revocation moves the line forward so a token minted //! between the two cannot survive it. //! //! `db_synckit_accounts_layer` pins the audit log's basic append and the //! cross-user token revocation, and is not repeated here. //! //! Delete this file and the trail could start deduping, cascade away with the //! account it describes, or stop advancing, none of it visible to a route //! test. use crate::harness::db::TestDb; use crate::harness::seed_user; use makenotwork::db::synckit; use makenotwork::db::{SyncAppId, UserId}; /// Seed a sync app owned by `user`. async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { sqlx::query_scalar::<_, SyncAppId>( "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, $2, $3, $4) RETURNING id", ) .bind(user) .bind(name) .bind(format!("hash_{name}")) .bind(&name[..name.len().min(8)]) .fetch_one(pool) .await .expect("seed sync app") } // ── security ──────────────────────────────────────────────────────────────── #[tokio::test] async fn the_audit_log_appends_an_identical_event_twice_and_scopes_it_to_one_app() { let db = TestDb::new().await; let user = seed_user(&db.pool, "sksecp_append").await; let app = seed_app(&db.pool, user, "secappend").await; let other_app = seed_app(&db.pool, user, "secother").await; // The same event twice, argument for argument. An audit log that deduped // would hide a repeated attack as one attempt. for _ in 0..2 { synckit::record_security_event( &db.pool, app, Some(user), synckit::sync_security_event::AUTH_FAILURE, Some(serde_json::json!({ "attempt": "same" })), Some("198.51.100.7"), ) .await .unwrap(); } synckit::record_security_event( &db.pool, other_app, Some(user), synckit::sync_security_event::KEY_ROTATION_COMPLETED, None, None, ) .await .unwrap(); let here: Vec<(String, Option)> = sqlx::query_as( "SELECT event_type, ip FROM sync_security_events WHERE app_id = $1 ORDER BY id", ) .bind(app) .fetch_all(&db.pool) .await .unwrap(); assert_eq!( here.len(), 2, "append-only: two identical events are two rows: {here:?}" ); assert!( here.iter() .all(|(e, ip)| e == "auth_failure" && ip.as_deref() == Some("198.51.100.7")), "both rows keep what was recorded: {here:?}" ); let there: Vec<(String, Option)> = sqlx::query_as( "SELECT event_type, ip FROM sync_security_events WHERE app_id = $1 ORDER BY id", ) .bind(other_app) .fetch_all(&db.pool) .await .unwrap(); assert_eq!( there.len(), 1, "another app's audit trail is its own: {there:?}" ); // The literal strings are the contract with whoever queries this table by // hand, so they are asserted as literals rather than against the constant. assert_eq!(there[0].0, "key_rotation_completed", "{there:?}"); } #[tokio::test] async fn an_audit_row_outlives_the_user_it_names() { let db = TestDb::new().await; let owner = seed_user(&db.pool, "sksecp_owner").await; let subject = seed_user(&db.pool, "sksecp_subject").await; // The app belongs to someone else, so removing the subject cannot take the // app (and its events) down by cascade. let app = seed_app(&db.pool, owner, "secoutlive").await; synckit::record_security_event( &db.pool, app, Some(subject), synckit::sync_security_event::DEVICE_REMOVED, Some(serde_json::json!({ "device": "old-laptop" })), Some("203.0.113.4"), ) .await .unwrap(); sqlx::query("DELETE FROM users WHERE id = $1") .bind(subject) .execute(&db.pool) .await .expect("remove the audited user"); let rows: Vec<(String, Option, Option)> = sqlx::query_as( "SELECT event_type, user_id, detail FROM sync_security_events WHERE app_id = $1", ) .bind(app) .fetch_all(&db.pool) .await .unwrap(); assert_eq!( rows.len(), 1, "the trail survives the account it describes: {rows:?}" ); assert_eq!(rows[0].0, "device_removed"); assert_eq!( rows[0].1, None, "the subject is nulled rather than the row deleted: {rows:?}" ); assert_eq!( rows[0].2, Some(serde_json::json!({ "device": "old-laptop" })), "and the detail an operator would investigate is still there: {rows:?}" ); } #[tokio::test] async fn revoking_sync_tokens_leaves_the_website_session_alone() { let db = TestDb::new().await; let user = seed_user(&db.pool, "sksecp_split").await; let before: ( Option>, Option>, ) = sqlx::query_as( "SELECT sync_jwt_invalidated_at, jwt_invalidated_at FROM users WHERE id = $1", ) .bind(user) .fetch_one(&db.pool) .await .unwrap(); assert_eq!( (before.0, before.1), (None, None), "a fresh user has neither stamp set" ); synckit::invalidate_user_sync_tokens(&db.pool, user) .await .unwrap(); let after: ( Option>, Option>, ) = sqlx::query_as( "SELECT sync_jwt_invalidated_at, jwt_invalidated_at FROM users WHERE id = $1", ) .bind(user) .fetch_one(&db.pool) .await .unwrap(); assert!( after.0.is_some(), "the sync sessions must be forced to re-authenticate: {after:?}" ); // The two stamps are deliberately separate: removing a sync device must // not log the creator out of the website they are working in. assert_eq!( after.1, None, "and the website session stamp must not be touched: {after:?}" ); } #[tokio::test] async fn revoking_sync_tokens_again_moves_the_stamp_forward() { let db = TestDb::new().await; let user = seed_user(&db.pool, "sksecp_forward").await; // Backdated by a day so the comparison below cannot turn on clock // resolution: the second stamp has to be later by roughly that day. sqlx::query( "UPDATE users SET sync_jwt_invalidated_at = NOW() - INTERVAL '1 day' WHERE id = $1", ) .bind(user) .execute(&db.pool) .await .unwrap(); let old: Option> = sqlx::query_scalar("SELECT sync_jwt_invalidated_at FROM users WHERE id = $1") .bind(user) .fetch_one(&db.pool) .await .unwrap(); let old = old.expect("the backdated stamp is set"); synckit::invalidate_user_sync_tokens(&db.pool, user) .await .unwrap(); let new: Option> = sqlx::query_scalar("SELECT sync_jwt_invalidated_at FROM users WHERE id = $1") .bind(user) .fetch_one(&db.pool) .await .unwrap(); let new = new.expect("the stamp is still set"); // A second revocation has to move the line forward, or a token issued // between the two revocations would survive the second one. assert!( new > old, "the revocation line must advance: {new} is not after {old}" ); }