Skip to main content

max / makenotwork

6.6 KB · 207 lines History Blame Raw
1 //! Invite code creation, redemption, and limit enforcement tests.
2
3 use crate::harness::TestHarness;
4
5 #[tokio::test]
6 async fn invite_create_as_creator() {
7 let mut h = TestHarness::new().await;
8 let user_id = h
9 .signup("invcreator", "invcreator@test.com", "password123")
10 .await;
11 h.grant_creator(user_id).await;
12 h.client.post_form("/logout", "").await;
13 h.login("invcreator", "password123").await;
14
15 let resp = h.client.post_form("/api/invites/create", "").await;
16 assert!(
17 resp.status.is_success(),
18 "Create invite failed: {} {}",
19 resp.status,
20 resp.text
21 );
22 assert!(
23 resp.text.contains("Invite code:"),
24 "Response should contain 'Invite code:', got: {}",
25 resp.text
26 );
27 assert!(
28 resp.text.contains("makenot.work/join?invite="),
29 "Response should contain invite link"
30 );
31 }
32
33 #[tokio::test]
34 async fn invite_non_creator_blocked() {
35 let mut h = TestHarness::new().await;
36 let _user_id = h
37 .signup("invnoncreator", "invnoncreator@test.com", "password123")
38 .await;
39
40 let resp = h.client.post_form("/api/invites/create", "").await;
41 assert!(
42 resp.text.contains("creator access"),
43 "Non-creator should see 'creator access' error, got: {}",
44 resp.text
45 );
46 }
47
48 #[tokio::test]
49 async fn invite_redeem_on_signup() {
50 let mut h = TestHarness::new().await;
51
52 // Creator generates an invite
53 let creator_id = h.signup("inviter", "inviter@test.com", "password123").await;
54 h.grant_creator(creator_id).await;
55 h.client.post_form("/logout", "").await;
56 h.login("inviter", "password123").await;
57
58 let resp = h.client.post_form("/api/invites/create", "").await;
59 assert!(resp.status.is_success());
60
61 // Extract the formatted code (XXXX-XXXX-XXXX) from response
62 let text = &resp.text;
63 let code_start = text
64 .find("Invite code: ")
65 .expect("should contain 'Invite code: '")
66 + "Invite code: ".len();
67 let code_end = text[code_start..]
68 .find(" ")
69 .map_or(code_start + 14, |i| code_start + i);
70 let formatted_code = &text[code_start..code_end];
71
72 // Log out, sign up a new user with the invite code
73 h.client.post_form("/logout", "").await;
74 h.client.fetch_csrf_token().await;
75 let body = format!(
76 "username=invitee&email=invitee@test.com&password=password123&invite_code={}",
77 urlencoding::encode(formatted_code),
78 );
79 let resp = h.client.post_form("/join/step/account", &body).await;
80 assert!(
81 resp.status.is_success() || resp.status.is_redirection(),
82 "Signup with invite failed: {} {}",
83 resp.status,
84 resp.text,
85 );
86
87 // Verify the invite was redeemed in the DB
88 let redeemed: Option<(uuid::Uuid,)> = sqlx::query_as(
89 "SELECT redeemed_by_id FROM invite_codes WHERE creator_id = $1 AND redeemed_by_id IS NOT NULL"
90 )
91 .bind(creator_id)
92 .fetch_optional(&h.db)
93 .await
94 .unwrap();
95
96 assert!(redeemed.is_some(), "Invite code should have been redeemed");
97 }
98
99 /// Invite codes are stored hashed, never in plaintext: a DB read of the `code`
100 /// column must yield a SHA-256 hex digest, not the shareable code.
101 #[tokio::test]
102 async fn invite_code_stored_hashed() {
103 let mut h = TestHarness::new().await;
104 let creator_id = h.signup("invhash", "invhash@test.com", "password123").await;
105 h.grant_creator(creator_id).await;
106
107 let raw = "TESTCODE1234";
108 makenotwork::db::invites::create_invite_code(&h.db, creator_id, raw)
109 .await
110 .unwrap();
111
112 let stored: String = sqlx::query_scalar("SELECT code FROM invite_codes WHERE creator_id = $1")
113 .bind(creator_id)
114 .fetch_one(&h.db)
115 .await
116 .unwrap();
117
118 assert_ne!(stored, raw, "plaintext invite code must not be stored");
119 assert_eq!(
120 stored,
121 makenotwork::crypto::invite_code_hash(raw),
122 "stored value must be the code hash"
123 );
124 assert_eq!(stored.len(), 64, "SHA-256 hex digest is 64 chars");
125
126 // The hashed lookup still resolves the raw code.
127 let found = makenotwork::db::invites::get_valid_invite_code(&h.db, raw)
128 .await
129 .unwrap();
130 assert!(found.is_some(), "hashed lookup should resolve the raw code");
131 }
132
133 /// A single-use invite can never onboard two users: concurrent redemptions of
134 /// the same code, both of which pass the validity read, resolve to exactly one
135 /// winner via the guarded atomic UPDATE.
136 #[tokio::test]
137 async fn invite_redeemed_once_under_race() {
138 let mut h = TestHarness::new().await;
139 let creator_id = h.signup("invrace", "invrace@test.com", "password123").await;
140 h.grant_creator(creator_id).await;
141
142 let raw = "RACECODE5678";
143 makenotwork::db::invites::create_invite_code(&h.db, creator_id, raw)
144 .await
145 .unwrap();
146 let invite = makenotwork::db::invites::get_valid_invite_code(&h.db, raw)
147 .await
148 .unwrap()
149 .expect("invite should be valid");
150
151 let user_a = h.signup("racea", "racea@test.com", "password123").await;
152 let user_b = h.signup("raceb", "raceb@test.com", "password123").await;
153
154 // Both readers saw the invite as unredeemed; only one write may win.
155 let first = makenotwork::db::invites::redeem_invite_code(&h.db, invite.id, user_a)
156 .await
157 .unwrap();
158 let second = makenotwork::db::invites::redeem_invite_code(&h.db, invite.id, user_b)
159 .await
160 .unwrap();
161
162 assert!(first, "first redemption should win the claim");
163 assert!(!second, "second redemption of the same code must lose");
164
165 let redeemer: uuid::Uuid =
166 sqlx::query_scalar("SELECT redeemed_by_id FROM invite_codes WHERE id = $1")
167 .bind(invite.id)
168 .fetch_one(&h.db)
169 .await
170 .unwrap();
171 assert_eq!(
172 redeemer,
173 *user_a.as_uuid(),
174 "the winner must be the only redeemer"
175 );
176 }
177
178 #[tokio::test]
179 async fn invite_limit_enforced() {
180 let mut h = TestHarness::new().await;
181 let user_id = h
182 .signup("invlimit", "invlimit@test.com", "password123")
183 .await;
184 h.grant_creator(user_id).await;
185 h.client.post_form("/logout", "").await;
186 h.login("invlimit", "password123").await;
187
188 // Create 5 invites (the limit)
189 for i in 0..5 {
190 let resp = h.client.post_form("/api/invites/create", "").await;
191 assert!(
192 resp.text.contains("Invite code:"),
193 "Invite {} should succeed, got: {}",
194 i + 1,
195 resp.text,
196 );
197 }
198
199 // 6th should be rejected
200 let resp = h.client.post_form("/api/invites/create", "").await;
201 assert!(
202 resp.text.contains("limit"),
203 "6th invite should mention 'limit', got: {}",
204 resp.text,
205 );
206 }
207