//! Invite code creation, redemption, and limit enforcement tests. use crate::harness::TestHarness; #[tokio::test] async fn invite_create_as_creator() { let mut h = TestHarness::new().await; let user_id = h .signup("invcreator", "invcreator@test.com", "password123") .await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("invcreator", "password123").await; let resp = h.client.post_form("/api/invites/create", "").await; assert!( resp.status.is_success(), "Create invite failed: {} {}", resp.status, resp.text ); assert!( resp.text.contains("Invite code:"), "Response should contain 'Invite code:', got: {}", resp.text ); assert!( resp.text.contains("makenot.work/join?invite="), "Response should contain invite link" ); } #[tokio::test] async fn invite_non_creator_blocked() { let mut h = TestHarness::new().await; let _user_id = h .signup("invnoncreator", "invnoncreator@test.com", "password123") .await; let resp = h.client.post_form("/api/invites/create", "").await; assert!( resp.text.contains("creator access"), "Non-creator should see 'creator access' error, got: {}", resp.text ); } #[tokio::test] async fn invite_redeem_on_signup() { let mut h = TestHarness::new().await; // Creator generates an invite let creator_id = h.signup("inviter", "inviter@test.com", "password123").await; h.grant_creator(creator_id).await; h.client.post_form("/logout", "").await; h.login("inviter", "password123").await; let resp = h.client.post_form("/api/invites/create", "").await; assert!(resp.status.is_success()); // Extract the formatted code (XXXX-XXXX-XXXX) from response let text = &resp.text; let code_start = text .find("Invite code: ") .expect("should contain 'Invite code: '") + "Invite code: ".len(); let code_end = text[code_start..] .find(" ") .map_or(code_start + 14, |i| code_start + i); let formatted_code = &text[code_start..code_end]; // Log out, sign up a new user with the invite code h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; let body = format!( "username=invitee&email=invitee@test.com&password=password123&invite_code={}", urlencoding::encode(formatted_code), ); let resp = h.client.post_form("/join/step/account", &body).await; assert!( resp.status.is_success() || resp.status.is_redirection(), "Signup with invite failed: {} {}", resp.status, resp.text, ); // Verify the invite was redeemed in the DB let redeemed: Option<(uuid::Uuid,)> = sqlx::query_as( "SELECT redeemed_by_id FROM invite_codes WHERE creator_id = $1 AND redeemed_by_id IS NOT NULL" ) .bind(creator_id) .fetch_optional(&h.db) .await .unwrap(); assert!(redeemed.is_some(), "Invite code should have been redeemed"); } /// Invite codes are stored hashed, never in plaintext: a DB read of the `code` /// column must yield a SHA-256 hex digest, not the shareable code. #[tokio::test] async fn invite_code_stored_hashed() { let mut h = TestHarness::new().await; let creator_id = h.signup("invhash", "invhash@test.com", "password123").await; h.grant_creator(creator_id).await; let raw = "TESTCODE1234"; makenotwork::db::invites::create_invite_code(&h.db, creator_id, raw) .await .unwrap(); let stored: String = sqlx::query_scalar("SELECT code FROM invite_codes WHERE creator_id = $1") .bind(creator_id) .fetch_one(&h.db) .await .unwrap(); assert_ne!(stored, raw, "plaintext invite code must not be stored"); assert_eq!( stored, makenotwork::crypto::invite_code_hash(raw), "stored value must be the code hash" ); assert_eq!(stored.len(), 64, "SHA-256 hex digest is 64 chars"); // The hashed lookup still resolves the raw code. let found = makenotwork::db::invites::get_valid_invite_code(&h.db, raw) .await .unwrap(); assert!(found.is_some(), "hashed lookup should resolve the raw code"); } /// A single-use invite can never onboard two users: concurrent redemptions of /// the same code, both of which pass the validity read, resolve to exactly one /// winner via the guarded atomic UPDATE. #[tokio::test] async fn invite_redeemed_once_under_race() { let mut h = TestHarness::new().await; let creator_id = h.signup("invrace", "invrace@test.com", "password123").await; h.grant_creator(creator_id).await; let raw = "RACECODE5678"; makenotwork::db::invites::create_invite_code(&h.db, creator_id, raw) .await .unwrap(); let invite = makenotwork::db::invites::get_valid_invite_code(&h.db, raw) .await .unwrap() .expect("invite should be valid"); let user_a = h.signup("racea", "racea@test.com", "password123").await; let user_b = h.signup("raceb", "raceb@test.com", "password123").await; // Both readers saw the invite as unredeemed; only one write may win. let first = makenotwork::db::invites::redeem_invite_code(&h.db, invite.id, user_a) .await .unwrap(); let second = makenotwork::db::invites::redeem_invite_code(&h.db, invite.id, user_b) .await .unwrap(); assert!(first, "first redemption should win the claim"); assert!(!second, "second redemption of the same code must lose"); let redeemer: uuid::Uuid = sqlx::query_scalar("SELECT redeemed_by_id FROM invite_codes WHERE id = $1") .bind(invite.id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( redeemer, *user_a.as_uuid(), "the winner must be the only redeemer" ); } #[tokio::test] async fn invite_limit_enforced() { let mut h = TestHarness::new().await; let user_id = h .signup("invlimit", "invlimit@test.com", "password123") .await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("invlimit", "password123").await; // Create 5 invites (the limit) for i in 0..5 { let resp = h.client.post_form("/api/invites/create", "").await; assert!( resp.text.contains("Invite code:"), "Invite {} should succeed, got: {}", i + 1, resp.text, ); } // 6th should be rejected let resp = h.client.post_form("/api/invites/create", "").await; assert!( resp.text.contains("limit"), "6th invite should mention 'limit', got: {}", resp.text, ); }