//! Adversarial coverage for `db::idempotency` (the POST-retry cache). //! //! Run #1 graded `db/idempotency.rs` Testing=C (zero tests). These probe the //! invariants the module is supposed to hold: (key, user, method, path) scoping, //! first-writer-wins under ON CONFLICT, and the 24-hour cleanup boundary. use crate::harness::db::TestDb; use makenotwork::db::{UserId, idempotency}; use sqlx::PgPool; /// Seed a minimal verified user and return its id (the cache FKs `users(id)`). async fn seed_user(pool: &PgPool, username: &str) -> UserId { let hash = makenotwork::auth::hash_password("password123").expect("hash"); sqlx::query_scalar::<_, UserId>( "INSERT INTO users (username, email, password_hash, email_verified) VALUES ($1, $2, $3, true) RETURNING id", ) .bind(username) .bind(format!("{username}@test.com")) .bind(&hash) .fetch_one(pool) .await .expect("seed user") } #[tokio::test] async fn store_then_get_roundtrip() { let db = TestDb::new().await; let user = seed_user(&db.pool, "idem_roundtrip").await; assert!( idempotency::get_cached_response(&db.pool, "k1", user, "POST", "/checkout") .await .expect("get miss") .is_none(), "cold key must miss" ); idempotency::store_response( &db.pool, "k1", user, "POST", "/checkout", 201, "{\"ok\":true}", ) .await .expect("store"); let hit = idempotency::get_cached_response(&db.pool, "k1", user, "POST", "/checkout") .await .expect("get hit") .expect("must hit after store"); assert_eq!(hit.status_code, 201); assert_eq!(hit.response_body, "{\"ok\":true}"); } #[tokio::test] async fn scope_isolates_key_across_user_method_path() { let db = TestDb::new().await; let alice = seed_user(&db.pool, "idem_alice").await; let bob = seed_user(&db.pool, "idem_bob").await; // Same key string, four distinct scopes, none may leak into another. idempotency::store_response(&db.pool, "shared", alice, "POST", "/a", 200, "alice-a") .await .unwrap(); idempotency::store_response(&db.pool, "shared", alice, "POST", "/b", 200, "alice-b") .await .unwrap(); idempotency::store_response( &db.pool, "shared", alice, "DELETE", "/a", 200, "alice-del-a", ) .await .unwrap(); idempotency::store_response(&db.pool, "shared", bob, "POST", "/a", 200, "bob-a") .await .unwrap(); let cases = [ (alice, "POST", "/a", "alice-a"), (alice, "POST", "/b", "alice-b"), (alice, "DELETE", "/a", "alice-del-a"), (bob, "POST", "/a", "bob-a"), ]; for (user, method, path, want) in cases { let got = idempotency::get_cached_response(&db.pool, "shared", user, method, path) .await .unwrap() .expect("each scope is stored independently"); assert_eq!(got.response_body, want, "scope {method} {path} leaked"); } // A scope nobody wrote (bob, DELETE, /a) must still miss. assert!( idempotency::get_cached_response(&db.pool, "shared", bob, "DELETE", "/a") .await .unwrap() .is_none() ); } #[tokio::test] async fn second_store_is_a_no_op_first_writer_wins() { let db = TestDb::new().await; let user = seed_user(&db.pool, "idem_firstwriter").await; idempotency::store_response(&db.pool, "k", user, "POST", "/x", 201, "first") .await .unwrap(); // ON CONFLICT DO NOTHING: a second store on the same scope must not clobber. idempotency::store_response(&db.pool, "k", user, "POST", "/x", 500, "second") .await .unwrap(); let got = idempotency::get_cached_response(&db.pool, "k", user, "POST", "/x") .await .unwrap() .unwrap(); assert_eq!(got.status_code, 201, "first writer's status must stand"); assert_eq!(got.response_body, "first", "first writer's body must stand"); } #[tokio::test] async fn concurrent_stores_keep_exactly_one_row() { let db = TestDb::new().await; let user = seed_user(&db.pool, "idem_concurrent").await; // 16 concurrent deliveries of the "same" retry, each with a distinct body. let mut handles = Vec::new(); for i in 0..16 { let pool = db.pool.clone(); handles.push(tokio::spawn(async move { idempotency::store_response( &pool, "race", user, "POST", "/checkout", 201, &format!("body-{i}"), ) .await })); } for h in handles { h.await.expect("join").expect("store ok"); } let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM idempotency_keys WHERE key = 'race' AND user_id = $1", ) .bind(user) .fetch_one(&db.pool) .await .unwrap(); assert_eq!(count, 1, "exactly one row survives the race"); // And the cached read is stable (some single winner's body). let got = idempotency::get_cached_response(&db.pool, "race", user, "POST", "/checkout") .await .unwrap() .unwrap(); assert!(got.response_body.starts_with("body-")); } #[tokio::test] async fn cleanup_expired_respects_24h_boundary() { let db = TestDb::new().await; let user = seed_user(&db.pool, "idem_cleanup").await; idempotency::store_response(&db.pool, "fresh", user, "POST", "/x", 200, "fresh") .await .unwrap(); idempotency::store_response(&db.pool, "stale", user, "POST", "/x", 200, "stale") .await .unwrap(); // Backdate the stale row just past the 24h window. sqlx::query( "UPDATE idempotency_keys SET created_at = NOW() - INTERVAL '25 hours' \ WHERE key = 'stale' AND user_id = $1", ) .bind(user) .execute(&db.pool) .await .unwrap(); let deleted = idempotency::cleanup_expired(&db.pool).await.unwrap(); assert_eq!(deleted, 1, "only the >24h row is purged"); assert!( idempotency::get_cached_response(&db.pool, "fresh", user, "POST", "/x") .await .unwrap() .is_some(), "fresh row survives cleanup" ); assert!( idempotency::get_cached_response(&db.pool, "stale", user, "POST", "/x") .await .unwrap() .is_none(), "stale row is gone" ); } #[tokio::test] async fn empty_key_is_stored_and_scoped_independently() { let db = TestDb::new().await; let user = seed_user(&db.pool, "idem_emptykey").await; // An empty key is a benign, distinct key value, it must round-trip and not // collide with a non-empty key in the same scope. idempotency::store_response(&db.pool, "", user, "POST", "/x", 202, "empty") .await .unwrap(); idempotency::store_response(&db.pool, "k", user, "POST", "/x", 200, "nonempty") .await .unwrap(); let empty = idempotency::get_cached_response(&db.pool, "", user, "POST", "/x") .await .unwrap() .unwrap(); assert_eq!(empty.response_body, "empty"); let nonempty = idempotency::get_cached_response(&db.pool, "k", user, "POST", "/x") .await .unwrap() .unwrap(); assert_eq!(nonempty.response_body, "nonempty"); }