Skip to main content

max / makenotwork

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