Skip to main content

max / makenotwork

cover the last four money and data files; the ratchet reaches its floor of 2 routes::stripe::webhook is the v1 dispatcher, as distinct from the handlers its siblings cover. Its own decisions are the event lock, the dedup read, the failure queue and the settlement gate. The lock was unreachable from a test, because both existing suites retry through its 503 as timing noise; the new test takes the same advisory lock itself, so the contended path is reached on purpose and the losing delivery has to write nothing. routes::storage::images: a cover is presigned to a staging key that names nobody, so one pending_uploads lookup is the whole authorization for "confirm this key". A stranger's in-flight key must be refused and must not reach the deletion queue. Measuring that found the gate runs before the Run #6 idempotency check and a successful confirm consumes the row, so an ordinary retry is refused at the gate and never reaches that branch; the branch covers the narrower window where the row update committed and the cleanup did not. Both are tested, as two guards reaching one requirement. routes::synckit::sync had two endpoints with no coverage at all, one of which re-prices a live Stripe subscription: bounds before the provider is touched, Stripe before the database, and raise-now versus lower-at-renewal. routes::synckit::groups has thirteen handlers, three one-line guards, and exactly one handler had its permission checked anywhere. The new file walks the matrix rather than the features. UNTESTED_HIGH_WATER is 2, which is the floor: routes/stripe/mod.rs and db/synckit/mod.rs declare no behaviour a test could observe. Excluding them by rule was declined, since the seal's value is that its rule is one sentence long. The doc comment now says so, so 2 is not a to-do. Closes mnw-server d965ba57.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-01 00:47 UTC
Signed with PGP, not checked
Commit: fde8b6b9281ad98489a2f613d6783061be59a75f
Parent: 4239540
6 files changed, +1510 insertions, -1 deletion
@@ -33,7 +33,21 @@
33 33 /// Lower it when you cover one. Never raise it: a new untested file in these
34 34 /// areas is the thing this seal exists to refuse. If you genuinely need to add
35 35 /// one, the honest move is to write the test, not to bump the constant.
36 - const UNTESTED_HIGH_WATER: usize = 6;
36 + ///
37 + /// # 2 is the floor, and it is not a to-do
38 + ///
39 + /// The two files left are `routes/stripe/mod.rs` and `db/synckit/mod.rs`. Both
40 + /// are declaration-only: `mod` and `pub use`, plus a route table. Neither holds
41 + /// behaviour a test could observe that is not already observed by testing the
42 + /// thing it declares, and a test written to move this number would be theatre
43 + /// inside a seal whose only value is that it is dumb and honest.
44 + ///
45 + /// Excluding them by rule was considered and declined. The rule is one sentence
46 + /// long ("how many files in these areas contain no test"), and a second concept
47 + /// bolted on to spare two files costs more than it saves; a floor of 2 that says
48 + /// why is more honest than a rule with a carve-out in it. So: do not drive this
49 + /// to 0, and do not treat 2 as work outstanding.
50 + const UNTESTED_HIGH_WATER: usize = 2;
37 51
38 52 /// Anything that moves money or decides what someone is entitled to.
39 53 const MONEY: &[&str] = &[
@@ -128,6 +128,7 @@
128 128 mod ssh_management;
129 129 mod sso;
130 130 mod storage;
131 + mod storage_image_routes;
131 132 mod storage_routes_workflows;
132 133 mod streaming;
133 134 mod stripe_checkout_return_routes;
@@ -136,6 +137,7 @@
136 137 mod stripe_disconnect;
137 138 mod stripe_tip_checkout_routes;
138 139 mod stripe_webhook_billing_replay;
140 + mod stripe_webhook_dispatch;
139 141 mod stripe_webhook_exactly_once;
140 142 mod stripe_webhook_v2_routes;
141 143 mod stripe_webhooks;
@@ -146,6 +148,7 @@
146 148 mod synckit_billing;
147 149 mod synckit_blob_multipart;
148 150 mod synckit_group_rotation;
151 + mod synckit_group_routes;
149 152 mod synckit_groups_billing;
150 153 mod synckit_paid_sync;
151 154 mod synckit_per_key_storage;
@@ -153,6 +156,7 @@
153 156 mod synckit_security;
154 157 mod synckit_selective;
155 158 mod synckit_sse;
159 + mod synckit_sync_routes;
156 160 mod tags;
157 161 mod tier_enforcement;
158 162 mod totp;
@@ -1,0 +1,470 @@
1 + //! Route-layer contract tests for `routes::storage::images`, the four handlers
2 + //! behind a project cover and an item cover.
3 + //!
4 + //! Five suites touch these routes in passing (`storage`, `creator_media`,
5 + //! `scanning`, `video`, `tier_enforcement`) and none of them is their contract
6 + //! test: between them they check that a cover upload works, that it does not
7 + //! flip the shared `items.scan_status`, and that the item route writes
8 + //! `cover_image_url` as well as the key. What nobody checks is the part of these
9 + //! handlers that exists to stop them destroying a file.
10 + //!
11 + //! A cover is presigned to a `staging/{uuid}` key, and a staging key carries no
12 + //! user, project or item in its path. There is nothing to do a prefix check
13 + //! against, so ownership of the key is proved entirely by the `pending_uploads`
14 + //! row written at presign. That single lookup is the only thing standing between
15 + //! "confirm this key" and one creator handing another creator's in-flight upload
16 + //! to the deletion queue, and it is deliberately placed before the size-reject
17 + //! path for exactly that reason.
18 + //!
19 + //! The other two guards here are both scar tissue with a run number on them.
20 + //! Re-confirming the key a project already displays must never queue the live
21 + //! object for deletion (Run #6), and the path that returns early must still
22 + //! clear the pending row or the orphan reaper deletes the live object a day
23 + //! later instead (Run #7).
24 + //!
25 + //! Measuring that turned up something worth writing down: the `pending_uploads`
26 + //! gate runs BEFORE the idempotency check, and a successful confirm consumes the
27 + //! row. So an ordinary retry (a dropped response, a double-click) is refused at
28 + //! the gate with a 400 and never reaches the idempotency branch at all. The
29 + //! branch is not dead code, it covers the narrower window where the row update
30 + //! committed and the pending-row cleanup did not, and it is reachable exactly
31 + //! then. Both are tested below, because they are two different guards arriving
32 + //! at the same requirement: whatever happens, the image on display survives.
33 + //!
34 + //! Replacement is the third: the old key is enqueued for deletion inside the same
35 + //! transaction as the row update, and the storage counter moves by the delta
36 + //! rather than by the sum, so a creator who replaces a cover ten times is charged
37 + //! for one.
38 + //!
39 + //! Delete this file and a stranger could aim the deletion queue at somebody
40 + //! else's upload, a retried confirm could delete the cover it was confirming, and
41 + //! replacing an image could bill for both copies.
42 +
43 + use crate::harness::TestHarness;
44 + use serde_json::{Value, json};
45 +
46 + /// A trusted creator with a project and an item, on a tier with room to upload.
47 + async fn creator_with_project(h: &mut TestHarness, username: &str) -> (String, String) {
48 + let setup = h.create_creator_with_item(username, "audio", 0).await;
49 + h.trust_user(setup.user_id).await;
50 + h.grant_tier(setup.user_id, "small_files").await;
51 + (setup.project_id, setup.item_id)
52 + }
53 +
54 + /// Presign a project cover and return the staging key.
55 + async fn presign_project_cover(h: &mut TestHarness, project_id: &str, file_name: &str) -> String {
56 + let body = json!({
57 + "project_id": project_id,
58 + "file_name": file_name,
59 + "content_type": "image/png",
60 + });
61 + let resp = h
62 + .client
63 + .post_json("/api/projects/image/presign", &body.to_string())
64 + .await;
65 + assert_eq!(resp.status.as_u16(), 200, "presign failed: {}", resp.text);
66 + let data: Value = resp.json();
67 + data["s3_key"]
68 + .as_str()
69 + .expect("presign returns an s3_key")
70 + .to_string()
71 + }
72 +
73 + /// Put bytes at the key, as the browser's PUT to the presigned URL would.
74 + fn upload_bytes(h: &TestHarness, key: &str, size: usize) {
75 + h.storage
76 + .as_ref()
77 + .expect("with_storage provides an in-memory bucket")
78 + .put(key, vec![b'x'; size]);
79 + }
80 +
81 + async fn confirm_project_cover(
82 + h: &mut TestHarness,
83 + project_id: &str,
84 + key: &str,
85 + ) -> crate::harness::client::TestResponse {
86 + let body = json!({ "project_id": project_id, "s3_key": key });
87 + h.client
88 + .post_json("/api/projects/image/confirm", &body.to_string())
89 + .await
90 + }
91 +
92 + async fn deletions_for(h: &TestHarness, key: &str) -> i64 {
93 + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pending_s3_deletions WHERE s3_key = $1")
94 + .bind(key)
95 + .fetch_one(&h.db)
96 + .await
97 + .expect("count queued deletions")
98 + }
99 +
100 + async fn pending_upload_rows(h: &TestHarness, key: &str) -> i64 {
101 + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pending_uploads WHERE s3_key = $1")
102 + .bind(key)
103 + .fetch_one(&h.db)
104 + .await
105 + .expect("count pending uploads")
106 + }
107 +
108 + async fn storage_used(h: &TestHarness, username: &str) -> i64 {
109 + sqlx::query_scalar::<_, i64>("SELECT storage_used_bytes FROM users WHERE username = $1")
110 + .bind(username)
111 + .fetch_one(&h.db)
112 + .await
113 + .expect("read storage counter")
114 + }
115 +
116 + async fn cover_url(h: &TestHarness, project_id: &str) -> Option<String> {
117 + sqlx::query_scalar::<_, Option<String>>(
118 + "SELECT cover_image_url FROM projects WHERE id = $1::uuid",
119 + )
120 + .bind(project_id)
121 + .fetch_one(&h.db)
122 + .await
123 + .expect("read project cover url")
124 + }
125 +
126 + /// The staging key is unguessable in practice but not secret, and it names
127 + /// nobody, so the `pending_uploads` lookup is the entire authorization. A
128 + /// creator confirming a key minted for someone else's upload must be refused,
129 + /// and, because the gate sits before the size-reject path, the object they
130 + /// pointed at must not be queued for deletion on the way out.
131 + ///
132 + /// This is the difference between a rejected request and a creator losing an
133 + /// upload because a stranger typed its key.
134 + #[tokio::test]
135 + async fn confirming_a_key_minted_for_another_creator_is_refused_and_deletes_nothing() {
136 + let mut h = TestHarness::with_storage().await;
137 +
138 + let (victim_project, _) = creator_with_project(&mut h, "coverowner").await;
139 + let victim_key = presign_project_cover(&mut h, &victim_project, "mine.png").await;
140 + upload_bytes(&h, &victim_key, 2048);
141 + h.client.post_form("/logout", "").await;
142 +
143 + let (thief_project, _) = creator_with_project(&mut h, "coverthief").await;
144 + let resp = confirm_project_cover(&mut h, &thief_project, &victim_key).await;
145 +
146 + assert_eq!(
147 + resp.status.as_u16(),
148 + 400,
149 + "a key with no pending_uploads row for this user is not confirmable: {}",
150 + resp.text
151 + );
152 + assert_eq!(
153 + deletions_for(&h, &victim_key).await,
154 + 0,
155 + "the refusal must happen before the size-reject path, or a stranger can \
156 + aim the deletion queue at an in-flight upload"
157 + );
158 + assert_eq!(
159 + pending_upload_rows(&h, &victim_key).await,
160 + 1,
161 + "and the owner's claim on the key is untouched"
162 + );
163 + assert_eq!(
164 + cover_url(&h, &thief_project).await,
165 + None,
166 + "nothing was written to the caller's own project either"
167 + );
168 + }
169 +
170 + /// The ordinary retry: a dropped response, a double-click, a client that resends.
171 + /// The key has already been consumed, so the `pending_uploads` gate refuses it
172 + /// before the idempotency branch is consulted.
173 + ///
174 + /// The status is the least interesting assertion here. What Run #6 was about is
175 + /// the second one: the object the request names is the one the project is
176 + /// currently displaying, and a refusal that queued it for deletion would delete
177 + /// the live cover on a retry that changed nothing.
178 + #[tokio::test]
179 + async fn a_retried_confirm_is_refused_without_touching_the_live_cover() {
180 + let mut h = TestHarness::with_storage().await;
181 + let (project_id, _) = creator_with_project(&mut h, "coverretry").await;
182 +
183 + let key = presign_project_cover(&mut h, &project_id, "cover.png").await;
184 + upload_bytes(&h, &key, 4096);
185 + let first = confirm_project_cover(&mut h, &project_id, &key).await;
186 + assert_eq!(first.status.as_u16(), 200, "first confirm: {}", first.text);
187 + let url = cover_url(&h, &project_id)
188 + .await
189 + .expect("the cover url is written");
190 + let charged = storage_used(&h, "coverretry").await;
191 +
192 + assert_eq!(
193 + pending_upload_rows(&h, &key).await,
194 + 0,
195 + "a successful confirm consumes the key's claim"
196 + );
197 +
198 + let again = confirm_project_cover(&mut h, &project_id, &key).await;
199 + assert_eq!(
200 + again.status.as_u16(),
201 + 400,
202 + "the key is spent, so the gate refuses before anything else runs: {}",
203 + again.text
204 + );
205 + assert_eq!(
206 + deletions_for(&h, &key).await,
207 + 0,
208 + "and the object the project is displaying is not queued for deletion (Run #6)"
209 + );
210 + assert_eq!(
211 + cover_url(&h, &project_id).await.as_deref(),
212 + Some(url.as_str()),
213 + "the project still shows the same image"
214 + );
215 + assert_eq!(
216 + storage_used(&h, "coverretry").await,
217 + charged,
218 + "and is still charged once"
219 + );
220 + }
221 +
222 + /// The window the idempotency branch actually serves: the transaction committed
223 + /// the new cover, and the pending-row cleanup after it did not run. The row and
224 + /// the live URL both name the same key, which is a state no ordinary request can
225 + /// produce, so it is set up directly.
226 + ///
227 + /// A confirm arriving then must recognise the key as the one already on display
228 + /// and return it, rather than treating it as a replacement, which would charge
229 + /// storage a second time and queue the live object for deletion as the "old"
230 + /// one. It must also clear the pending row on the way out, or the orphan reaper
231 + /// deletes that object 24 hours later (Run #7).
232 + #[tokio::test]
233 + async fn a_confirm_that_lands_on_the_cover_already_shown_returns_it_and_deletes_nothing() {
234 + let mut h = TestHarness::with_storage().await;
235 + let (project_id, _) = creator_with_project(&mut h, "covercrash").await;
236 +
237 + let key = presign_project_cover(&mut h, &project_id, "cover.png").await;
238 + upload_bytes(&h, &key, 4096);
239 + let first = confirm_project_cover(&mut h, &project_id, &key).await;
240 + assert_eq!(first.status.as_u16(), 200, "first confirm: {}", first.text);
241 + let url = cover_url(&h, &project_id)
242 + .await
243 + .expect("the cover url is written");
244 + let charged = storage_used(&h, "covercrash").await;
245 +
246 + // Re-create the state a crash between the commit and the cleanup leaves.
247 + let user_id: makenotwork::db::UserId =
248 + sqlx::query_scalar("SELECT id FROM users WHERE username = 'covercrash'")
249 + .fetch_one(&h.db)
250 + .await
251 + .expect("read the creator id");
252 + sqlx::query("INSERT INTO pending_uploads (user_id, s3_key, bucket) VALUES ($1, $2, 'main')")
253 + .bind(user_id)
254 + .bind(&key)
255 + .execute(&h.db)
256 + .await
257 + .expect("restore the pending row a crash would have left");
258 +
259 + let again = confirm_project_cover(&mut h, &project_id, &key).await;
260 + assert_eq!(
261 + again.status.as_u16(),
262 + 200,
263 + "the key already on display is confirmed, not re-applied: {}",
264 + again.text
265 + );
266 + let body: Value = again.json();
267 + assert_eq!(
268 + body["image_url"].as_str(),
269 + Some(url.as_str()),
270 + "and the answer is the URL the project already has"
271 + );
272 +
273 + assert_eq!(
274 + deletions_for(&h, &key).await,
275 + 0,
276 + "the live object must not be queued as the replaced one (Run #6)"
277 + );
278 + assert_eq!(
279 + pending_upload_rows(&h, &key).await,
280 + 0,
281 + "and the stale row is cleared, or the reaper deletes the live cover (Run #7)"
282 + );
283 + assert_eq!(
284 + storage_used(&h, "covercrash").await,
285 + charged,
286 + "one image on display, charged once"
287 + );
288 + }
289 +
290 + /// Replacing a cover charges the difference between the two files, not their
291 + /// sum, and hands the old key to the deletion queue. A creator iterating on
292 + /// artwork does it many times; billing the sum would exhaust their quota with
293 + /// one image on display.
294 + #[tokio::test]
295 + async fn replacing_a_cover_charges_the_delta_and_queues_the_old_key() {
296 + let mut h = TestHarness::with_storage().await;
297 + let (project_id, _) = creator_with_project(&mut h, "coverswap").await;
298 +
299 + let first_key = presign_project_cover(&mut h, &project_id, "first.png").await;
300 + upload_bytes(&h, &first_key, 4000);
301 + let resp = confirm_project_cover(&mut h, &project_id, &first_key).await;
302 + assert_eq!(resp.status.as_u16(), 200, "first confirm: {}", resp.text);
303 + let after_first = storage_used(&h, "coverswap").await;
304 + assert_eq!(after_first, 4000, "the first image is charged in full");
305 +
306 + let second_key = presign_project_cover(&mut h, &project_id, "second.png").await;
307 + upload_bytes(&h, &second_key, 6000);
308 + let resp = confirm_project_cover(&mut h, &project_id, &second_key).await;
309 + assert_eq!(resp.status.as_u16(), 200, "replace confirm: {}", resp.text);
310 +
311 + assert_eq!(
312 + storage_used(&h, "coverswap").await,
313 + 6000,
314 + "a replacement charges the delta: one image on display, one image billed"
315 + );
316 + assert!(
317 + deletions_for(&h, &first_key).await > 0,
318 + "the replaced object is queued for deletion rather than left to the reaper"
319 + );
320 + assert!(
321 + cover_url(&h, &project_id)
322 + .await
323 + .is_some_and(|u| u.contains(&second_key)),
324 + "and the project displays the new image"
325 + );
326 + }
327 +
328 + /// Ownership of the target, checked on both halves of the flow. Presign is the
329 + /// half that matters most: it is what mints the `pending_uploads` row the
330 + /// confirm gate reads, so a stranger who could presign against someone else's
331 + /// project would hold a key the confirm gate then accepts.
332 + #[tokio::test]
333 + async fn a_strangers_project_is_refused_at_presign_and_at_confirm() {
334 + let mut h = TestHarness::with_storage().await;
335 +
336 + let (owned_project, _) = creator_with_project(&mut h, "coverwall").await;
337 + h.client.post_form("/logout", "").await;
338 + let (own_project, _) = creator_with_project(&mut h, "coverintruder").await;
339 +
340 + let body = json!({
341 + "project_id": owned_project,
342 + "file_name": "theirs.png",
343 + "content_type": "image/png",
344 + });
345 + let resp = h
346 + .client
347 + .post_json("/api/projects/image/presign", &body.to_string())
348 + .await;
349 + assert_eq!(
350 + resp.status.as_u16(),
351 + 403,
352 + "a stranger cannot mint an upload slot against another creator's project: {}",
353 + resp.text
354 + );
355 +
356 + // A key of the intruder's own, aimed at the project they do not own.
357 + let own_key = presign_project_cover(&mut h, &own_project, "ok.png").await;
358 + upload_bytes(&h, &own_key, 1024);
359 + let resp = confirm_project_cover(&mut h, &owned_project, &own_key).await;
360 + assert_eq!(
361 + resp.status.as_u16(),
362 + 403,
363 + "nor confirm into it with a key they legitimately own: {}",
364 + resp.text
365 + );
366 + assert_eq!(
367 + cover_url(&h, &owned_project).await,
368 + None,
369 + "the target project is untouched"
370 + );
371 + }
372 +
373 + /// A project id that resolves to nothing is a 404, distinct from the 403 a real
374 + /// project belonging to someone else gets. Collapsing the two would turn the
375 + /// endpoint into an oracle for which project ids exist.
376 + #[tokio::test]
377 + async fn an_unknown_project_is_not_found_rather_than_forbidden() {
378 + let mut h = TestHarness::with_storage().await;
379 + creator_with_project(&mut h, "covermissing").await;
380 +
381 + let body = json!({
382 + "project_id": "ce9f7087-d503-4cf1-8f80-b2080508e5fe",
383 + "file_name": "ghost.png",
384 + "content_type": "image/png",
385 + });
386 + let resp = h
387 + .client
388 + .post_json("/api/projects/image/presign", &body.to_string())
389 + .await;
390 +
391 + assert_eq!(
392 + resp.status.as_u16(),
393 + 404,
394 + "an id that matches no project is not found: {}",
395 + resp.text
396 + );
397 + }
398 +
399 + /// Both image routes accept only cover-shaped uploads, and the content type is
400 + /// signed into the presigned URL. A type outside that set must be refused before
401 + /// a URL exists, on the item route as well as the project one.
402 + #[tokio::test]
403 + async fn the_image_routes_refuse_a_non_image_upload() {
404 + let mut h = TestHarness::with_storage().await;
405 + let (project_id, item_id) = creator_with_project(&mut h, "coverwrongtype").await;
406 +
407 + let body = json!({
408 + "project_id": project_id,
409 + "file_name": "cover.png",
410 + "content_type": "audio/mpeg",
411 + });
412 + let resp = h
413 + .client
414 + .post_json("/api/projects/image/presign", &body.to_string())
415 + .await;
416 + assert_eq!(
417 + resp.status.as_u16(),
418 + 400,
419 + "audio is not a project cover: {}",
420 + resp.text
421 + );
422 +
423 + let body = json!({
424 + "item_id": item_id,
425 + "file_name": "cover.mp3",
426 + "content_type": "image/png",
427 + });
428 + let resp = h
429 + .client
430 + .post_json("/api/items/image/presign", &body.to_string())
431 + .await;
432 + assert_eq!(
433 + resp.status.as_u16(),
434 + 400,
435 + "the extension has to agree with the declared image type: {}",
436 + resp.text
437 + );
438 + }
439 +
440 + /// The item half carries the same staging-key gate as the project half, and it
441 + /// is a separate code path with its own copy of the lookup. A regression that
442 + /// removed one would leave the other passing.
443 + #[tokio::test]
444 + async fn the_item_route_also_refuses_a_key_it_did_not_mint() {
445 + let mut h = TestHarness::with_storage().await;
446 +
447 + let (project_id, _) = creator_with_project(&mut h, "itemcoverowner").await;
448 + let victim_key = presign_project_cover(&mut h, &project_id, "mine.png").await;
449 + upload_bytes(&h, &victim_key, 2048);
450 + h.client.post_form("/logout", "").await;
451 +
452 + let (_, thief_item) = creator_with_project(&mut h, "itemcoverthief").await;
453 + let body = json!({ "item_id": thief_item, "s3_key": victim_key });
454 + let resp = h
455 + .client
456 + .post_json("/api/items/image/confirm", &body.to_string())
457 + .await;
458 +
459 + assert_eq!(
460 + resp.status.as_u16(),
461 + 400,
462 + "the item route proves key ownership the same way: {}",
463 + resp.text
464 + );
465 + assert_eq!(
466 + deletions_for(&h, &victim_key).await,
467 + 0,
468 + "and refuses before anything can be queued for deletion"
469 + );
470 + }
@@ -1,0 +1,350 @@
1 + //! Route-layer contract tests for `routes::stripe::webhook`, the v1 dispatcher:
2 + //! the envelope handling every Stripe event passes through before any handler
3 + //! sees it.
4 + //!
5 + //! Its siblings cover what the handlers do. `stripe_webhook_exactly_once` owns
6 + //! the checkout half of the exactly-once protocol, `stripe_webhook_billing_replay`
7 + //! the invoice half, and `stripe_webhooks` drives one delivery of each event type
8 + //! through to its effect. What none of them assert is the envelope's own
9 + //! behaviour, which is where an event is lost rather than mishandled.
10 + //!
11 + //! Four things only this file pins.
12 + //!
13 + //! The event lock. A second delivery arriving while the first is mid-flight is
14 + //! answered 503 rather than parked on a pooled connection, and the sibling
15 + //! suites treat that 503 as timing noise to retry through. Here it is the
16 + //! subject: the lock is taken by the test, so the contended path is reached on
17 + //! purpose instead of by luck, and the delivery that loses must write nothing at
18 + //! all.
19 + //!
20 + //! The failure path. A handler error ACKs 200 (the local retry queue owns
21 + //! redelivery from that point) while leaving the event *unmarked*, so both the
22 + //! queue worker and a Stripe redelivery still re-run it. `stripe_webhooks` checks
23 + //! the queue row; the unmarked half is the one that decides whether a retry can
24 + //! ever happen, and nothing checked it.
25 + //!
26 + //! The unhandled arm. `MnwEvent::Unhandled` must be acknowledged and marked, not
27 + //! queued: Stripe sends event types MNW never asked for, and treating one as a
28 + //! failure would fill the retry queue with work that can never succeed.
29 + //!
30 + //! The settlement gate's other side. `dispatch_checkout_session` defers a
31 + //! funds-capturing checkout until it settles; the subscription-mode kinds must
32 + //! NOT be deferred, since they capture nothing at checkout and the subscription
33 + //! bills separately. `stripe_webhook_exactly_once` pins the deferral. A gate
34 + //! that deferred everything would pass that test and silently stop every Fan+
35 + //! and creator-tier signup whose provider reported `unpaid`.
36 +
37 + use crate::harness::TestHarness;
38 + use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload};
39 + use makenotwork::db::UserId;
40 +
41 + /// Sign an event envelope and POST it, without the 503 retry loop the sibling
42 + /// suites use: this file is asserting on that status, so it must not swallow it.
43 + async fn post_once(
44 + h: &mut TestHarness,
45 + event_id: &str,
46 + event_type: &str,
47 + object: serde_json::Value,
48 + ) -> crate::harness::client::TestResponse {
49 + let payload = serde_json::json!({
50 + "id": event_id,
51 + "type": event_type,
52 + "data": {"object": object},
53 + })
54 + .to_string();
55 + let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET);
56 + h.client
57 + .request_with_headers(
58 + "POST",
59 + "/stripe/webhook",
60 + Some(&payload),
61 + &[
62 + ("stripe-signature", signature.as_str()),
63 + ("content-type", "application/json"),
64 + ],
65 + )
66 + .await
67 + }
68 +
69 + /// Retry through the transient 503 the way Stripe does, for the deliveries a
70 + /// test wants to succeed rather than to observe.
71 + async fn post_until_settled(
72 + h: &mut TestHarness,
73 + event_id: &str,
74 + event_type: &str,
75 + object: serde_json::Value,
76 + ) -> crate::harness::client::TestResponse {
77 + for _ in 0..200 {
78 + let resp = post_once(h, event_id, event_type, object.clone()).await;
79 + if resp.status != 503 {
80 + return resp;
81 + }
82 + tokio::time::sleep(std::time::Duration::from_millis(10)).await;
83 + }
84 + panic!("{event_id}: the webhook route answered 503 for two seconds");
85 + }
86 +
87 + async fn processed(h: &TestHarness, event_id: &str) -> bool {
88 + sqlx::query_scalar::<_, i64>(
89 + "SELECT COUNT(*) FROM processed_webhook_events WHERE event_id = $1",
90 + )
91 + .bind(event_id)
92 + .fetch_one(&h.db)
93 + .await
94 + .expect("count processed markers")
95 + > 0
96 + }
97 +
98 + async fn queued(h: &TestHarness) -> i64 {
99 + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webhook_events WHERE source = 'stripe'")
100 + .fetch_one(&h.db)
101 + .await
102 + .expect("count queued retries")
103 + }
104 +
105 + /// A checkout session with no `checkout_type`, which routes to the purchase
106 + /// handler, and no `buyer_id`, which that handler requires. The cheapest way to
107 + /// make a handler fail for reasons the envelope has to cope with.
108 + fn unroutable_purchase(session_id: &str, seller_id: UserId) -> serde_json::Value {
109 + serde_json::json!({
110 + "id": session_id,
111 + "object": "checkout_session",
112 + "mode": "payment",
113 + "payment_status": "paid",
114 + "metadata": {"seller_id": seller_id.to_string()},
115 + "payment_intent": format!("pi_{session_id}"),
116 + })
117 + }
118 +
119 + /// The contended path, reached deliberately. The test holds the same advisory
120 + /// lock the handler takes, so the delivery below is guaranteed to lose it.
121 + ///
122 + /// Two halves. The loser must be told to redeliver rather than made to wait on a
123 + /// pooled connection, and it must leave nothing behind: a 503 that had already
124 + /// marked the event would turn a transient collision into a permanently skipped
125 + /// event. Then, with the lock released, the same delivery must go through, which
126 + /// is what makes the 503 a deferral rather than a refusal.
127 + #[tokio::test]
128 + async fn a_delivery_that_loses_the_event_lock_is_deferred_and_writes_nothing() {
129 + let mut h = TestHarness::with_stripe().await;
130 + let event_id = "evt_dispatch_contended";
131 +
132 + // The lock transaction borrows the pool it came from, so it takes its own
133 + // handle rather than `h.db`: the harness client needs `&mut h` while the
134 + // lock is still held, which is the whole point of the test.
135 + let pool = h.db.clone();
136 + let held = makenotwork::db::webhook_events::try_lock_event(&pool, event_id)
137 + .await
138 + .expect("take the event lock")
139 + .expect("the lock is free before the test takes it");
140 +
141 + let resp = post_once(
142 + &mut h,
143 + event_id,
144 + "payment_intent.created",
145 + serde_json::json!({"id": "pi_dispatch_contended"}),
146 + )
147 + .await;
148 + assert_eq!(
149 + resp.status.as_u16(),
150 + 503,
151 + "a delivery that loses the lock is told to come back: {}",
152 + resp.text
153 + );
154 + assert!(
155 + !processed(&h, event_id).await,
156 + "a deferred delivery must not mark the event, or the redelivery it just \
157 + asked for would be skipped"
158 + );
159 + assert_eq!(
160 + queued(&h).await,
161 + 0,
162 + "a deferred delivery is not a failed one and does not enter the retry queue"
163 + );
164 +
165 + // Releasing the lock is the whole difference; nothing else about the
166 + // delivery changes.
167 + held.rollback().await.expect("release the event lock");
168 +
169 + let resp = post_until_settled(
170 + &mut h,
171 + event_id,
172 + "payment_intent.created",
173 + serde_json::json!({"id": "pi_dispatch_contended"}),
174 + )
175 + .await;
176 + assert_eq!(
177 + resp.status.as_u16(),
178 + 200,
179 + "the same delivery goes through once the lock is free: {}",
180 + resp.text
181 + );
182 + assert!(
183 + processed(&h, event_id).await,
184 + "and is marked, so the next redelivery short-circuits"
185 + );
186 + }
187 +
188 + /// A handler error is ACKed so Stripe stops its own redelivery schedule, and the
189 + /// event is queued locally instead. The event must NOT be marked: the whole
190 + /// point of the queue is that the work still has to happen, and a marked event
191 + /// short-circuits both the queue worker's re-run and any Stripe redelivery.
192 + #[tokio::test]
193 + async fn a_failed_handler_is_queued_and_deliberately_left_unmarked() {
194 + let mut h = TestHarness::with_stripe().await;
195 + let seller_id = h
196 + .signup("dispatchseller", "dispatchseller@test.com", "password123")
197 + .await;
198 + let event_id = "evt_dispatch_failed";
199 +
200 + let resp = post_until_settled(
201 + &mut h,
202 + event_id,
203 + "checkout.session.completed",
204 + unroutable_purchase("cs_dispatch_failed", seller_id),
205 + )
206 + .await;
207 + assert_eq!(
208 + resp.status.as_u16(),
209 + 200,
210 + "the local queue owns retry from here, so Stripe is ACKed: {}",
211 + resp.text
212 + );
213 + assert_eq!(
214 + queued(&h).await,
215 + 1,
216 + "the event is recoverable from the queue"
217 + );
218 + assert!(
219 + !processed(&h, event_id).await,
220 + "marking a failed event would make both retry routes a no-op"
221 + );
222 +
223 + // The unmarked half, demonstrated rather than asserted about: a redelivery
224 + // of the same event id re-enters the handler instead of short-circuiting.
225 + let resp = post_until_settled(
226 + &mut h,
227 + event_id,
228 + "checkout.session.completed",
229 + unroutable_purchase("cs_dispatch_failed", seller_id),
230 + )
231 + .await;
232 + assert_eq!(resp.status.as_u16(), 200, "redelivery: {}", resp.text);
233 + assert_eq!(
234 + queued(&h).await,
235 + 2,
236 + "the redelivery re-ran the handler, which is what leaving it unmarked buys"
237 + );
238 + }
239 +
240 + /// Stripe sends event types MNW never subscribed to. They reach
241 + /// `MnwEvent::Unhandled`, which is a success: acknowledged, marked so the
242 + /// redelivery is cheap, and kept out of a retry queue where they could never
243 + /// succeed.
244 + #[tokio::test]
245 + async fn an_event_type_mnw_does_not_handle_is_marked_and_not_queued() {
246 + let mut h = TestHarness::with_stripe().await;
247 + let event_id = "evt_dispatch_unhandled";
248 +
249 + let resp = post_until_settled(
250 + &mut h,
251 + event_id,
252 + "payment_intent.created",
253 + serde_json::json!({"id": "pi_dispatch_unhandled"}),
254 + )
255 + .await;
256 +
257 + assert_eq!(
258 + resp.status.as_u16(),
259 + 200,
260 + "an event we do not act on is still an event we accept: {}",
261 + resp.text
262 + );
263 + assert!(
264 + processed(&h, event_id).await,
265 + "marked, so a redelivery costs one dedup read"
266 + );
267 + assert_eq!(
268 + queued(&h).await,
269 + 0,
270 + "an unhandled type is not a failure and must not fill the retry queue"
271 + );
272 + }
273 +
274 + /// A body with no signature header is refused before the lock, the dedup read,
275 + /// or any handler. The two "wrote nothing" assertions matter more than the
276 + /// status: this endpoint is public, and an unsigned body that got as far as the
277 + /// event lock would be a free way to make real deliveries answer 503.
278 + #[tokio::test]
279 + async fn an_unsigned_delivery_is_refused_before_the_event_lock() {
280 + let mut h = TestHarness::with_stripe().await;
281 + let event_id = "evt_dispatch_unsigned";
282 +
283 + let payload = serde_json::json!({
284 + "id": event_id,
285 + "type": "payment_intent.created",
286 + "data": {"object": {"id": "pi_dispatch_unsigned"}},
287 + })
288 + .to_string();
289 + let resp = h
290 + .client
291 + .request_with_headers(
292 + "POST",
293 + "/stripe/webhook",
294 + Some(&payload),
295 + &[("content-type", "application/json")],
296 + )
297 + .await;
298 +
299 + assert_eq!(
300 + resp.status.as_u16(),
301 + 400,
302 + "a body with no signature is not a Stripe event: {}",
303 + resp.text
304 + );
305 + assert!(!processed(&h, event_id).await, "nothing was accepted");
306 + assert_eq!(queued(&h).await, 0, "and nothing was queued");
307 + }
308 +
309 + /// The settlement gate applies to the kinds that capture funds at checkout, and
310 + /// only those. A Fan+ session reports no `payment_status` MNW should wait on,
311 + /// because the subscription bills on its own schedule; deferring it would leave
312 + /// the subscriber paying and unsubscribed until an event that never comes.
313 + #[tokio::test]
314 + async fn a_subscription_checkout_is_not_held_back_by_the_settlement_gate() {
315 + let mut h = TestHarness::with_mocks().await;
316 + let user_id = h
317 + .signup("dispatchfan", "dispatchfan@test.com", "password123")
318 + .await;
319 +
320 + let resp = post_until_settled(
321 + &mut h,
322 + "evt_dispatch_unpaid_fanplus",
323 + "checkout.session.completed",
324 + serde_json::json!({
325 + "id": "cs_dispatch_unpaid_fanplus",
326 + "object": "checkout.session",
327 + "subscription": "sub_dispatch_unpaid_fanplus",
328 + "customer": "cus_dispatch_unpaid_fanplus",
329 + // The state that defers a purchase. A subscription-mode session
330 + // must be finalized on it regardless.
331 + "payment_status": "unpaid",
332 + "currency": "usd",
333 + "metadata": {"checkout_type": "fan_plus", "user_id": user_id.to_string()},
334 + }),
335 + )
336 + .await;
337 + assert_eq!(resp.status.as_u16(), 200, "Fan+ checkout: {}", resp.text);
338 +
339 + let rows: i64 =
340 + sqlx::query_scalar("SELECT COUNT(*) FROM fan_plus_subscriptions WHERE user_id = $1")
341 + .bind(user_id)
342 + .fetch_one(&h.db)
343 + .await
344 + .expect("count fan plus subscriptions");
345 + assert_eq!(
346 + rows, 1,
347 + "a subscription-mode checkout captures nothing at checkout, so there is \
348 + nothing to wait for and the signup must land"
349 + );
350 + }
@@ -1,0 +1,360 @@
1 + //! HTTP contract tests for `routes::synckit::groups`, specifically the three
2 + //! guards at the top of that file and which of its thirteen handlers each one
3 + //! covers.
4 + //!
5 + //! `synckit_group_rotation` owns the rotation state machine, `synckit_groups_billing`
6 + //! the subscription gate, and `db_synckit_groups` / `db_synckit_invitations` the
7 + //! layer beneath. All of them authenticate as somebody who is allowed to be there.
8 + //! Between them exactly one handler, `rotate`, has its permission checked.
9 + //!
10 + //! The guards are `require_group` (404 for a group outside the caller's app),
11 + //! `require_member` (403 for group-scoped reads and writes) and `require_admin`
12 + //! (403 for membership management). Each is one line at the top of each handler,
13 + //! which is the cheapest thing in the file to leave out and the most expensive to
14 + //! leave out of the wrong one. A missing `require_admin` on `add_member` lets any
15 + //! member add anyone; a missing `require_member` on `pull` hands a stranger the
16 + //! whole group changelog; a missing `require_group` turns a group id into a
17 + //! cross-tenant read.
18 + //!
19 + //! So this file walks the matrix rather than the features: for a stranger, and
20 + //! then for a member who is not the admin, it asks every handler and asserts the
21 + //! answer. What it pins is the shape of the wall, not any one brick.
22 + //!
23 + //! One distinction is deliberate and worth keeping straight. A group in another
24 + //! app answers 404, not 403: the caller's app scope is applied before membership
25 + //! is consulted, so a developer cannot use group ids to learn what exists in
26 + //! somebody else's app. Within the caller's own app, a group they are not in
27 + //! answers 403, which tells them only what they could learn by being told to go
28 + //! away.
29 +
30 + use serde_json::json;
31 +
32 + use super::synckit_paid_sync::{auth_as, create_internal_app, harness_with_blobs};
33 + use crate::harness::TestHarness;
34 + use makenotwork::db::{SyncAppId, UserId};
35 +
36 + /// The admin-only endpoints, as `(method, path suffix, body)`. `{g}` is the
37 + /// group id. A member who is not the admin must be refused every one of them.
38 + const ADMIN_ONLY: &[(&str, &str)] = &[
39 + ("POST", "/members"),
40 + ("GET", "/pubkeys"),
41 + ("POST", "/rotate"),
42 + ("POST", "/invitations"),
43 + ("GET", "/invitations"),
44 + ];
45 +
46 + /// A body that would be valid if the caller were allowed. The guard runs before
47 + /// the body is acted on, so these exist to prove the refusal is the guard rather
48 + /// than a parse failure further in.
49 + fn plausible_body(suffix: &str, subject: UserId) -> String {
50 + match suffix {
51 + "/members" => json!({
52 + "member_email": "nobody@example.com",
53 + "sealed_gck": "sealed_x_v1",
54 + "member_pubkey": "pk_x",
55 + })
56 + .to_string(),
57 + "/rotate" => json!({
58 + "gck_version": 2,
59 + "grants": [{ "user_id": subject.to_string(), "sealed_gck": "sealed_x_v2" }],
60 + })
61 + .to_string(),
62 + "/invitations" => json!({ "note": "join us" }).to_string(),
63 + // The guards run before the device is resolved, so any well-formed id
64 + // reaches them; using a real device would test the device check instead.
65 + "/pull" => {
66 + json!({ "device_id": uuid::Uuid::new_v4().to_string(), "cursor": 0 }).to_string()
67 + }
68 + _ => String::new(),
69 + }
70 + }
71 +
72 + async fn call(
73 + h: &mut TestHarness,
74 + method: &str,
75 + path: &str,
76 + body: &str,
77 + ) -> crate::harness::client::TestResponse {
78 + match method {
79 + "GET" => h.client.get(path).await,
80 + "POST" => h.client.post_json(path, body).await,
81 + "DELETE" => h.client.delete(path).await,
82 + other => panic!("unhandled method {other}"),
83 + }
84 + }
85 +
86 + async fn create_group(h: &mut TestHarness, name: &str) -> String {
87 + let resp = h
88 + .client
89 + .post_json(
90 + "/api/sync/groups",
91 + &json!({
92 + "id": uuid::Uuid::new_v4().to_string(),
93 + "name": name,
94 + "admin_sealed_gck": "sealed_admin_v1",
95 + "admin_pubkey": "pk_admin",
96 + })
97 + .to_string(),
98 + )
99 + .await;
100 + assert_eq!(resp.status.as_u16(), 200, "create group: {}", resp.text);
101 + resp.json::<serde_json::Value>()["id"]
102 + .as_str()
103 + .expect("group id")
104 + .to_string()
105 + }
106 +
107 + /// Seed a verified account and add it to the group as an ordinary member.
108 + async fn add_member(h: &mut TestHarness, group_id: &str, username: &str) -> UserId {
109 + let email = format!("{username}@example.com");
110 + let user = h.signup(username, &email, "Password1!").await;
111 + sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
112 + .bind(user)
113 + .execute(&h.db)
114 + .await
115 + .expect("verify member");
116 +
117 + let resp = h
118 + .client
119 + .post_json(
120 + &format!("/api/sync/groups/{group_id}/members"),
121 + &json!({
122 + "member_email": email,
123 + "sealed_gck": format!("sealed_{username}_v1"),
124 + "member_pubkey": format!("pk_{username}"),
125 + })
126 + .to_string(),
127 + )
128 + .await;
129 + assert_eq!(resp.status.as_u16(), 204, "add member: {}", resp.text);
130 + user
131 + }
132 +
133 + /// An admin with one group, plus the app they both live in.
134 + async fn group_with_admin(h: &mut TestHarness, tag: &str) -> (UserId, SyncAppId, String) {
135 + let admin = h
136 + .signup(
137 + &format!("{tag}_admin"),
138 + &format!("{tag}_admin@example.com"),
139 + "Password1!",
140 + )
141 + .await;
142 + let (app, _key) = create_internal_app(&h.db, admin).await;
143 + auth_as(h, admin, app, "admin-key");
144 + let group_id = create_group(h, "Team").await;
145 + (admin, app, group_id)
146 + }
147 +
148 + /// Every group-scoped handler, asked by somebody in the same app who is not in
149 + /// the group. All of them must answer 403, and none of them may leak the group's
150 + /// contents on the way.
151 + ///
152 + /// The read endpoints matter most here: `pull` and `grant` return the group's
153 + /// encrypted changelog and a sealed key, and an endpoint that answered
154 + /// 200-with-nothing rather than 403 would be one refactor away from answering
155 + /// 200-with-something.
156 + #[tokio::test]
157 + async fn a_stranger_in_the_same_app_is_refused_every_group_endpoint() {
158 + let (mut h, _blobs) = harness_with_blobs().await;
159 + let (_admin, app, group_id) = group_with_admin(&mut h, "stranger").await;
160 +
161 + let stranger = h
162 + .signup("stranger_eve", "stranger_eve@example.com", "Password1!")
163 + .await;
164 + auth_as(&mut h, stranger, app, "eve-key");
165 +
166 + // Member-gated and admin-gated alike: a non-member is refused by the first
167 + // guard either way, so the whole surface answers the same way.
168 + let endpoints: Vec<(&str, String, String)> = vec![
169 + ("GET", "/members".into(), String::new()),
170 + (
171 + "POST",
172 + "/members".into(),
173 + plausible_body("/members", stranger),
174 + ),
175 + ("GET", "/pubkeys".into(), String::new()),
176 + (
177 + "POST",
178 + "/rotate".into(),
179 + plausible_body("/rotate", stranger),
180 + ),
181 + ("GET", "/grant".into(), String::new()),
182 + ("POST", "/pull".into(), plausible_body("/pull", stranger)),
183 + (
184 + "POST",
185 + "/invitations".into(),
186 + plausible_body("/invitations", stranger),
187 + ),
188 + ("GET", "/invitations".into(), String::new()),
189 + ];
190 +
191 + for (method, suffix, body) in endpoints {
192 + let path = format!("/api/sync/groups/{group_id}{suffix}");
193 + let resp = call(&mut h, method, &path, &body).await;
194 + assert_eq!(
195 + resp.status.as_u16(),
196 + 403,
197 + "{method} {suffix} must refuse a non-member, got {}: {}",
198 + resp.status,
199 + resp.text
200 + );
201 + assert!(
202 + !resp.text.contains("sealed_admin_v1"),
203 + "{method} {suffix} leaked a grant to a non-member"
204 + );
205 + }
206 +
207 + // And the group does not show up in what they can see.
208 + let resp = h.client.get("/api/sync/groups").await;
209 + assert_eq!(resp.status.as_u16(), 200, "list groups: {}", resp.text);
210 + assert!(
211 + !resp.text.contains(&group_id),
212 + "a group the caller is not in must not be listed for them"
213 + );
214 + }
215 +
216 + /// The admin-only half of the matrix, asked by a real member. This is the
217 + /// distinction a single missing guard erases: the caller is legitimately in the
218 + /// group, so `require_member` passes and only `require_admin` is left.
219 + ///
220 + /// A member who could add members could add themselves an accomplice; one who
221 + /// could read `/pubkeys` could seal to identities they were never given; one who
222 + /// could mint an invitation could hand out the group.
223 + #[tokio::test]
224 + async fn a_member_who_is_not_the_admin_is_refused_the_admin_endpoints() {
225 + let (mut h, _blobs) = harness_with_blobs().await;
226 + let (_admin, app, group_id) = group_with_admin(&mut h, "member").await;
227 + let bob = add_member(&mut h, &group_id, "member_bob").await;
228 +
229 + auth_as(&mut h, bob, app, "bob-key");
230 +
231 + for (method, suffix) in ADMIN_ONLY {
232 + let path = format!("/api/sync/groups/{group_id}{suffix}");
233 + let resp = call(&mut h, method, &path, &plausible_body(suffix, bob)).await;
234 + assert_eq!(
235 + resp.status.as_u16(),
236 + 403,
237 + "{method} {suffix} is admin-only, got {}: {}",
238 + resp.status,
239 + resp.text
240 + );
241 + }
242 +
243 + // The member-gated endpoints stay open to them, which is what makes the
244 + // assertions above about `require_admin` rather than about being refused
245 + // in general.
246 + for suffix in ["/members", "/grant"] {
247 + let path = format!("/api/sync/groups/{group_id}{suffix}");
248 + let resp = h.client.get(&path).await;
249 + assert_eq!(
250 + resp.status.as_u16(),
251 + 200,
252 + "GET {suffix} is open to a member: {}",
253 + resp.text
254 + );
255 + }
256 + }
257 +
258 + /// A member cannot remove another member, and in particular cannot remove the
259 + /// admin. `remove_member` is the one admin endpoint whose path carries a second
260 + /// id, so it is the one where a guard could be written against the wrong subject.
261 + #[tokio::test]
262 + async fn a_member_cannot_remove_anyone_including_the_admin() {
263 + let (mut h, _blobs) = harness_with_blobs().await;
264 + let (admin, app, group_id) = group_with_admin(&mut h, "removal").await;
265 + let bob = add_member(&mut h, &group_id, "removal_bob").await;
266 + let carol = add_member(&mut h, &group_id, "removal_carol").await;
267 +
268 + auth_as(&mut h, bob, app, "bob-key");
269 +
270 + for target in [admin, carol, bob] {
271 + let resp = h
272 + .client
273 + .delete(&format!("/api/sync/groups/{group_id}/members/{target}"))
274 + .await;
275 + assert_eq!(
276 + resp.status.as_u16(),
277 + 403,
278 + "a member removing {target} must be refused, got {}: {}",
279 + resp.status,
280 + resp.text
281 + );
282 + }
283 +
284 + // Nobody left. Read it back as the admin, since membership is admin-visible
285 + // and this is the assertion the three refusals above are for.
286 + auth_as(&mut h, admin, app, "admin-key");
287 + let resp = h
288 + .client
289 + .get(&format!("/api/sync/groups/{group_id}/members"))
290 + .await;
291 + assert_eq!(resp.status.as_u16(), 200, "list members: {}", resp.text);
292 + let members = resp.json::<serde_json::Value>();
293 + assert_eq!(
294 + members.as_array().map(Vec::len),
295 + Some(3),
296 + "all three are still in the group: {members}"
297 + );
298 + }
299 +
300 + /// App scope is applied before membership, so a group belonging to another app
301 + /// is 404 rather than 403 even to a caller who is its admin under a different
302 + /// token. Answering 403 would confirm the id exists, which is a cross-tenant
303 + /// read of exactly the kind the scope is there to refuse.
304 + #[tokio::test]
305 + async fn a_group_in_another_app_is_not_found_rather_than_forbidden() {
306 + let (mut h, _blobs) = harness_with_blobs().await;
307 + let (admin, _first_app, group_id) = group_with_admin(&mut h, "scope").await;
308 +
309 + // The same human, a second app of their own. Nothing about the group changed;
310 + // only the app claim in the token did. Inserted here rather than through
311 + // `create_internal_app`, which mints a fixed api_key and so cannot be called
312 + // twice against one database.
313 + let second_key = "test-api-key-group-scope-second";
314 + let second_app: SyncAppId = sqlx::query_scalar(
315 + "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status) \
316 + VALUES ($1, 'SecondApp', $2, $3, TRUE, 'active') RETURNING id",
317 + )
318 + .bind(admin)
319 + .bind(crate::harness::hash_api_key(second_key))
320 + .bind(&second_key[..8])
321 + .fetch_one(&h.db)
322 + .await
323 + .expect("insert the second internal app");
324 + auth_as(&mut h, admin, second_app, second_key);
325 +
326 + for (method, suffix) in [("GET", "/members"), ("GET", "/grant"), ("POST", "/pull")] {
327 + let path = format!("/api/sync/groups/{group_id}{suffix}");
328 + let resp = call(&mut h, method, &path, &plausible_body(suffix, admin)).await;
329 + assert_eq!(
330 + resp.status.as_u16(),
331 + 404,
332 + "{method} {suffix} under another app must not confirm the id exists, \
333 + got {}: {}",
334 + resp.status,
335 + resp.text
336 + );
337 + }
338 + }
339 +
340 + /// A group id that exists nowhere answers the same 404 as one in another app, so
341 + /// the two cases are indistinguishable from outside. If they diverged, the pair
342 + /// would become an oracle for which group ids are real.
343 + #[tokio::test]
344 + async fn an_unknown_group_answers_the_same_as_one_in_another_app() {
345 + let (mut h, _blobs) = harness_with_blobs().await;
346 + let (_admin, _app, _group_id) = group_with_admin(&mut h, "ghost").await;
347 +
348 + let ghost = uuid::Uuid::new_v4();
349 + let resp = h
350 + .client
351 + .get(&format!("/api/sync/groups/{ghost}/members"))
352 + .await;
353 +
354 + assert_eq!(
355 + resp.status.as_u16(),
356 + 404,
357 + "an id that matches no group is not found: {}",
358 + resp.text
359 + );
360 + }
@@ -1,0 +1,311 @@
1 + //! HTTP contract tests for `routes::synckit::sync`, the endpoints an end user's
2 + //! app talks to once it holds a sync token.
3 + //!
4 + //! Seven suites drive parts of this file already: `synckit` (push, pull, devices,
5 + //! keys), `synckit_selective` (the pull filters), `synckit_paid_sync` (the
6 + //! subscription gate and the storage quota), `synckit_security` (device removal
7 + //! invalidating tokens), `synckit_sse`, `synckit_group_rotation` and
8 + //! `synckit_adversarial`. Between them the data path is well covered.
9 + //!
10 + //! Two endpoints on it were not covered at all, and the larger of the two is the
11 + //! one that moves money.
12 + //!
13 + //! `queue_storage_cap_change` re-prices a live Stripe subscription. It has three
14 + //! contracts worth the name and no test reached any of them. The bounds are
15 + //! checked before the provider is touched, so a nonsense cap costs nothing and
16 + //! cannot re-price anything. Stripe is updated *first* and the database second,
17 + //! deliberately, so a provider failure leaves the user on the cap they are paying
18 + //! for rather than sitting on headroom they were never billed for. And the
19 + //! direction decides the timing: raising takes effect immediately, because a user
20 + //! raising their cap is usually one already blocked by a full one, while lowering
21 + //! is queued to the period boundary, because they paid for the room.
22 + //!
23 + //! `sync_account` is the smaller one, and it had no test of any kind. It answers
24 + //! with an identity, from a bearer token, which makes "whose identity" the only
25 + //! question it has.
26 + //!
27 + //! Delete this file and a cap change could bill for one thing and store another,
28 + //! a Stripe outage could hand out free storage, and a lowered cap could take
29 + //! effect the moment it was asked for, inside a period the user already paid for.
30 +
31 + use super::synckit_paid_sync::{auth_as, create_internal_app, seed_subscription};
32 + use crate::harness::TestHarness;
33 + use crate::harness::faults::stripe_unavailable;
34 + use makenotwork::db::{SyncAppId, UserId};
35 + use makenotwork::payments::{MAX_CAP_BYTES, MIN_CAP_BYTES};
36 + use serde_json::{Value, json};
37 +
38 + const GIB: i64 = 1024 * 1024 * 1024;
39 +
40 + /// A subscribed user of an internal app, authenticated as their own device.
41 + /// Returns their id, the app id, and the cap they start on.
42 + async fn subscribed_user(
43 + h: &mut TestHarness,
44 + username: &str,
45 + starting_cap: i64,
46 + ) -> (UserId, SyncAppId) {
47 + let user_id = h
48 + .signup(username, &format!("{username}@test.com"), "Password1!")
49 + .await;
50 + let (app_id, api_key) = create_internal_app(&h.db, user_id).await;
51 + seed_subscription(&h.db, user_id, app_id, "active", starting_cap).await;
52 + auth_as(h, user_id, app_id, &api_key);
53 + (user_id, app_id)
54 + }
55 +
56 + /// `(storage_limit_bytes, pending_storage_limit_bytes)` as stored.
57 + async fn caps(h: &TestHarness, user_id: UserId, app_id: SyncAppId) -> (Option<i64>, Option<i64>) {
58 + sqlx::query_as::<_, (Option<i64>, Option<i64>)>(
59 + "SELECT storage_limit_bytes, pending_storage_limit_bytes \
60 + FROM app_sync_subscriptions WHERE user_id = $1 AND app_id = $2",
61 + )
62 + .bind(user_id)
63 + .bind(app_id)
64 + .fetch_one(&h.db)
65 + .await
66 + .expect("read caps")
67 + }
68 +
69 + /// How many times the provider was asked to re-price the subscription.
70 + fn repricings(h: &TestHarness) -> u32 {
71 + h.mock_stripe
72 + .as_ref()
73 + .expect("with_mocks provides a payment provider")
74 + .faults()
75 + .calls("update_synckit_app_sub_price")
76 + }
77 +
78 + async fn post_cap(h: &mut TestHarness, cap_bytes: i64) -> crate::harness::client::TestResponse {
79 + h.client
80 + .post_json(
81 + "/api/v1/sync/subscription/storage-cap",
82 + &json!({ "cap_bytes": cap_bytes }).to_string(),
83 + )
84 + .await
85 + }
86 +
87 + /// A cap outside the offered range is refused before the provider is touched.
88 + /// The call count is the assertion that matters: a handler that re-priced first
89 + /// and validated second would leave a live subscription billing for a cap the
90 + /// database then refused to record.
91 + #[tokio::test]
92 + async fn a_cap_outside_the_offered_range_never_reaches_the_provider() {
93 + let mut h = TestHarness::with_mocks().await;
94 + let (user_id, app_id) = subscribed_user(&mut h, "capbounds", 500 * GIB).await;
95 +
96 + for cap in [0, MIN_CAP_BYTES - 1, MAX_CAP_BYTES + 1] {
97 + let resp = post_cap(&mut h, cap).await;
98 + assert_eq!(
99 + resp.status.as_u16(),
100 + 400,
101 + "{cap} bytes is not a cap we sell: {}",
102 + resp.text
103 + );
104 + }
105 +
106 + assert_eq!(
107 + repricings(&h),
108 + 0,
109 + "the bounds are checked before Stripe, so a nonsense cap costs nothing"
110 + );
111 + assert_eq!(
112 + caps(&h, user_id, app_id).await,
113 + (Some(500 * GIB), None),
114 + "and the stored cap is untouched"
115 + );
116 + }
117 +
118 + /// Stripe first, database second. If the re-price fails, the user must be left
119 + /// on the cap they are paying for: recording the new cap anyway would hand out
120 + /// storage nobody is billed for, and every such failure is silent, because the
121 + /// user sees the error and the platform sees nothing.
122 + #[tokio::test]
123 + async fn a_provider_failure_leaves_the_stored_cap_where_it_was() {
124 + let mut h = TestHarness::with_mocks().await;
125 + let (user_id, app_id) = subscribed_user(&mut h, "capoutage", 500 * GIB).await;
126 +
127 + h.mock_stripe
128 + .as_ref()
129 + .expect("with_mocks provides a payment provider")
130 + .faults()
131 + .fail_always("update_synckit_app_sub_price", stripe_unavailable);
132 +
133 + let resp = post_cap(&mut h, 1000 * GIB).await;
134 + assert_eq!(
135 + resp.status.as_u16(),
136 + 503,
137 + "the provider is down, so the change did not happen: {}",
138 + resp.text
139 + );
140 + assert_eq!(
141 + caps(&h, user_id, app_id).await,
142 + (Some(500 * GIB), None),
143 + "no cap is granted that Stripe was not told to bill for"
144 + );
145 + }
146 +
147 + /// Raising takes effect now. The user asking is usually one already blocked by a
148 + /// full cap, and Stripe has just been re-priced either way, so making them wait
149 + /// for the period boundary would sell them headroom they cannot use.
150 + #[tokio::test]
151 + async fn raising_the_cap_takes_effect_immediately() {
152 + let mut h = TestHarness::with_mocks().await;
153 + let (user_id, app_id) = subscribed_user(&mut h, "capraise", 500 * GIB).await;
154 +
155 + let resp = post_cap(&mut h, 1000 * GIB).await;
156 + assert_eq!(resp.status.as_u16(), 200, "raise the cap: {}", resp.text);
157 +
158 + assert_eq!(
159 + caps(&h, user_id, app_id).await,
160 + (Some(1000 * GIB), None),
161 + "the new cap is live and nothing is left pending"
162 + );
163 + assert_eq!(repricings(&h), 1, "and Stripe was re-priced exactly once");
164 +
165 + let body: Value = resp.json();
166 + assert_eq!(
167 + body["storage_limit_bytes"].as_i64(),
168 + Some(1000 * GIB),
169 + "the answer reports the cap that is now in force"
170 + );
171 + assert!(
172 + body["pending_storage_limit_bytes"].is_null(),
173 + "and nothing is queued, got {}",
174 + body["pending_storage_limit_bytes"]
175 + );
176 + }
177 +
178 + /// Lowering waits for the period boundary. The user paid for the room they
179 + /// currently have, so taking it away mid-period is taking back something already
180 + /// bought; the renewal webhook promotes the pending cap when Stripe rolls the
181 + /// period.
182 + #[tokio::test]
183 + async fn lowering_the_cap_is_queued_for_the_period_boundary() {
184 + let mut h = TestHarness::with_mocks().await;
185 + let (user_id, app_id) = subscribed_user(&mut h, "caplower", 1000 * GIB).await;
186 +
187 + let resp = post_cap(&mut h, 500 * GIB).await;
188 + assert_eq!(resp.status.as_u16(), 200, "lower the cap: {}", resp.text);
189 +
190 + assert_eq!(
191 + caps(&h, user_id, app_id).await,
192 + (Some(1000 * GIB), Some(500 * GIB)),
193 + "the room stays until the period the user paid for ends"
194 + );
195 + assert_eq!(
196 + repricings(&h),
197 + 1,
198 + "Stripe is re-priced now, with no proration"
199 + );
200 +
201 + let body: Value = resp.json();
202 + assert_eq!(
203 + body["storage_limit_bytes"].as_i64(),
204 + Some(1000 * GIB),
205 + "the answer still reports the cap in force"
206 + );
207 + assert_eq!(
208 + body["pending_storage_limit_bytes"].as_i64(),
209 + Some(500 * GIB),
210 + "alongside the one that takes over at renewal"
211 + );
212 + }
213 +
214 + /// There is nothing to re-price without a subscription, and asking Stripe to
215 + /// change a subscription that does not exist is how a handler ends up acting on
216 + /// somebody else's.
217 + #[tokio::test]
218 + async fn a_cap_change_without_a_subscription_is_refused() {
219 + let mut h = TestHarness::with_mocks().await;
220 + let user_id = h
221 + .signup("capnosub", "capnosub@test.com", "Password1!")
222 + .await;
223 + let (app_id, api_key) = create_internal_app(&h.db, user_id).await;
224 + auth_as(&mut h, user_id, app_id, &api_key);
225 +
226 + let resp = post_cap(&mut h, 1000 * GIB).await;
227 +
228 + assert_eq!(
229 + resp.status.as_u16(),
230 + 400,
231 + "no subscription, nothing to adjust: {}",
232 + resp.text
233 + );
234 + assert_eq!(repricings(&h), 0, "and the provider is never called");
235 + }
236 +
237 + /// The subscription endpoint answers 200 with `active: false` rather than 404
238 + /// when there is no subscription. Clients render a subscribe prompt off that
239 + /// shape, so a 404 here would show them an error screen at exactly the moment
240 + /// they are being asked to pay.
241 + #[tokio::test]
242 + async fn an_unsubscribed_user_gets_an_inactive_status_rather_than_a_404() {
243 + let mut h = TestHarness::with_mocks().await;
244 + let user_id = h
245 + .signup("substatus", "substatus@test.com", "Password1!")
246 + .await;
247 + let (app_id, api_key) = create_internal_app(&h.db, user_id).await;
248 + auth_as(&mut h, user_id, app_id, &api_key);
249 +
250 + let resp = h.client.get("/api/v1/sync/subscription").await;
251 +
252 + assert_eq!(
253 + resp.status.as_u16(),
254 + 200,
255 + "having no subscription is an answer, not an error: {}",
256 + resp.text
257 + );
258 + let body: Value = resp.json();
259 + assert_eq!(
260 + body["active"].as_bool(),
261 + Some(false),
262 + "and the answer is that they do not have one"
263 + );
264 + }
265 +
266 + /// The account endpoint answers with an identity, taken from a bearer token, so
267 + /// the only question it has is whose. Two subscribed users, two tokens, and each
268 + /// must see itself.
269 + #[tokio::test]
270 + async fn the_account_endpoint_answers_for_the_token_holder_and_nobody_else() {
271 + let mut h = TestHarness::with_mocks().await;
272 +
273 + let first = h.signup("acctone", "acctone@test.com", "Password1!").await;
274 + let (app_id, api_key) = create_internal_app(&h.db, first).await;
275 + auth_as(&mut h, first, app_id, &api_key);
276 +
277 + let resp = h.client.get("/api/v1/sync/account").await;
278 + assert_eq!(resp.status.as_u16(), 200, "account: {}", resp.text);
279 + let body: Value = resp.json();
280 + assert_eq!(body["username"].as_str(), Some("acctone"));
281 + assert_eq!(body["email"].as_str(), Some("acctone@test.com"));
282 +
283 + // A second user of the same app. The app id in the token is identical; only
284 + // the user claim differs, which is the claim this endpoint has to read.
285 + let second = h.signup("accttwo", "accttwo@test.com", "Password1!").await;
286 + auth_as(&mut h, second, app_id, &api_key);
287 +
288 + let resp = h.client.get("/api/v1/sync/account").await;
289 + assert_eq!(resp.status.as_u16(), 200, "second account: {}", resp.text);
290 + let body: Value = resp.json();
291 + assert_eq!(
292 + body["username"].as_str(),
293 + Some("accttwo"),
294 + "a token for the second user must not answer with the first user's name"
295 + );
296 + assert_eq!(body["email"].as_str(), Some("accttwo@test.com"));
297 + }
298 +
299 + /// Without a token there is no identity to answer with.
300 + #[tokio::test]
301 + async fn the_account_endpoint_is_closed_to_an_unauthenticated_caller() {
302 + let mut h = TestHarness::with_mocks().await;
303 +
304 + let resp = h.client.get("/api/v1/sync/account").await;
305 +
306 + assert!(
307 + resp.status.is_client_error(),
308 + "an identity endpoint with no token is refused, got {}",
309 + resp.status
310 + );
311 + }