//! DB-layer contract tests for `db::synckit::invitations`. //! //! The invite link exists to remove a two-channel exchange from onboarding, and //! the properties worth pinning are the ones that keep it from also removing the //! security the exchange provided: //! //! - a token is redeemable once, even under a race; //! - accepting records a key and grants nothing; //! - expiry, revocation and redemption each close the token; //! - the token itself is never recoverable from the table. //! //! Design: wiki synckit-groups-design. use crate::harness::db::TestDb; use crate::harness::seed_user; use chrono::{Duration, Utc}; use makenotwork::db::synckit; use makenotwork::db::{SyncAppId, SyncGroupId, UserId}; use uuid::Uuid; /// 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") } /// A group with `admin` as its admin and only member. async fn seed_group(pool: &sqlx::PgPool, app: SyncAppId, admin: UserId) -> SyncGroupId { let id = SyncGroupId::from_uuid(Uuid::new_v4()); synckit::create_group(pool, id, app, admin, "Invites", "sealed-admin", "admin-pub") .await .expect("create group"); id } /// The hash the caller would store for a token. Mirrors the route layer, which /// hashes before the value reaches the db module. fn hash(token: &str) -> String { makenotwork::crypto::sha256_hex(token) } fn in_hours(h: i64) -> chrono::DateTime { Utc::now() + Duration::hours(h) } #[tokio::test] async fn a_fresh_invitation_is_pending_and_names_nobody() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "inv_admin").await; let app = seed_app(&db.pool, admin, "inv_app").await; let group = seed_group(&db.pool, app, admin).await; let inv = synckit::create_invitation(&db.pool, group, admin, &hash("tok-a"), in_hours(24)) .await .expect("create invitation"); assert_eq!(inv.group_id, group); assert_eq!(inv.inviter_user_id, admin); // An outstanding invitation names no invitee. Anything else would mean the // admin had to know who they were inviting, which is the exchange this // removes. assert!(inv.invitee_user_id.is_none()); assert!(inv.invitee_pubkey.is_none()); assert!(inv.accepted_at.is_none()); assert!(inv.redeemed_at.is_none()); } #[tokio::test] async fn the_token_is_not_recoverable_from_the_table() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "inv_hash_admin").await; let app = seed_app(&db.pool, admin, "inv_hash_app").await; let group = seed_group(&db.pool, app, admin).await; synckit::create_invitation(&db.pool, group, admin, &hash("secret-token"), in_hours(24)) .await .expect("create invitation"); // The whole row, as text. A read of the table (or of a backup) must not hand // out something redeemable. let dumped: String = sqlx::query_scalar("SELECT string_agg(t::text, ' ') FROM sync_group_invitations t") .fetch_one(&db.pool) .await .expect("dump invitations"); assert!( !dumped.contains("secret-token"), "the plaintext token reached the database: {dumped}" ); } #[tokio::test] async fn accepting_records_the_key_and_grants_nothing() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "inv_acc_admin").await; let bob = seed_user(&db.pool, "inv_acc_bob").await; let app = seed_app(&db.pool, admin, "inv_acc_app").await; let group = seed_group(&db.pool, app, admin).await; synckit::create_invitation(&db.pool, group, admin, &hash("tok-b"), in_hours(24)) .await .expect("create invitation"); let accepted = synckit::accept_invitation(&db.pool, &hash("tok-b"), bob, "bob-pubkey") .await .expect("accept") .expect("a live token accepts"); assert_eq!(accepted.invitee_user_id, Some(bob)); assert_eq!(accepted.invitee_pubkey.as_deref(), Some("bob-pubkey")); assert!(accepted.accepted_at.is_some()); // The load-bearing assertion: acceptance is not membership. Bob holds no // grant and cannot read the group until the admin confirms his fingerprint. assert!( !synckit::is_group_member(&db.pool, group, bob) .await .expect("membership check"), "accepting an invitation must not make anyone a member" ); } #[tokio::test] async fn a_token_accepts_once_even_when_two_users_race_it() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "inv_race_admin").await; let bob = seed_user(&db.pool, "inv_race_bob").await; let carol = seed_user(&db.pool, "inv_race_carol").await; let app = seed_app(&db.pool, admin, "inv_race_app").await; let group = seed_group(&db.pool, app, admin).await; synckit::create_invitation(&db.pool, group, admin, &hash("tok-race"), in_hours(24)) .await .expect("create invitation"); // Concurrent, against the same pool: the conditional UPDATE is what makes // this safe, not any ordering the callers arrange. let token_hash = hash("tok-race"); let (first, second) = tokio::join!( synckit::accept_invitation(&db.pool, &token_hash, bob, "bob-pub"), synckit::accept_invitation(&db.pool, &token_hash, carol, "carol-pub"), ); let winners = [first.expect("bob call"), second.expect("carol call")] .into_iter() .flatten() .count(); assert_eq!(winners, 1, "a one-use token accepted twice"); } #[tokio::test] async fn an_expired_token_does_not_accept() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "inv_exp_admin").await; let bob = seed_user(&db.pool, "inv_exp_bob").await; let app = seed_app(&db.pool, admin, "inv_exp_app").await; let group = seed_group(&db.pool, app, admin).await; synckit::create_invitation(&db.pool, group, admin, &hash("tok-old"), in_hours(-1)) .await .expect("create invitation"); assert!( synckit::accept_invitation(&db.pool, &hash("tok-old"), bob, "bob-pub") .await .expect("accept call") .is_none(), "an expired link must not be redeemable" ); // Still readable, so the invitee can be told why rather than "no such link". assert!( synckit::get_invitation_by_token(&db.pool, &hash("tok-old")) .await .expect("lookup") .is_some() ); } #[tokio::test] async fn a_revoked_token_does_not_accept() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "inv_rev_admin").await; let bob = seed_user(&db.pool, "inv_rev_bob").await; let app = seed_app(&db.pool, admin, "inv_rev_app").await; let group = seed_group(&db.pool, app, admin).await; let inv = synckit::create_invitation(&db.pool, group, admin, &hash("tok-rev"), in_hours(24)) .await .expect("create invitation"); assert!( synckit::revoke_invitation(&db.pool, group, inv.id) .await .expect("revoke") ); assert!( synckit::accept_invitation(&db.pool, &hash("tok-rev"), bob, "bob-pub") .await .expect("accept call") .is_none() ); } #[tokio::test] async fn revoking_works_after_acceptance_and_blocks_redemption() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "inv_rev2_admin").await; let bob = seed_user(&db.pool, "inv_rev2_bob").await; let app = seed_app(&db.pool, admin, "inv_rev2_app").await; let group = seed_group(&db.pool, app, admin).await; let inv = synckit::create_invitation(&db.pool, group, admin, &hash("tok-nope"), in_hours(24)) .await .expect("create invitation"); synckit::accept_invitation(&db.pool, &hash("tok-nope"), bob, "bob-pub") .await .expect("accept") .expect("accepts"); // The case that matters: the admin looked at the fingerprint and did not // recognise it. Throwing the invitation away must work at that point, and it // must foreclose confirming it afterwards. assert!( synckit::revoke_invitation(&db.pool, group, inv.id) .await .expect("revoke an accepted invitation") ); assert!( !synckit::redeem_invitation(&db.pool, group, inv.id) .await .expect("redeem call"), "a revoked invitation must not be redeemable" ); } #[tokio::test] async fn redeeming_is_terminal_and_only_once() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "inv_red_admin").await; let bob = seed_user(&db.pool, "inv_red_bob").await; let app = seed_app(&db.pool, admin, "inv_red_app").await; let group = seed_group(&db.pool, app, admin).await; let inv = synckit::create_invitation(&db.pool, group, admin, &hash("tok-red"), in_hours(24)) .await .expect("create invitation"); // Not redeemable before acceptance: there is no key to seal to yet. assert!( !synckit::redeem_invitation(&db.pool, group, inv.id) .await .expect("early redeem") ); synckit::accept_invitation(&db.pool, &hash("tok-red"), bob, "bob-pub") .await .expect("accept") .expect("accepts"); assert!( synckit::redeem_invitation(&db.pool, group, inv.id) .await .expect("redeem") ); // A double-confirm is caught here rather than silently adding twice. assert!( !synckit::redeem_invitation(&db.pool, group, inv.id) .await .expect("second redeem") ); } #[tokio::test] async fn an_invitation_from_another_group_cannot_be_closed() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "inv_x_admin").await; let other = seed_user(&db.pool, "inv_x_other").await; let bob = seed_user(&db.pool, "inv_x_bob").await; let app = seed_app(&db.pool, admin, "inv_x_app").await; let group = seed_group(&db.pool, app, admin).await; let foreign = seed_group(&db.pool, app, other).await; let inv = synckit::create_invitation(&db.pool, group, admin, &hash("tok-x"), in_hours(24)) .await .expect("create invitation"); synckit::accept_invitation(&db.pool, &hash("tok-x"), bob, "bob-pub") .await .expect("accept") .expect("accepts"); // The group id is part of the predicate, so holding an id from elsewhere is // not enough to act on it. assert!( !synckit::redeem_invitation(&db.pool, foreign, inv.id) .await .expect("cross-group redeem") ); assert!( !synckit::revoke_invitation(&db.pool, foreign, inv.id) .await .expect("cross-group revoke") ); } #[tokio::test] async fn listing_shows_the_invitee_email_after_acceptance() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "inv_list_admin").await; let bob = seed_user(&db.pool, "inv_list_bob").await; let app = seed_app(&db.pool, admin, "inv_list_app").await; let group = seed_group(&db.pool, app, admin).await; synckit::create_invitation(&db.pool, group, admin, &hash("tok-l1"), in_hours(24)) .await .expect("create outstanding"); synckit::create_invitation(&db.pool, group, admin, &hash("tok-l2"), in_hours(24)) .await .expect("create second"); synckit::accept_invitation(&db.pool, &hash("tok-l2"), bob, "bob-pub") .await .expect("accept") .expect("accepts"); let list = synckit::list_invitations(&db.pool, group) .await .expect("list"); assert_eq!(list.len(), 2); let accepted = list .iter() .find(|i| i.invitee_user_id == Some(bob)) .expect("the accepted one is listed"); // The admin reads an email, not a user id, and the key is what the // fingerprint is derived from. assert!(accepted.invitee_email.is_some()); assert_eq!(accepted.invitee_pubkey.as_deref(), Some("bob-pub")); let outstanding = list .iter() .find(|i| i.invitee_user_id.is_none()) .expect("the outstanding one is listed"); assert!(outstanding.invitee_email.is_none()); } #[tokio::test] async fn one_live_acceptance_per_invitee_per_group() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "inv_dup_admin").await; let bob = seed_user(&db.pool, "inv_dup_bob").await; let app = seed_app(&db.pool, admin, "inv_dup_app").await; let group = seed_group(&db.pool, app, admin).await; for token in ["tok-d1", "tok-d2"] { synckit::create_invitation(&db.pool, group, admin, &hash(token), in_hours(24)) .await .expect("create invitation"); } synckit::accept_invitation(&db.pool, &hash("tok-d1"), bob, "bob-pub") .await .expect("first accept") .expect("accepts"); // Two live acceptances by the same person would put them in the admin's queue // twice, and confirming both would add them twice. The partial unique index // refuses it, surfacing as an error rather than a duplicate row. assert!( synckit::accept_invitation(&db.pool, &hash("tok-d2"), bob, "bob-pub") .await .is_err(), "the same invitee accepted two live invitations to one group" ); }