Skip to main content

max / makenotwork

13.4 KB · 382 lines History Blame Raw
1 //! DB-layer contract tests for `db::synckit::invitations`.
2 //!
3 //! The invite link exists to remove a two-channel exchange from onboarding, and
4 //! the properties worth pinning are the ones that keep it from also removing the
5 //! security the exchange provided:
6 //!
7 //! - a token is redeemable once, even under a race;
8 //! - accepting records a key and grants nothing;
9 //! - expiry, revocation and redemption each close the token;
10 //! - the token itself is never recoverable from the table.
11 //!
12 //! Design: wiki synckit-groups-design.
13
14 use crate::harness::db::TestDb;
15 use crate::harness::seed_user;
16 use chrono::{Duration, Utc};
17 use makenotwork::db::synckit;
18 use makenotwork::db::{SyncAppId, SyncGroupId, UserId};
19 use uuid::Uuid;
20
21 /// Seed a sync app owned by `user`.
22 async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
23 sqlx::query_scalar::<_, SyncAppId>(
24 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix)
25 VALUES ($1, $2, $3, $4) RETURNING id",
26 )
27 .bind(user)
28 .bind(name)
29 .bind(format!("hash_{name}"))
30 .bind(&name[..name.len().min(8)])
31 .fetch_one(pool)
32 .await
33 .expect("seed sync app")
34 }
35
36 /// A group with `admin` as its admin and only member.
37 async fn seed_group(pool: &sqlx::PgPool, app: SyncAppId, admin: UserId) -> SyncGroupId {
38 let id = SyncGroupId::from_uuid(Uuid::new_v4());
39 synckit::create_group(pool, id, app, admin, "Invites", "sealed-admin", "admin-pub")
40 .await
41 .expect("create group");
42 id
43 }
44
45 /// The hash the caller would store for a token. Mirrors the route layer, which
46 /// hashes before the value reaches the db module.
47 fn hash(token: &str) -> String {
48 makenotwork::crypto::sha256_hex(token)
49 }
50
51 fn in_hours(h: i64) -> chrono::DateTime<Utc> {
52 Utc::now() + Duration::hours(h)
53 }
54
55 #[tokio::test]
56 async fn a_fresh_invitation_is_pending_and_names_nobody() {
57 let db = TestDb::new().await;
58 let admin = seed_user(&db.pool, "inv_admin").await;
59 let app = seed_app(&db.pool, admin, "inv_app").await;
60 let group = seed_group(&db.pool, app, admin).await;
61
62 let inv = synckit::create_invitation(&db.pool, group, admin, &hash("tok-a"), in_hours(24))
63 .await
64 .expect("create invitation");
65
66 assert_eq!(inv.group_id, group);
67 assert_eq!(inv.inviter_user_id, admin);
68 // An outstanding invitation names no invitee. Anything else would mean the
69 // admin had to know who they were inviting, which is the exchange this
70 // removes.
71 assert!(inv.invitee_user_id.is_none());
72 assert!(inv.invitee_pubkey.is_none());
73 assert!(inv.accepted_at.is_none());
74 assert!(inv.redeemed_at.is_none());
75 }
76
77 #[tokio::test]
78 async fn the_token_is_not_recoverable_from_the_table() {
79 let db = TestDb::new().await;
80 let admin = seed_user(&db.pool, "inv_hash_admin").await;
81 let app = seed_app(&db.pool, admin, "inv_hash_app").await;
82 let group = seed_group(&db.pool, app, admin).await;
83
84 synckit::create_invitation(&db.pool, group, admin, &hash("secret-token"), in_hours(24))
85 .await
86 .expect("create invitation");
87
88 // The whole row, as text. A read of the table (or of a backup) must not hand
89 // out something redeemable.
90 let dumped: String =
91 sqlx::query_scalar("SELECT string_agg(t::text, ' ') FROM sync_group_invitations t")
92 .fetch_one(&db.pool)
93 .await
94 .expect("dump invitations");
95 assert!(
96 !dumped.contains("secret-token"),
97 "the plaintext token reached the database: {dumped}"
98 );
99 }
100
101 #[tokio::test]
102 async fn accepting_records_the_key_and_grants_nothing() {
103 let db = TestDb::new().await;
104 let admin = seed_user(&db.pool, "inv_acc_admin").await;
105 let bob = seed_user(&db.pool, "inv_acc_bob").await;
106 let app = seed_app(&db.pool, admin, "inv_acc_app").await;
107 let group = seed_group(&db.pool, app, admin).await;
108
109 synckit::create_invitation(&db.pool, group, admin, &hash("tok-b"), in_hours(24))
110 .await
111 .expect("create invitation");
112
113 let accepted = synckit::accept_invitation(&db.pool, &hash("tok-b"), bob, "bob-pubkey")
114 .await
115 .expect("accept")
116 .expect("a live token accepts");
117
118 assert_eq!(accepted.invitee_user_id, Some(bob));
119 assert_eq!(accepted.invitee_pubkey.as_deref(), Some("bob-pubkey"));
120 assert!(accepted.accepted_at.is_some());
121
122 // The load-bearing assertion: acceptance is not membership. Bob holds no
123 // grant and cannot read the group until the admin confirms his fingerprint.
124 assert!(
125 !synckit::is_group_member(&db.pool, group, bob)
126 .await
127 .expect("membership check"),
128 "accepting an invitation must not make anyone a member"
129 );
130 }
131
132 #[tokio::test]
133 async fn a_token_accepts_once_even_when_two_users_race_it() {
134 let db = TestDb::new().await;
135 let admin = seed_user(&db.pool, "inv_race_admin").await;
136 let bob = seed_user(&db.pool, "inv_race_bob").await;
137 let carol = seed_user(&db.pool, "inv_race_carol").await;
138 let app = seed_app(&db.pool, admin, "inv_race_app").await;
139 let group = seed_group(&db.pool, app, admin).await;
140
141 synckit::create_invitation(&db.pool, group, admin, &hash("tok-race"), in_hours(24))
142 .await
143 .expect("create invitation");
144
145 // Concurrent, against the same pool: the conditional UPDATE is what makes
146 // this safe, not any ordering the callers arrange.
147 let token_hash = hash("tok-race");
148 let (first, second) = tokio::join!(
149 synckit::accept_invitation(&db.pool, &token_hash, bob, "bob-pub"),
150 synckit::accept_invitation(&db.pool, &token_hash, carol, "carol-pub"),
151 );
152 let winners = [first.expect("bob call"), second.expect("carol call")]
153 .into_iter()
154 .flatten()
155 .count();
156 assert_eq!(winners, 1, "a one-use token accepted twice");
157 }
158
159 #[tokio::test]
160 async fn an_expired_token_does_not_accept() {
161 let db = TestDb::new().await;
162 let admin = seed_user(&db.pool, "inv_exp_admin").await;
163 let bob = seed_user(&db.pool, "inv_exp_bob").await;
164 let app = seed_app(&db.pool, admin, "inv_exp_app").await;
165 let group = seed_group(&db.pool, app, admin).await;
166
167 synckit::create_invitation(&db.pool, group, admin, &hash("tok-old"), in_hours(-1))
168 .await
169 .expect("create invitation");
170
171 assert!(
172 synckit::accept_invitation(&db.pool, &hash("tok-old"), bob, "bob-pub")
173 .await
174 .expect("accept call")
175 .is_none(),
176 "an expired link must not be redeemable"
177 );
178 // Still readable, so the invitee can be told why rather than "no such link".
179 assert!(
180 synckit::get_invitation_by_token(&db.pool, &hash("tok-old"))
181 .await
182 .expect("lookup")
183 .is_some()
184 );
185 }
186
187 #[tokio::test]
188 async fn a_revoked_token_does_not_accept() {
189 let db = TestDb::new().await;
190 let admin = seed_user(&db.pool, "inv_rev_admin").await;
191 let bob = seed_user(&db.pool, "inv_rev_bob").await;
192 let app = seed_app(&db.pool, admin, "inv_rev_app").await;
193 let group = seed_group(&db.pool, app, admin).await;
194
195 let inv = synckit::create_invitation(&db.pool, group, admin, &hash("tok-rev"), in_hours(24))
196 .await
197 .expect("create invitation");
198 assert!(
199 synckit::revoke_invitation(&db.pool, group, inv.id)
200 .await
201 .expect("revoke")
202 );
203
204 assert!(
205 synckit::accept_invitation(&db.pool, &hash("tok-rev"), bob, "bob-pub")
206 .await
207 .expect("accept call")
208 .is_none()
209 );
210 }
211
212 #[tokio::test]
213 async fn revoking_works_after_acceptance_and_blocks_redemption() {
214 let db = TestDb::new().await;
215 let admin = seed_user(&db.pool, "inv_rev2_admin").await;
216 let bob = seed_user(&db.pool, "inv_rev2_bob").await;
217 let app = seed_app(&db.pool, admin, "inv_rev2_app").await;
218 let group = seed_group(&db.pool, app, admin).await;
219
220 let inv = synckit::create_invitation(&db.pool, group, admin, &hash("tok-nope"), in_hours(24))
221 .await
222 .expect("create invitation");
223 synckit::accept_invitation(&db.pool, &hash("tok-nope"), bob, "bob-pub")
224 .await
225 .expect("accept")
226 .expect("accepts");
227
228 // The case that matters: the admin looked at the fingerprint and did not
229 // recognise it. Throwing the invitation away must work at that point, and it
230 // must foreclose confirming it afterwards.
231 assert!(
232 synckit::revoke_invitation(&db.pool, group, inv.id)
233 .await
234 .expect("revoke an accepted invitation")
235 );
236 assert!(
237 !synckit::redeem_invitation(&db.pool, group, inv.id)
238 .await
239 .expect("redeem call"),
240 "a revoked invitation must not be redeemable"
241 );
242 }
243
244 #[tokio::test]
245 async fn redeeming_is_terminal_and_only_once() {
246 let db = TestDb::new().await;
247 let admin = seed_user(&db.pool, "inv_red_admin").await;
248 let bob = seed_user(&db.pool, "inv_red_bob").await;
249 let app = seed_app(&db.pool, admin, "inv_red_app").await;
250 let group = seed_group(&db.pool, app, admin).await;
251
252 let inv = synckit::create_invitation(&db.pool, group, admin, &hash("tok-red"), in_hours(24))
253 .await
254 .expect("create invitation");
255
256 // Not redeemable before acceptance: there is no key to seal to yet.
257 assert!(
258 !synckit::redeem_invitation(&db.pool, group, inv.id)
259 .await
260 .expect("early redeem")
261 );
262
263 synckit::accept_invitation(&db.pool, &hash("tok-red"), bob, "bob-pub")
264 .await
265 .expect("accept")
266 .expect("accepts");
267
268 assert!(
269 synckit::redeem_invitation(&db.pool, group, inv.id)
270 .await
271 .expect("redeem")
272 );
273 // A double-confirm is caught here rather than silently adding twice.
274 assert!(
275 !synckit::redeem_invitation(&db.pool, group, inv.id)
276 .await
277 .expect("second redeem")
278 );
279 }
280
281 #[tokio::test]
282 async fn an_invitation_from_another_group_cannot_be_closed() {
283 let db = TestDb::new().await;
284 let admin = seed_user(&db.pool, "inv_x_admin").await;
285 let other = seed_user(&db.pool, "inv_x_other").await;
286 let bob = seed_user(&db.pool, "inv_x_bob").await;
287 let app = seed_app(&db.pool, admin, "inv_x_app").await;
288 let group = seed_group(&db.pool, app, admin).await;
289 let foreign = seed_group(&db.pool, app, other).await;
290
291 let inv = synckit::create_invitation(&db.pool, group, admin, &hash("tok-x"), in_hours(24))
292 .await
293 .expect("create invitation");
294 synckit::accept_invitation(&db.pool, &hash("tok-x"), bob, "bob-pub")
295 .await
296 .expect("accept")
297 .expect("accepts");
298
299 // The group id is part of the predicate, so holding an id from elsewhere is
300 // not enough to act on it.
301 assert!(
302 !synckit::redeem_invitation(&db.pool, foreign, inv.id)
303 .await
304 .expect("cross-group redeem")
305 );
306 assert!(
307 !synckit::revoke_invitation(&db.pool, foreign, inv.id)
308 .await
309 .expect("cross-group revoke")
310 );
311 }
312
313 #[tokio::test]
314 async fn listing_shows_the_invitee_email_after_acceptance() {
315 let db = TestDb::new().await;
316 let admin = seed_user(&db.pool, "inv_list_admin").await;
317 let bob = seed_user(&db.pool, "inv_list_bob").await;
318 let app = seed_app(&db.pool, admin, "inv_list_app").await;
319 let group = seed_group(&db.pool, app, admin).await;
320
321 synckit::create_invitation(&db.pool, group, admin, &hash("tok-l1"), in_hours(24))
322 .await
323 .expect("create outstanding");
324 synckit::create_invitation(&db.pool, group, admin, &hash("tok-l2"), in_hours(24))
325 .await
326 .expect("create second");
327 synckit::accept_invitation(&db.pool, &hash("tok-l2"), bob, "bob-pub")
328 .await
329 .expect("accept")
330 .expect("accepts");
331
332 let list = synckit::list_invitations(&db.pool, group)
333 .await
334 .expect("list");
335 assert_eq!(list.len(), 2);
336
337 let accepted = list
338 .iter()
339 .find(|i| i.invitee_user_id == Some(bob))
340 .expect("the accepted one is listed");
341 // The admin reads an email, not a user id, and the key is what the
342 // fingerprint is derived from.
343 assert!(accepted.invitee_email.is_some());
344 assert_eq!(accepted.invitee_pubkey.as_deref(), Some("bob-pub"));
345
346 let outstanding = list
347 .iter()
348 .find(|i| i.invitee_user_id.is_none())
349 .expect("the outstanding one is listed");
350 assert!(outstanding.invitee_email.is_none());
351 }
352
353 #[tokio::test]
354 async fn one_live_acceptance_per_invitee_per_group() {
355 let db = TestDb::new().await;
356 let admin = seed_user(&db.pool, "inv_dup_admin").await;
357 let bob = seed_user(&db.pool, "inv_dup_bob").await;
358 let app = seed_app(&db.pool, admin, "inv_dup_app").await;
359 let group = seed_group(&db.pool, app, admin).await;
360
361 for token in ["tok-d1", "tok-d2"] {
362 synckit::create_invitation(&db.pool, group, admin, &hash(token), in_hours(24))
363 .await
364 .expect("create invitation");
365 }
366
367 synckit::accept_invitation(&db.pool, &hash("tok-d1"), bob, "bob-pub")
368 .await
369 .expect("first accept")
370 .expect("accepts");
371
372 // Two live acceptances by the same person would put them in the admin's queue
373 // twice, and confirming both would add them twice. The partial unique index
374 // refuses it, surfacing as an error rather than a duplicate row.
375 assert!(
376 synckit::accept_invitation(&db.pool, &hash("tok-d2"), bob, "bob-pub")
377 .await
378 .is_err(),
379 "the same invitee accepted two live invitations to one group"
380 );
381 }
382