//! DB-layer contract tests for `db::passkeys`, WebAuthn credential storage. //! //! Audit Run 16 graded `db/passkeys.rs` A- with "thin module tests": the //! ownership-scoped mutations (rename/delete return false for a non-owner) and //! the discoverable-login lookup were exercised only through the HTTP passkey //! flow. These pin the invariants directly, most importantly that rename and //! delete are user-scoped (an IDOR guard), the credential-id lookup resolves the //! owner, and the post-auth update bumps the stored counter. use crate::harness::db::TestDb; use makenotwork::db::{UserId, passkeys}; use serde_json::json; async fn seed_user(pool: &sqlx::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 create_then_find_by_credential_id_roundtrips_the_owner() { let db = TestDb::new().await; let user = seed_user(&db.pool, "pk_roundtrip").await; let cred = b"cred-roundtrip".as_slice(); passkeys::create_passkey(&db.pool, user, "Yubikey", &json!({"counter": 0}), cred) .await .unwrap(); let found = passkeys::find_user_by_credential_id(&db.pool, cred) .await .unwrap(); let (owner, stored) = found.expect("a stored credential resolves to its owner"); assert_eq!(owner, user); assert_eq!(stored, json!({"counter": 0})); // An unknown credential id resolves to nobody (no discoverable-login match). assert!( passkeys::find_user_by_credential_id(&db.pool, b"nope".as_slice()) .await .unwrap() .is_none() ); } #[tokio::test] async fn update_after_auth_bumps_the_counter_and_stamps_last_used() { let db = TestDb::new().await; let user = seed_user(&db.pool, "pk_counter").await; let cred = b"cred-counter".as_slice(); passkeys::create_passkey(&db.pool, user, "Key", &json!({"counter": 4}), cred) .await .unwrap(); // Pre-auth: last_used_at is NULL. let before = passkeys::list_passkeys(&db.pool, user).await.unwrap(); assert_eq!(before.len(), 1); assert!(before[0].last_used_at.is_none()); passkeys::update_passkey_after_auth(&db.pool, cred, &json!({"counter": 5})) .await .unwrap(); let (_, stored) = passkeys::find_user_by_credential_id(&db.pool, cred) .await .unwrap() .unwrap(); assert_eq!( stored, json!({"counter": 5}), "the signature counter is advanced" ); let after = passkeys::list_passkeys(&db.pool, user).await.unwrap(); assert!( after[0].last_used_at.is_some(), "last_used_at is stamped on auth" ); } /// The IDOR guard: one user must not be able to delete another user's passkey /// by id. `delete_passkey` scopes on `(id, user_id)`, so a non-owner's delete is /// a no-op that returns false, the credential survives. #[tokio::test] async fn delete_is_scoped_to_the_owner() { let db = TestDb::new().await; let owner = seed_user(&db.pool, "pk_owner").await; let attacker = seed_user(&db.pool, "pk_attacker").await; let pk = passkeys::create_passkey(&db.pool, owner, "Key", &json!({}), b"c-own".as_slice()) .await .unwrap(); // Attacker tries to delete the owner's passkey by id -> no-op. assert!( !passkeys::delete_passkey(&db.pool, pk, attacker) .await .unwrap(), "a non-owner delete must return false" ); assert_eq!( passkeys::count_passkeys(&db.pool, owner).await.unwrap(), 1, "the credential survives" ); // The owner can delete it. assert!(passkeys::delete_passkey(&db.pool, pk, owner).await.unwrap()); assert_eq!(passkeys::count_passkeys(&db.pool, owner).await.unwrap(), 0); } #[tokio::test] async fn rename_is_scoped_to_the_owner() { let db = TestDb::new().await; let owner = seed_user(&db.pool, "pk_rn_owner").await; let attacker = seed_user(&db.pool, "pk_rn_attacker").await; let pk = passkeys::create_passkey(&db.pool, owner, "Original", &json!({}), b"c-rn".as_slice()) .await .unwrap(); assert!( !passkeys::rename_passkey(&db.pool, pk, attacker, "Pwned") .await .unwrap(), "a non-owner rename must return false" ); let names: Vec<_> = passkeys::list_passkeys(&db.pool, owner).await.unwrap(); assert_eq!( names[0].name, "Original", "the name is unchanged by a non-owner" ); assert!( passkeys::rename_passkey(&db.pool, pk, owner, "Renamed") .await .unwrap() ); let names: Vec<_> = passkeys::list_passkeys(&db.pool, owner).await.unwrap(); assert_eq!(names[0].name, "Renamed"); } #[tokio::test] async fn credential_exclusion_list_is_per_user() { let db = TestDb::new().await; let a = seed_user(&db.pool, "pk_excl_a").await; let b = seed_user(&db.pool, "pk_excl_b").await; passkeys::create_passkey(&db.pool, a, "A1", &json!({"id": "a1"}), b"a1".as_slice()) .await .unwrap(); passkeys::create_passkey(&db.pool, a, "A2", &json!({"id": "a2"}), b"a2".as_slice()) .await .unwrap(); passkeys::create_passkey(&db.pool, b, "B1", &json!({"id": "b1"}), b"b1".as_slice()) .await .unwrap(); // The registration exclusion list must contain only the caller's credentials. let a_creds = passkeys::get_passkey_credentials(&db.pool, a) .await .unwrap(); assert_eq!(a_creds.len(), 2); assert!( a_creds .iter() .all(|c| c["id"].as_str().unwrap().starts_with('a')) ); assert_eq!(passkeys::count_passkeys(&db.pool, b).await.unwrap(), 1); }