Skip to main content

max / makenotwork

server: contract tests for the money and user-data files that had none The coverage ratchet in tests/untested_money_paths.rs counts files that move money or hold a creator's data and carry no test of any kind. It stood at 32. This lowers it to 10, measured by running the seal rather than by arithmetic: 147 tests across 18 files, full suite 2478 lib + 1584 integration, zero failures. The biggest of them is db/subscriptions.rs, 842 lines behind a #[cfg(test)] block that held a test-only constructor and not one test. That file is why the seal matches on test attributes rather than on cfg(test) modules, and it now has both halves covered: the access gate clause by clause, and the webhook-driven lifecycle writes. Every function in the module is pub(crate), so the tests drive the nearest production call site and assert what it answers; each file's header says which call site and what breaks if the file is deleted. Two defects found by writing these, both filed rather than fixed, because both are money and neither is mine to rule on. The Fan+ duplicate-webhook guard documented at webhook/checkout.rs:527 does not exist in the SQL, so a Stripe redelivery sends a second welcome email; the fix belongs on the caller, since adding the guard would break the revival path that routes through the same statement. And a renewal invoice arriving after cancellation still mints the credit, because apply_stripe_update correctly refuses to move a canceled row's period and its result is then discarded. GoingsOn problems 51d49e66 and fba73bac. The tests pin the documented guards and deliberately leave the credit unasserted, so they survive whichever way that is ruled. Seven test failures were triaged on the way and none was a production bug. Six seeded a world Postgres refuses: migration 104 carries a partial unique index on (buyer_id, item_id) where status = 'completed', so one buyer cannot hold two completed transactions for one item. The seventh asserted 200 on a webhook redelivery and got 503, which is the route's documented "redeliver me" while pg_try_advisory_xact_lock is still held by a transaction sqlx rolls back lazily; the helper now redelivers on 503 rather than pinning the timing. One thing worth knowing about the seal itself: it matches the lowercase phrase "contract tests for", and its subject parser only accepts a bare backticked path. Two of these files opened with a capital C and earned nothing until reworded, and the pre-existing brace form `db::synckit::{log, blobs, keys}` credits none of the three. Part of the original 32 was that, rather than absent tests.
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-08-31 17:24 UTC
Signed with PGP, not checked
Commit: 57de569ee6bb630c092410778e05f6b08adee371
Parent: 22661a6
20 files changed, +8469 insertions, -2 deletions
@@ -5,7 +5,7 @@
5 5 //! tests. It counts files in those areas that contain no test of any kind and
6 6 //! fails when the number goes up. It cannot make anyone write a good test; it
7 7 //! can stop a new payment handler or sync table from landing with none at all,
8 - //! which is how the current 34 accumulated, one reasonable-looking file at a
8 + //! which is how the original 34 accumulated, one reasonable-looking file at a
9 9 //! time.
10 10 //!
11 11 //! Deliberately dumb. A file counts as covered if it holds a test attribute, if
@@ -33,7 +33,7 @@
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 = 32;
36 + const UNTESTED_HIGH_WATER: usize = 10;
37 37
38 38 /// Anything that moves money or decides what someone is entitled to.
39 39 const MONEY: &[&str] = &[
@@ -31,21 +31,33 @@
31 31 mod custom_pages;
32 32 mod db_collections_layer;
33 33 mod db_creator_tiers_layer;
34 + mod db_fan_plus_layer;
34 35 mod db_imports_layer;
35 36 mod db_issues_layer;
36 37 mod db_items_bulk_layer;
37 38 mod db_items_layer;
39 + mod db_items_media_layer;
38 40 mod db_passkeys_layer;
39 41 mod db_payments_layer;
42 + mod db_pending_uploads_layer;
43 + mod db_revenue_stats_layer;
40 44 mod db_scan_jobs_layer;
41 45 mod db_scanning_layer;
42 46 mod db_ssh_keys_layer;
47 + mod db_subscriptions_layer;
48 + mod db_subscriptions_lifecycle_layer;
43 49 mod db_synckit_accounts_layer;
44 50 mod db_synckit_billing_layer;
51 + mod db_synckit_blobs_layer;
52 + mod db_synckit_devices_keys_layer;
45 53 mod db_synckit_groups;
46 54 mod db_synckit_invitations;
47 55 mod db_synckit_layer;
56 + mod db_synckit_log_layer;
57 + mod db_synckit_log_push_layer;
48 58 mod db_synckit_rotation;
59 + mod db_synckit_security_layer;
60 + mod db_synckit_subscriptions_layer;
49 61 mod db_transactions_layer;
50 62 mod db_users_layer;
51 63 mod db_webhook_events;
@@ -116,8 +128,13 @@
116 128 mod ssh_management;
117 129 mod sso;
118 130 mod storage;
131 + mod storage_routes_workflows;
119 132 mod streaming;
133 + mod stripe_checkout_routes;
120 134 mod stripe_disconnect;
135 + mod stripe_tip_checkout_routes;
136 + mod stripe_webhook_billing_replay;
137 + mod stripe_webhook_exactly_once;
121 138 mod stripe_webhooks;
122 139 mod subscriptions;
123 140 mod suspension;
@@ -129,6 +146,7 @@
129 146 mod synckit_groups_billing;
130 147 mod synckit_paid_sync;
131 148 mod synckit_per_key_storage;
149 + mod synckit_routes_workflows;
132 150 mod synckit_security;
133 151 mod synckit_selective;
134 152 mod synckit_sse;
@@ -1,0 +1,391 @@
1 + //! DB-layer contract tests for `db::fan_plus`, the Fan+ membership store.
2 + //!
3 + //! Every function in the module is `pub(crate)`, so it is driven here through
4 + //! the Stripe webhook endpoint that is its only production caller, and the
5 + //! assertions read the `fan_plus_subscriptions` rows the module writes. What is
6 + //! pinned is the part Stripe redelivery can corrupt: a Fan+ checkout creates
7 + //! exactly one subscription however many times it is delivered, a fresh
8 + //! checkout revives a canceled membership rather than opening a second row, a
9 + //! status update writes status, period and the cancel-at-period-end flag
10 + //! together, and a repeated cancellation keeps the first `canceled_at` instead
11 + //! of sliding it forward.
12 + //!
13 + //! Existing coverage of the canceled-is-terminal guard lives in
14 + //! `stripe_webhooks.rs` and is not repeated here.
15 + //!
16 + //! Delete this file and nothing checks that a redelivered Fan+ webhook writes
17 + //! one row, which is the failure Stripe's at-least-once delivery produces.
18 +
19 + use crate::harness::TestHarness;
20 + use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload};
21 + use makenotwork::db::UserId;
22 + // ── db::fan_plus, driven through its only production caller ──
23 +
24 + /// POST a signed Stripe event to the webhook endpoint.
25 + async fn post_event(
26 + h: &mut TestHarness,
27 + event_id: &str,
28 + event_type: &str,
29 + object: serde_json::Value,
30 + ) -> crate::harness::client::TestResponse {
31 + let payload = serde_json::json!({
32 + "id": event_id,
33 + "type": event_type,
34 + "data": {"object": object},
35 + })
36 + .to_string();
37 + let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET);
38 + h.client
39 + .request_with_headers(
40 + "POST",
41 + "/stripe/webhook",
42 + Some(&payload),
43 + &[
44 + ("stripe-signature", &signature),
45 + ("content-type", "application/json"),
46 + ],
47 + )
48 + .await
49 + }
50 +
51 + /// A completed Fan+ checkout session. `checkout_type=fan_plus` is what routes
52 + /// it to the Fan+ handler; `user_id` is who gets the membership.
53 + fn fan_plus_session(session_id: &str, user_id: UserId, sub_id: &str) -> serde_json::Value {
54 + serde_json::json!({
55 + "id": session_id,
56 + "object": "checkout.session",
57 + "mode": "subscription",
58 + "metadata": {"checkout_type": "fan_plus", "user_id": user_id.to_string()},
59 + "subscription": sub_id,
60 + "customer": format!("cus_{sub_id}"),
61 + "payment_status": "no_payment_required",
62 + })
63 + }
64 +
65 + /// A `customer.subscription.*` object carrying a period on its first item.
66 + fn subscription_object(
67 + sub_id: &str,
68 + status: &str,
69 + cancel_at_period_end: bool,
70 + period: (i64, i64),
71 + ) -> serde_json::Value {
72 + serde_json::json!({
73 + "id": sub_id,
74 + "object": "subscription",
75 + "status": status,
76 + "cancel_at_period_end": cancel_at_period_end,
77 + "items": {"object": "list", "data": [{
78 + "id": "si_fp_layer",
79 + "current_period_start": period.0,
80 + "current_period_end": period.1,
81 + }]},
82 + })
83 + }
84 +
85 + async fn fan_plus_row_count(pool: &sqlx::PgPool, user_id: UserId) -> i64 {
86 + sqlx::query_scalar("SELECT COUNT(*) FROM fan_plus_subscriptions WHERE user_id = $1")
87 + .bind(user_id)
88 + .fetch_one(pool)
89 + .await
90 + .expect("count fan plus rows")
91 + }
92 +
93 + #[tokio::test]
94 + async fn fan_plus_checkout_creates_exactly_one_subscription_under_redelivery() {
95 + let mut h = TestHarness::with_stripe().await;
96 + let user_id = h
97 + .signup("fplayer_new", "fplayer_new@test.com", "password123")
98 + .await;
99 +
100 + let session = fan_plus_session("cs_fp_layer_1", user_id, "sub_fp_layer_1");
101 + let resp = post_event(
102 + &mut h,
103 + "evt_fp_layer_create",
104 + "checkout.session.completed",
105 + session.clone(),
106 + )
107 + .await;
108 + assert_eq!(
109 + resp.status.as_u16(),
110 + 200,
111 + "Fan+ checkout webhook failed: {}",
112 + resp.text
113 + );
114 + assert_eq!(
115 + fan_plus_row_count(&h.db, user_id).await,
116 + 1,
117 + "one checkout creates one membership"
118 + );
119 +
120 + // Stripe's own retry: identical event id. The dedup layer must swallow it.
121 + let resp = post_event(
122 + &mut h,
123 + "evt_fp_layer_create",
124 + "checkout.session.completed",
125 + session.clone(),
126 + )
127 + .await;
128 + assert_eq!(
129 + resp.status.as_u16(),
130 + 200,
131 + "retried delivery must be accepted, not errored: {}",
132 + resp.text
133 + );
134 +
135 + // A redelivery that escapes the event-id dedup (distinct id, same session)
136 + // reaches `create_fan_plus_subscription`, whose ON CONFLICT (user_id) is the
137 + // backstop: still one row, still the same Stripe ids.
138 + let resp = post_event(
139 + &mut h,
140 + "evt_fp_layer_create_dup",
141 + "checkout.session.completed",
142 + session,
143 + )
144 + .await;
145 + assert_eq!(
146 + resp.status.as_u16(),
147 + 200,
148 + "duplicate Fan+ checkout must not error: {}",
149 + resp.text
150 + );
151 + assert_eq!(
152 + fan_plus_row_count(&h.db, user_id).await,
153 + 1,
154 + "a redelivered Fan+ checkout must never mint a second membership"
155 + );
156 +
157 + let (sub_id, customer_id, status): (String, String, String) = sqlx::query_as(
158 + "SELECT stripe_subscription_id, stripe_customer_id, status \
159 + FROM fan_plus_subscriptions WHERE user_id = $1",
160 + )
161 + .bind(user_id)
162 + .fetch_one(&h.db)
163 + .await
164 + .unwrap();
165 + assert_eq!(sub_id, "sub_fp_layer_1");
166 + assert_eq!(customer_id, "cus_sub_fp_layer_1");
167 + assert_eq!(status, "active", "a new membership starts active");
168 + }
169 +
170 + #[tokio::test]
171 + async fn a_new_fan_plus_checkout_reactivates_the_canceled_membership_row() {
172 + let mut h = TestHarness::with_stripe().await;
173 + let user_id = h
174 + .signup("fplayer_re", "fplayer_re@test.com", "password123")
175 + .await;
176 +
177 + // A previously canceled membership. The unique constraint is on user_id, so
178 + // re-subscribing has to reuse this row rather than insert beside it.
179 + sqlx::query(
180 + "INSERT INTO fan_plus_subscriptions \
181 + (user_id, stripe_subscription_id, stripe_customer_id, status, canceled_at) \
182 + VALUES ($1, 'sub_fp_layer_old', 'cus_fp_layer_old', 'canceled', NOW())",
183 + )
184 + .bind(user_id)
185 + .execute(&h.db)
186 + .await
187 + .unwrap();
188 +
189 + let resp = post_event(
190 + &mut h,
191 + "evt_fp_layer_resub",
192 + "checkout.session.completed",
193 + fan_plus_session("cs_fp_layer_re", user_id, "sub_fp_layer_new"),
194 + )
195 + .await;
196 + assert_eq!(
197 + resp.status.as_u16(),
198 + 200,
199 + "re-subscribe webhook failed: {}",
200 + resp.text
201 + );
202 +
203 + assert_eq!(
204 + fan_plus_row_count(&h.db, user_id).await,
205 + 1,
206 + "re-subscribing updates the existing row rather than adding one"
207 + );
208 + let (status, sub_id, canceled_at): (String, String, Option<chrono::DateTime<chrono::Utc>>) =
209 + sqlx::query_as(
210 + "SELECT status, stripe_subscription_id, canceled_at \
211 + FROM fan_plus_subscriptions WHERE user_id = $1",
212 + )
213 + .bind(user_id)
214 + .fetch_one(&h.db)
215 + .await
216 + .unwrap();
217 + assert_eq!(status, "active", "checkout is the reactivation path");
218 + assert_eq!(
219 + sub_id, "sub_fp_layer_new",
220 + "the row now points at the new Stripe subscription"
221 + );
222 + assert_eq!(
223 + canceled_at, None,
224 + "reactivation clears the old cancellation stamp"
225 + );
226 + }
227 +
228 + #[tokio::test]
229 + async fn a_stripe_update_writes_fan_plus_status_period_and_cancel_flag_together() {
230 + let mut h = TestHarness::with_stripe().await;
231 + let user_id = h
232 + .signup("fplayer_upd", "fplayer_upd@test.com", "password123")
233 + .await;
234 + let sub_id = "sub_fp_layer_upd";
235 + sqlx::query(
236 + "INSERT INTO fan_plus_subscriptions \
237 + (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) \
238 + VALUES ($1, $2, 'cus_fp_layer_upd', 'active', to_timestamp(1600000000))",
239 + )
240 + .bind(user_id)
241 + .bind(sub_id)
242 + .execute(&h.db)
243 + .await
244 + .unwrap();
245 +
246 + // Payment fell behind and the fan scheduled a cancellation: one event
247 + // carries status, period and the flag, and all three must land.
248 + let resp = post_event(
249 + &mut h,
250 + "evt_fp_layer_upd",
251 + "customer.subscription.updated",
252 + subscription_object(sub_id, "past_due", true, (1_700_000_000, 1_702_592_000)),
253 + )
254 + .await;
255 + assert_eq!(
256 + resp.status.as_u16(),
257 + 200,
258 + "update webhook failed: {}",
259 + resp.text
260 + );
261 +
262 + let (status, period_end, cancel_pending): (String, i64, bool) = sqlx::query_as(
263 + "SELECT status, EXTRACT(EPOCH FROM current_period_end)::BIGINT, cancel_at_period_end \
264 + FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1",
265 + )
266 + .bind(sub_id)
267 + .fetch_one(&h.db)
268 + .await
269 + .unwrap();
270 + assert_eq!(status, "past_due", "Stripe's status is written through");
271 + assert_eq!(
272 + period_end, 1_702_592_000,
273 + "the period advances to the one Stripe sent, not the seeded 1600000000"
274 + );
275 + assert!(
276 + cancel_pending,
277 + "cancel_at_period_end tracks Stripe, which owns that flag"
278 + );
279 +
280 + // And back: resuming in Stripe's portal clears the flag here.
281 + let resp = post_event(
282 + &mut h,
283 + "evt_fp_layer_upd_resume",
284 + "customer.subscription.updated",
285 + subscription_object(sub_id, "active", false, (1_700_000_000, 1_702_592_000)),
286 + )
287 + .await;
288 + assert_eq!(
289 + resp.status.as_u16(),
290 + 200,
291 + "resume update failed: {}",
292 + resp.text
293 + );
294 + let (status, cancel_pending): (String, bool) = sqlx::query_as(
295 + "SELECT status, cancel_at_period_end FROM fan_plus_subscriptions \
296 + WHERE stripe_subscription_id = $1",
297 + )
298 + .bind(sub_id)
299 + .fetch_one(&h.db)
300 + .await
301 + .unwrap();
302 + assert_eq!(
303 + status, "active",
304 + "a recovered payment restores the membership"
305 + );
306 + assert!(!cancel_pending, "the scheduled cancellation is cleared");
307 + }
308 +
309 + #[tokio::test]
310 + async fn a_repeated_cancellation_keeps_the_first_canceled_at() {
311 + let mut h = TestHarness::with_stripe().await;
312 + let user_id = h
313 + .signup("fplayer_del", "fplayer_del@test.com", "password123")
314 + .await;
315 + let sub_id = "sub_fp_layer_del";
316 + sqlx::query(
317 + "INSERT INTO fan_plus_subscriptions \
318 + (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) \
319 + VALUES ($1, $2, 'cus_fp_layer_del', 'active', to_timestamp(1702592000))",
320 + )
321 + .bind(user_id)
322 + .bind(sub_id)
323 + .execute(&h.db)
324 + .await
325 + .unwrap();
326 +
327 + let resp = post_event(
328 + &mut h,
329 + "evt_fp_layer_del",
330 + "customer.subscription.deleted",
331 + subscription_object(sub_id, "canceled", false, (1_700_000_000, 1_702_592_000)),
332 + )
333 + .await;
334 + assert_eq!(
335 + resp.status.as_u16(),
336 + 200,
337 + "cancellation webhook failed: {}",
338 + resp.text
339 + );
340 +
341 + let (status, first_canceled_at): (String, Option<chrono::DateTime<chrono::Utc>>) =
342 + sqlx::query_as(
343 + "SELECT status, canceled_at FROM fan_plus_subscriptions \
344 + WHERE stripe_subscription_id = $1",
345 + )
346 + .bind(sub_id)
347 + .fetch_one(&h.db)
348 + .await
349 + .unwrap();
350 + assert_eq!(status, "canceled");
351 + let first_canceled_at = first_canceled_at.expect("cancellation stamps canceled_at");
352 +
353 + // A second delivery of the cancellation (distinct event id, so it reaches
354 + // the handler) must be a no-op on the stamp: `COALESCE(canceled_at, NOW())`
355 + // is what stops the cancellation date sliding forward on every redelivery,
356 + // and that date is what the fan's remaining access window is judged from.
357 + let resp = post_event(
358 + &mut h,
359 + "evt_fp_layer_del_again",
360 + "customer.subscription.deleted",
361 + subscription_object(sub_id, "canceled", false, (1_700_000_000, 1_702_592_000)),
362 + )
363 + .await;
364 + assert_eq!(
365 + resp.status.as_u16(),
366 + 200,
367 + "repeat cancellation must not error: {}",
368 + resp.text
369 + );
370 +
371 + let (status, second_canceled_at): (String, Option<chrono::DateTime<chrono::Utc>>) =
372 + sqlx::query_as(
373 + "SELECT status, canceled_at FROM fan_plus_subscriptions \
374 + WHERE stripe_subscription_id = $1",
375 + )
376 + .bind(sub_id)
377 + .fetch_one(&h.db)
378 + .await
379 + .unwrap();
380 + assert_eq!(status, "canceled", "still canceled");
381 + assert_eq!(
382 + second_canceled_at,
383 + Some(first_canceled_at),
384 + "a redelivered cancellation keeps the original canceled_at"
385 + );
386 + assert_eq!(
387 + fan_plus_row_count(&h.db, user_id).await,
388 + 1,
389 + "cancellation never adds rows"
390 + );
391 + }
@@ -1,0 +1,652 @@
1 + //! DB-layer contract tests for `db::items::media`, the writeback layer every
2 + //! upload confirm lands on.
3 + //!
4 + //! This is the compare-and-swap that decides whether storage may be credited,
5 + //! the cover triple-write (url + key + size), and the ownership-filtered
6 + //! size/metadata writebacks. These pin what each one actually promises: which
7 + //! outcome a stale expectation produces, that a losing write leaves every
8 + //! column untouched, that each file type writes its own column pair and no
9 + //! other, and that a non-owner never lands a byte.
10 + //!
11 + //! The pending-uploads table that used to share this file has its own
12 + //! module, db_pending_uploads_layer.
13 + //!
14 + //! Delete this file and two classes of loss stop being observable: a dropped
15 + //! CAS predicate (double-credited storage and clobbered live objects), and a
16 + //! size or metadata writeback that ignores its ownership filter.
17 +
18 + use crate::harness::db::TestDb;
19 + use crate::harness::{seed_project, seed_user};
20 + use makenotwork::db::items::{FileConfirmOutcome, update_item_file_cas};
21 + use makenotwork::db::{ItemId, ProjectId, UserId, items};
22 + use makenotwork::error::AppError;
23 + use makenotwork::storage::FileType;
24 +
25 + // ── helpers ──────────────────────────────────────────────────────────────────
26 +
27 + async fn seed_item(pool: &sqlx::PgPool, project: ProjectId, slug: &str) -> ItemId {
28 + sqlx::query_scalar::<_, ItemId>(
29 + "INSERT INTO items (project_id, title, item_type, price_cents, slug)
30 + VALUES ($1, $2, 'digital', 1000, $3) RETURNING id",
31 + )
32 + .bind(project)
33 + .bind(format!("Item {slug}"))
34 + .bind(slug)
35 + .fetch_one(pool)
36 + .await
37 + .expect("seed item")
38 + }
39 +
40 + /// A user, a project and one item, the minimum an item-media writeback needs.
41 + async fn owner_and_item(db: &TestDb, tag: &str) -> (UserId, ItemId) {
42 + let user = seed_user(&db.pool, &format!("media_{tag}")).await;
43 + let project = seed_project(&db.pool, user, &format!("media-{tag}")).await;
44 + let item = seed_item(&db.pool, project, &format!("i-{tag}")).await;
45 + (user, item)
46 + }
47 +
48 + /// The audio pair as stored.
49 + async fn audio_cols(pool: &sqlx::PgPool, item: ItemId) -> (Option<String>, Option<i64>) {
50 + sqlx::query_as("SELECT audio_s3_key, audio_file_size_bytes FROM items WHERE id = $1")
51 + .bind(item)
52 + .fetch_one(pool)
53 + .await
54 + .expect("read audio columns")
55 + }
56 +
57 + /// The video pair as stored.
58 + async fn video_cols(pool: &sqlx::PgPool, item: ItemId) -> (Option<String>, Option<i64>) {
59 + sqlx::query_as("SELECT video_s3_key, video_file_size_bytes FROM items WHERE id = $1")
60 + .bind(item)
61 + .fetch_one(pool)
62 + .await
63 + .expect("read video columns")
64 + }
65 +
66 + /// The cover triple as stored.
67 + #[allow(clippy::type_complexity)]
68 + async fn cover_cols(
69 + pool: &sqlx::PgPool,
70 + item: ItemId,
71 + ) -> (Option<String>, Option<String>, Option<i64>) {
72 + sqlx::query_as(
73 + "SELECT cover_image_url, cover_s3_key, cover_file_size_bytes FROM items WHERE id = $1",
74 + )
75 + .bind(item)
76 + .fetch_one(pool)
77 + .await
78 + .expect("read cover columns")
79 + }
80 +
81 + // ── update_item_file_cas: the guarded write behind every storage credit ───────
82 +
83 + /// The redelivery case. A confirm that is delivered twice observes the same
84 + /// pre-state twice, so the second call carries the same `expected_old_key`. It
85 + /// must report `LostRace` and leave the row exactly as the winner wrote it,
86 + /// because the caller credits storage on `Committed` and would otherwise be
87 + /// charged twice for one object.
88 + #[tokio::test]
89 + async fn a_replayed_file_confirm_reports_a_lost_race_and_credits_nothing_twice() {
90 + let db = TestDb::new().await;
91 + let (owner, item) = owner_and_item(&db, "replay").await;
92 +
93 + let first = update_item_file_cas(
94 + &db.pool,
95 + item,
96 + owner,
97 + FileType::Audio,
98 + None,
99 + "staging/first.mp3",
100 + 7_340_032,
101 + )
102 + .await
103 + .expect("first confirm runs");
104 + assert_eq!(
105 + first,
106 + FileConfirmOutcome::Committed,
107 + "the first confirm against a NULL column must commit"
108 + );
109 +
110 + // The exact byte count matters: the caller credits this number against the
111 + // creator's quota, so an off-by-anything is a billing error.
112 + let after_first = audio_cols(&db.pool, item).await;
113 + assert_eq!(
114 + after_first,
115 + (Some("staging/first.mp3".to_string()), Some(7_340_032)),
116 + "committed confirm must store the exact key and size, got {after_first:?}"
117 + );
118 +
119 + // Same event delivered again: the column no longer holds NULL.
120 + let replay = update_item_file_cas(
121 + &db.pool,
122 + item,
123 + owner,
124 + FileType::Audio,
125 + None,
126 + "staging/first.mp3",
127 + 7_340_032,
128 + )
129 + .await
130 + .expect("replayed confirm runs");
131 + assert_eq!(
132 + replay,
133 + FileConfirmOutcome::LostRace,
134 + "a redelivered confirm must lose the CAS, not commit a second time"
135 + );
136 +
137 + let after_replay = audio_cols(&db.pool, item).await;
138 + assert_eq!(
139 + after_replay, after_first,
140 + "the replay must leave the row byte-identical, got {after_replay:?}"
141 + );
142 +
143 + // A genuine replace observes the current key and swaps it. The new size
144 + // REPLACES the old one: 2_097_152, not 9_437_184 (a summing bug) and not
145 + // 7_340_032 (a write that never landed).
146 + let replace = update_item_file_cas(
147 + &db.pool,
148 + item,
149 + owner,
150 + FileType::Audio,
151 + Some("staging/first.mp3"),
152 + "staging/second.mp3",
153 + 2_097_152,
154 + )
155 + .await
156 + .expect("replace confirm runs");
157 + assert_eq!(
158 + replace,
159 + FileConfirmOutcome::Committed,
160 + "a confirm carrying the current key must commit the swap"
161 + );
162 + let after_replace = audio_cols(&db.pool, item).await;
163 + assert_eq!(
164 + after_replace,
165 + (Some("staging/second.mp3".to_string()), Some(2_097_152)),
166 + "the replace must overwrite both columns, got {after_replace:?}"
167 + );
168 + }
169 +
170 + /// The ownership filter is part of the same predicate as the CAS, so a stranger
171 + /// holding the correct expected key still writes nothing. Asserted separately
172 + /// from the CAS because a regression could drop either half alone.
173 + #[tokio::test]
174 + async fn a_non_owner_file_confirm_loses_the_race_and_writes_nothing() {
175 + let db = TestDb::new().await;
176 + let (owner, item) = owner_and_item(&db, "owner").await;
177 + let stranger = seed_user(&db.pool, "media_stranger").await;
178 +
179 + update_item_file_cas(
180 + &db.pool,
181 + item,
182 + owner,
183 + FileType::Video,
184 + None,
185 + "staging/owned.mp4",
186 + 4_500_000,
187 + )
188 + .await
189 + .expect("owner confirm runs");
190 +
191 + let outcome = update_item_file_cas(
192 + &db.pool,
193 + item,
194 + stranger,
195 + FileType::Video,
196 + Some("staging/owned.mp4"),
197 + "staging/stolen.mp4",
198 + 99,
199 + )
200 + .await
201 + .expect("stranger confirm runs");
202 + assert_eq!(
203 + outcome,
204 + FileConfirmOutcome::LostRace,
205 + "a non-owner must not be able to swap another creator's file"
206 + );
207 +
208 + let cols = video_cols(&db.pool, item).await;
209 + assert_eq!(
210 + cols,
211 + (Some("staging/owned.mp4".to_string()), Some(4_500_000)),
212 + "the non-owner write must not land, got {cols:?}"
213 + );
214 + }
215 +
216 + /// Each file type owns exactly one column pair. A mapping that crossed audio and
217 + /// video would still "work" for a single-file item, so both are written on one
218 + /// item with distinct keys and distinct sizes and each pair is read back.
219 + #[tokio::test]
220 + async fn each_file_type_writes_only_its_own_column_pair() {
221 + let db = TestDb::new().await;
222 + let (owner, item) = owner_and_item(&db, "cols").await;
223 +
224 + update_item_file_cas(
225 + &db.pool,
226 + item,
227 + owner,
228 + FileType::Audio,
229 + None,
230 + "staging/a.mp3",
231 + 5_000_000,
232 + )
233 + .await
234 + .expect("audio confirm runs");
235 + update_item_file_cas(
236 + &db.pool,
237 + item,
238 + owner,
239 + FileType::Video,
240 + None,
241 + "staging/v.mp4",
242 + 3_000_000,
243 + )
244 + .await
245 + .expect("video confirm runs");
246 +
247 + let audio = audio_cols(&db.pool, item).await;
248 + let video = video_cols(&db.pool, item).await;
249 + assert_eq!(
250 + audio,
251 + (Some("staging/a.mp3".to_string()), Some(5_000_000)),
252 + "audio columns hold the audio confirm, got {audio:?}"
253 + );
254 + assert_eq!(
255 + video,
256 + (Some("staging/v.mp4".to_string()), Some(3_000_000)),
257 + "video columns hold the video confirm, got {video:?}"
258 + );
259 + // Neither generic confirm touches the cover triple.
260 + let cover = cover_cols(&db.pool, item).await;
261 + assert_eq!(
262 + cover,
263 + (None, None, None),
264 + "audio/video confirms must leave the cover columns alone, got {cover:?}"
265 + );
266 + }
267 +
268 + /// Types that need a third column (or another table) are refused rather than
269 + /// half-written. The error names the route the caller should have used, which is
270 + /// what makes the rejection actionable.
271 + #[tokio::test]
272 + async fn a_file_type_with_a_dedicated_route_is_refused_before_any_write() {
273 + let db = TestDb::new().await;
274 + let (owner, item) = owner_and_item(&db, "route").await;
275 +
276 + let err = update_item_file_cas(
277 + &db.pool,
278 + item,
279 + owner,
280 + FileType::Cover,
281 + None,
282 + "staging/cover.png",
283 + 640_000,
284 + )
285 + .await
286 + .expect_err("a cover must not be confirmable through the generic writer");
287 + let message = err.to_string();
288 + match err {
289 + AppError::Internal(inner) => {
290 + let detail = inner.to_string();
291 + assert!(
292 + detail.contains("/api/items/image/confirm"),
293 + "the refusal must name the dedicated route, got {detail}"
294 + );
295 + assert!(
296 + detail.contains("cover"),
297 + "the refusal must name the offending file type, got {detail}"
298 + );
299 + }
300 + other => panic!("expected AppError::Internal, got {other:?} ({message})"),
301 + }
302 +
303 + let cover = cover_cols(&db.pool, item).await;
304 + assert_eq!(
305 + cover,
306 + (None, None, None),
307 + "a refused confirm must not half-write the row, got {cover:?}"
308 + );
309 + }
310 +
311 + /// The function takes any executor so the confirm and the storage credit share
312 + /// one transaction. That is only worth anything if a rollback takes the file
313 + /// writeback with it.
314 + #[tokio::test]
315 + async fn a_file_confirm_rolled_back_with_its_transaction_leaves_no_write() {
316 + let db = TestDb::new().await;
317 + let (owner, item) = owner_and_item(&db, "tx").await;
318 +
319 + let mut tx = db.pool.begin().await.expect("begin");
320 + let outcome = update_item_file_cas(
321 + &mut *tx,
322 + item,
323 + owner,
324 + FileType::Audio,
325 + None,
326 + "staging/rolled-back.mp3",
327 + 8_800_000,
328 + )
329 + .await
330 + .expect("in-transaction confirm runs");
331 + assert_eq!(
332 + outcome,
333 + FileConfirmOutcome::Committed,
334 + "inside the transaction the CAS matches"
335 + );
336 + tx.rollback().await.expect("rollback");
337 +
338 + let cols = audio_cols(&db.pool, item).await;
339 + assert_eq!(
340 + cols,
341 + (None, None),
342 + "rolling back the storage credit must undo the file writeback too, got {cols:?}"
343 + );
344 + }
345 +
346 + // ── update_item_cover: the three-column write ────────────────────────────────
347 +
348 + /// The cover write is the one that must move three columns together, and it
349 + /// carries the same CAS as the audio/video path. A stale expectation must leave
350 + /// all three as the winner left them: a partial write here shows a cover whose
351 + /// url, key and size disagree.
352 + #[tokio::test]
353 + async fn a_cover_write_moves_url_key_and_size_together_and_guards_a_stale_key() {
354 + let db = TestDb::new().await;
355 + let (owner, item) = owner_and_item(&db, "cover").await;
356 +
357 + let first = items::update_item_cover(
358 + &db.pool,
359 + item,
360 + owner,
361 + None,
362 + "https://cdn.test/cover-one.png",
363 + "covers/one.png",
364 + 640_000,
365 + )
366 + .await
367 + .expect("first cover write runs");
368 + assert!(first, "the first cover write against NULL must land");
369 + let after_first = cover_cols(&db.pool, item).await;
370 + assert_eq!(
371 + after_first,
372 + (
373 + Some("https://cdn.test/cover-one.png".to_string()),
374 + Some("covers/one.png".to_string()),
375 + Some(640_000)
376 + ),
377 + "all three cover columns must be written, got {after_first:?}"
378 + );
379 +
380 + // A second confirm that still believes the cover is unset loses.
381 + let stale = items::update_item_cover(
382 + &db.pool,
383 + item,
384 + owner,
385 + None,
386 + "https://cdn.test/cover-two.png",
387 + "covers/two.png",
388 + 250_000,
389 + )
390 + .await
391 + .expect("stale cover write runs");
392 + assert!(
393 + !stale,
394 + "a cover confirm carrying a stale expected key must report no rows updated"
395 + );
396 + let after_stale = cover_cols(&db.pool, item).await;
397 + assert_eq!(
398 + after_stale, after_first,
399 + "the loser must not overwrite any of the three columns, got {after_stale:?}"
400 + );
401 +
402 + // The correct expectation replaces all three. 250_000 replaces 640_000; a
403 + // summing bug would read 890_000 and a dropped write 640_000.
404 + let replace = items::update_item_cover(
405 + &db.pool,
406 + item,
407 + owner,
408 + Some("covers/one.png"),
409 + "https://cdn.test/cover-two.png",
410 + "covers/two.png",
411 + 250_000,
412 + )
413 + .await
414 + .expect("cover replace runs");
415 + assert!(
416 + replace,
417 + "a cover confirm carrying the current key must land"
418 + );
419 + let after_replace = cover_cols(&db.pool, item).await;
420 + assert_eq!(
421 + after_replace,
422 + (
423 + Some("https://cdn.test/cover-two.png".to_string()),
424 + Some("covers/two.png".to_string()),
425 + Some(250_000)
426 + ),
427 + "the replace must swap all three columns, got {after_replace:?}"
428 + );
429 +
430 + // Ownership is the other half of the same predicate.
431 + let stranger = seed_user(&db.pool, "media_cover_stranger").await;
432 + let by_stranger = items::update_item_cover(
433 + &db.pool,
434 + item,
435 + stranger,
436 + Some("covers/two.png"),
437 + "https://cdn.test/stolen.png",
438 + "covers/stolen.png",
439 + 11,
440 + )
441 + .await
442 + .expect("stranger cover write runs");
443 + assert!(
444 + !by_stranger,
445 + "a non-owner cover write must report no rows updated"
446 + );
447 + let after_stranger = cover_cols(&db.pool, item).await;
448 + assert_eq!(
449 + after_stranger, after_replace,
450 + "the non-owner write must not land, got {after_stranger:?}"
451 + );
452 + }
453 +
454 + // ── size and metadata writebacks ─────────────────────────────────────────────
455 +
456 + /// `get_item_file_sizes` feeds the storage decrement on delete, so it must read
457 + /// the three columns into the three fields without crossing them, and a missing
458 + /// item must read as three `None`s rather than an error (the delete path calls
459 + /// it after the row may already be gone).
460 + #[tokio::test]
461 + async fn file_sizes_read_back_per_column_and_a_missing_item_reads_as_none() {
462 + let db = TestDb::new().await;
463 + let (_owner, item) = owner_and_item(&db, "sizes").await;
464 +
465 + // Three distinct values, so a column swap changes the answer.
466 + sqlx::query(
467 + "UPDATE items SET audio_file_size_bytes = 5000000,
468 + cover_file_size_bytes = 250000,
469 + video_file_size_bytes = 3000000
470 + WHERE id = $1",
471 + )
472 + .bind(item)
473 + .execute(&db.pool)
474 + .await
475 + .expect("seed the three sizes");
476 +
477 + let sizes = items::get_item_file_sizes(&db.pool, item)
478 + .await
479 + .expect("read sizes");
480 + assert_eq!(
481 + sizes.audio_file_size_bytes,
482 + Some(5_000_000),
483 + "audio size read from the audio column"
484 + );
485 + assert_eq!(
486 + sizes.cover_file_size_bytes,
487 + Some(250_000),
488 + "cover size read from the cover column"
489 + );
490 + assert_eq!(
491 + sizes.video_file_size_bytes,
492 + Some(3_000_000),
493 + "video size read from the video column"
494 + );
495 +
496 + let missing = items::get_item_file_sizes(&db.pool, ItemId::new())
497 + .await
498 + .expect("a missing item is not an error here");
499 + assert_eq!(
500 + (
Lines truncated
@@ -1,0 +1,319 @@
1 + //! DB-layer contract tests for `db::pending_uploads`, the tracking table that
2 + //! decides whether an abandoned upload's object is reclaimed or leaked.
3 + //!
4 + //! Every function in the module is `pub(crate)`, so its reclaim/expiry half is
5 + //! reached the way production reaches it, through the orphan-upload reaper
6 + //! (`TestHarness::run_orphan_upload_reaper`), which calls
7 + //! `get_stale_pending_uploads` and `delete_pending_uploads` and nothing else.
8 + //! Those two carry the retry machinery this table exists for, and they are what
9 + //! the assertions below are about: the age cutoff on both sides, oldest-first
10 + //! draining under the `STALE_UPLOAD_BATCH` cap, the `(s3_key, bucket)` pair
11 + //! scoping on the delete, and the reclaim case where a live row took the key
12 + //! back and the object must survive while the tracking row goes.
13 + //!
14 + //! Not asserted here, and deliberately not claimed: `record_pending_upload`,
15 + //! `record_pending_multipart_upload`, `declared_size`, `touch_pending_upload`,
16 + //! `remove_pending_upload` and `is_owned`, which an integration test can only
17 + //! reach through the presign handlers.
18 + //!
19 + //! Delete this file and a reaper that either deletes a reclaimed object or
20 + //! leaves an orphan behind forever passes silently.
21 +
22 + use crate::harness::{TestHarness, seed_project};
23 + use makenotwork::db::items::update_item_file_cas;
24 + use makenotwork::db::{ItemId, ProjectId, UserId};
25 + use makenotwork::storage::{FileType, StorageBackend};
26 + // ── helpers ──────────────────────────────────────────────────────────────────
27 +
28 + async fn seed_item(pool: &sqlx::PgPool, project: ProjectId, slug: &str) -> ItemId {
29 + sqlx::query_scalar::<_, ItemId>(
30 + "INSERT INTO items (project_id, title, item_type, price_cents, slug)
31 + VALUES ($1, $2, 'digital', 1000, $3) RETURNING id",
32 + )
33 + .bind(project)
34 + .bind(format!("Item {slug}"))
35 + .bind(slug)
36 + .fetch_one(pool)
37 + .await
38 + .expect("seed item")
39 + }
40 +
41 + /// The audio pair as stored.
42 + async fn audio_cols(pool: &sqlx::PgPool, item: ItemId) -> (Option<String>, Option<i64>) {
43 + sqlx::query_as("SELECT audio_s3_key, audio_file_size_bytes FROM items WHERE id = $1")
44 + .bind(item)
45 + .fetch_one(pool)
46 + .await
47 + .expect("read audio columns")
48 + }
49 +
50 + // ── db::pending_uploads: the expiry and reclaim half ─────────────────────────
51 + //
52 + // Reached through the reaper, which is the only production caller of
53 + // `get_stale_pending_uploads` and `delete_pending_uploads`.
54 +
55 + /// Insert a pending upload aged `hours` old, with its object staged in storage.
56 + async fn pending_upload(h: &TestHarness, user: UserId, key: &str, bucket: &str, hours: i32) {
57 + h.storage
58 + .as_ref()
59 + .expect("with_storage provides a backend")
60 + .put(key, b"orphan".to_vec());
61 + sqlx::query(
62 + "INSERT INTO pending_uploads (user_id, s3_key, bucket, created_at)
63 + VALUES ($1, $2, $3, NOW() - make_interval(hours => $4))",
64 + )
65 + .bind(user)
66 + .bind(key)
67 + .bind(bucket)
68 + .bind(hours)
69 + .execute(&h.db)
70 + .await
71 + .expect("insert pending upload");
72 + }
73 +
74 + async fn pending_rows(h: &TestHarness, key: &str, bucket: &str) -> i64 {
75 + sqlx::query_scalar("SELECT COUNT(*) FROM pending_uploads WHERE s3_key = $1 AND bucket = $2")
76 + .bind(key)
77 + .bind(bucket)
78 + .fetch_one(&h.db)
79 + .await
80 + .expect("count pending rows")
81 + }
82 +
83 + /// The cutoff is 24 hours and it is a strict age test, so both sides of it are
84 + /// asserted in one run: a 25-hour-old upload is reaped and a 23-hour-old one is
85 + /// left completely alone. A test that only checked the old row could not tell
86 + /// "older than 24h" from "every row".
87 + #[tokio::test]
88 + async fn the_reaper_takes_uploads_past_the_cutoff_and_leaves_younger_ones_untouched() {
89 + let mut h = TestHarness::with_storage().await;
90 + let user = h
91 + .signup("reaper_cutoff", "reaper_cutoff@test.com", "pass1234")
92 + .await;
93 + let storage = h.storage.clone().expect("with_storage provides a backend");
94 +
95 + pending_upload(&h, user, "staging/too-old.bin", "main", 25).await;
96 + pending_upload(&h, user, "staging/still-young.bin", "main", 23).await;
97 +
98 + h.run_orphan_upload_reaper().await;
99 +
100 + assert_eq!(
101 + pending_rows(&h, "staging/too-old.bin", "main").await,
102 + 0,
103 + "an upload older than the 24h cutoff must lose its tracking row"
104 + );
105 + assert!(
106 + !storage.object_exists("staging/too-old.bin").await.unwrap(),
107 + "and its object must be deleted"
108 + );
109 +
110 + assert_eq!(
111 + pending_rows(&h, "staging/still-young.bin", "main").await,
112 + 1,
113 + "an upload younger than the cutoff is still in flight and must be kept"
114 + );
115 + assert!(
116 + storage
117 + .object_exists("staging/still-young.bin")
118 + .await
119 + .unwrap(),
120 + "deleting a young upload's object would destroy a transfer in progress"
121 + );
122 + }
123 +
124 + /// The reaper is capped at `STALE_UPLOAD_BATCH` (200) rows per tick and drains
125 + /// oldest first, so a backlog cannot wedge the tick. 201 rows put the cap on both
126 + /// sides in one run: exactly one row survives, and it is the youngest, which is
127 + /// what distinguishes oldest-first from an unordered or newest-first scan.
128 + #[tokio::test]
129 + async fn the_reaper_drains_the_oldest_two_hundred_and_leaves_the_rest_for_the_next_tick() {
130 + let mut h = TestHarness::with_storage().await;
131 + let user = h
132 + .signup("reaper_batch", "reaper_batch@test.com", "pass1234")
133 + .await;
134 + let storage = h.storage.clone().expect("with_storage provides a backend");
135 +
136 + // 201 stale rows, ages 48h down to about 44h40m, so the ordering is total and
137 + // every one of them is past the 24h cutoff.
138 + for i in 0..201i32 {
139 + let key = format!("staging/batch-{i:03}.bin");
140 + storage.put(&key, b"orphan".to_vec());
141 + }
142 + sqlx::query(
143 + "INSERT INTO pending_uploads (user_id, s3_key, bucket, created_at)
144 + SELECT $1, 'staging/batch-' || to_char(i, 'FM000') || '.bin', 'main',
145 + NOW() - INTERVAL '48 hours' + make_interval(mins => i)
146 + FROM generate_series(0, 200) AS i",
147 + )
148 + .bind(user)
149 + .execute(&h.db)
150 + .await
151 + .expect("insert 201 stale uploads");
152 +
153 + h.run_orphan_upload_reaper().await;
154 +
155 + let remaining: Vec<String> =
156 + sqlx::query_scalar("SELECT s3_key FROM pending_uploads ORDER BY s3_key")
157 + .fetch_all(&h.db)
158 + .await
159 + .expect("read remaining rows");
160 + assert_eq!(
161 + remaining,
162 + vec!["staging/batch-200.bin".to_string()],
163 + "exactly the youngest row is left for the next tick, got {remaining:?}"
164 + );
165 + assert!(
166 + storage
167 + .object_exists("staging/batch-200.bin")
168 + .await
169 + .unwrap(),
170 + "the row that was not reaped keeps its object"
171 + );
172 + assert!(
173 + !storage
174 + .object_exists("staging/batch-000.bin")
175 + .await
176 + .unwrap(),
177 + "the oldest row is the first one drained"
178 + );
179 +
180 + // The next tick finishes the backlog, which is what makes the cap a drain
181 + // rather than a permanent leak.
182 + h.run_orphan_upload_reaper().await;
183 + let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pending_uploads")
184 + .fetch_one(&h.db)
185 + .await
186 + .expect("count remaining");
187 + assert_eq!(left, 0, "the second tick drains what the cap held back");
188 + }
189 +
190 + /// The uniqueness key is `(s3_key, bucket)`, so the same key can be pending in
191 + /// two buckets at once and the delete must match the pair. Only the main-bucket
192 + /// row is stale here: a delete keyed on `s3_key` alone would take the synckit
193 + /// row with it and the reaper would forget an upload that is still in flight.
194 + #[tokio::test]
195 + async fn reaping_one_bucket_leaves_another_buckets_row_for_the_same_key() {
196 + let mut h = TestHarness::with_storage().await;
197 + let user = h
198 + .signup("reaper_bucket", "reaper_bucket@test.com", "pass1234")
199 + .await;
200 +
201 + pending_upload(&h, user, "staging/shared-key.bin", "main", 30).await;
202 + // Young, and in a different bucket: not eligible for this reap at all.
203 + sqlx::query(
204 + "INSERT INTO pending_uploads (user_id, s3_key, bucket, created_at)
205 + VALUES ($1, 'staging/shared-key.bin', 'synckit', NOW())",
206 + )
207 + .bind(user)
208 + .execute(&h.db)
209 + .await
210 + .expect("insert synckit pending row");
211 +
212 + h.run_orphan_upload_reaper().await;
213 +
214 + assert_eq!(
215 + pending_rows(&h, "staging/shared-key.bin", "main").await,
216 + 0,
217 + "the stale main-bucket row is reaped"
218 + );
219 + assert_eq!(
220 + pending_rows(&h, "staging/shared-key.bin", "synckit").await,
221 + 1,
222 + "the synckit row shares the key but not the bucket and must survive"
223 + );
224 + }
225 +
226 + /// The reclaim path, which is why the reaper routes through the live-key guard.
227 + /// A confirm can take a staging key over onto a live item row before the reaper
228 + /// reaches the stale pending record. The object now belongs to that item, so it
229 + /// must survive; only the tracking row goes.
230 + #[tokio::test]
231 + async fn a_key_reclaimed_by_a_live_item_keeps_its_object_and_only_loses_the_pending_row() {
232 + let mut h = TestHarness::with_storage().await;
233 + let user = h
234 + .signup("reaper_reclaim", "reaper_reclaim@test.com", "pass1234")
235 + .await;
236 + let storage = h.storage.clone().expect("with_storage provides a backend");
237 +
238 + let project = seed_project(&h.db, user, "reclaim-proj").await;
239 + let item = seed_item(&h.db, project, "reclaim-item").await;
240 + pending_upload(&h, user, "staging/reclaimed.bin", "main", 40).await;
241 +
242 + // The confirm that beat the reaper: the key is now the item's live audio.
243 + update_item_file_cas(
244 + &h.db,
245 + item,
246 + user,
247 + FileType::Audio,
248 + None,
249 + "staging/reclaimed.bin",
250 + 3_300_000,
251 + )
252 + .await
253 + .expect("confirm reclaims the key");
254 +
255 + h.run_orphan_upload_reaper().await;
256 +
257 + assert!(
258 + storage
259 + .object_exists("staging/reclaimed.bin")
260 + .await
261 + .unwrap(),
262 + "the object is a live item file now; deleting it would destroy a paid-for upload"
263 + );
264 + assert_eq!(
265 + pending_rows(&h, "staging/reclaimed.bin", "main").await,
266 + 0,
267 + "the stale tracking row is still cleared, or the reaper retries it forever"
268 + );
269 + let queued: i64 =
270 + sqlx::query_scalar("SELECT COUNT(*) FROM pending_s3_deletions WHERE s3_key = $1")
271 + .bind("staging/reclaimed.bin")
272 + .fetch_one(&h.db)
273 + .await
274 + .expect("count queued deletions");
275 + assert_eq!(
276 + queued, 0,
277 + "a live key must not be handed to the deletion queue either"
278 + );
279 + let audio = audio_cols(&h.db, item).await;
280 + assert_eq!(
281 + audio,
282 + (Some("staging/reclaimed.bin".to_string()), Some(3_300_000)),
283 + "and the item still points at it, got {audio:?}"
284 + );
285 + }
286 +
287 + /// Running the reaper twice over the same backlog must delete each object once.
288 + /// The tracking rows are gone after the first pass, so the second finds nothing:
289 + /// this is the replay guard on the reap itself.
290 + #[tokio::test]
291 + async fn a_second_reaper_pass_over_a_drained_backlog_deletes_nothing_again() {
292 + let mut h = TestHarness::with_storage().await;
293 + let user = h
294 + .signup("reaper_twice", "reaper_twice@test.com", "pass1234")
295 + .await;
296 + let storage = h.storage.clone().expect("with_storage provides a backend");
297 +
298 + pending_upload(&h, user, "staging/once-a.bin", "main", 26).await;
299 + pending_upload(&h, user, "staging/once-b.bin", "main", 27).await;
300 +
301 + h.run_orphan_upload_reaper().await;
302 + let after_first = storage.faults().calls("delete_object");
303 + assert_eq!(
304 + after_first, 2,
305 + "the first pass deletes each of the two orphans exactly once"
306 + );
307 +
308 + h.run_orphan_upload_reaper().await;
309 + assert_eq!(
310 + storage.faults().calls("delete_object"),
311 + after_first,
312 + "the second pass has no rows to act on and must issue no further deletes"
313 + );
314 + let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pending_uploads")
315 + .fetch_one(&h.db)
316 + .await
317 + .expect("count remaining");
318 + assert_eq!(left, 0, "nothing is left pending");
319 + }
@@ -1,0 +1,767 @@
1 + //! DB-layer contract tests for the money read-models:
2 + //! `db::transactions::revenue_stats` and `db::transactions::seller_contacts`.
3 + //!
4 + //! Revenue is what a creator is told they earned and what the platform reports
5 + //! to itself, so the aggregates are pinned with several distinct, non-round
6 + //! amounts per group: a wrong aggregate operator (max instead of sum, a count
7 + //! standing in for a total, a refunded row leaking into revenue) lands on a
8 + //! different number rather than agreeing by accident. The empty case is pinned
9 + //! separately, because zero rows is the one input where every wrong aggregate
10 + //! agrees with the right one.
11 + //!
12 + //! Contacts are a creator's fan list, which is the data a fan can withdraw:
13 + //! these pin that sharing is opt-in, that a revocation removes a buyer from
14 + //! every contact surface, that the platform-notification query deliberately
15 + //! ignores both, and that the paginated export walks each buyer exactly once.
16 + //!
17 + //! The Fan+ subscription store that used to share this file is
18 + //! `db_fan_plus_layer`.
19 + //!
20 + //! Delete this file and nothing checks that completed-only,
21 + //! currency-separated revenue is what the dashboards read, or that a revoked
22 + //! fan stays revoked.
23 +
24 + use crate::harness::db::TestDb;
25 + use crate::harness::seed_user;
26 + use makenotwork::currency::SettlementCurrency;
27 + use makenotwork::db::{ItemId, ProjectId, UserId, transactions};
28 +
29 + // ── seeding ──
30 +
31 + /// Insert a project owned by `user` with a distinguishable title.
32 + async fn seed_titled_project(
33 + pool: &sqlx::PgPool,
34 + user: UserId,
35 + slug: &str,
36 + title: &str,
37 + ) -> ProjectId {
38 + sqlx::query_scalar::<_, ProjectId>(
39 + "INSERT INTO projects (user_id, slug, title) VALUES ($1, $2, $3) RETURNING id",
40 + )
41 + .bind(user)
42 + .bind(slug)
43 + .bind(title)
44 + .fetch_one(pool)
45 + .await
46 + .expect("seed project")
47 + }
48 +
49 + async fn seed_item(pool: &sqlx::PgPool, project: ProjectId, slug: &str) -> ItemId {
50 + sqlx::query_scalar::<_, ItemId>(
51 + "INSERT INTO items (project_id, title, item_type, price_cents, slug)
52 + VALUES ($1, $2, 'digital', 1000, $3) RETURNING id",
53 + )
54 + .bind(project)
55 + .bind(format!("Item {slug}"))
56 + .bind(slug)
57 + .fetch_one(pool)
58 + .await
59 + .expect("seed item")
60 + }
61 +
62 + /// One transaction row, described by the fields these read-models group on.
63 + struct Sale {
64 + buyer: UserId,
65 + seller: UserId,
66 + item: ItemId,
67 + amount_cents: i32,
68 + currency: &'static str,
69 + status: &'static str,
70 + share_contact: bool,
71 + /// How long ago the sale happened. Relative offsets rather than fixed
72 + /// timestamps, so ordering is deterministic without any wall-clock
73 + /// assumption beyond "NOW() moves forward".
74 + hours_ago: i32,
75 + }
76 +
77 + impl Sale {
78 + /// A completed USD sale, the shape most of these tests vary from.
79 + fn completed(buyer: UserId, seller: UserId, item: ItemId, amount_cents: i32) -> Self {
80 + Sale {
81 + buyer,
82 + seller,
83 + item,
84 + amount_cents,
85 + currency: "usd",
86 + status: "completed",
87 + share_contact: false,
88 + hours_ago: 1,
89 + }
90 + }
91 +
92 + async fn insert(&self, pool: &sqlx::PgPool, session: &str) {
93 + sqlx::query(
94 + "INSERT INTO transactions
95 + (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, currency,
96 + status, stripe_checkout_session_id, item_title, seller_username, share_contact,
97 + created_at, completed_at)
98 + VALUES ($1, $2, $3, $4, 0, $5, $6, $7, 'Item', 'seller', $8,
99 + NOW() - make_interval(hours => $9::int),
100 + CASE WHEN $6 IN ('completed', 'refunded')
101 + THEN NOW() - make_interval(hours => $9::int) END)",
102 + )
103 + .bind(self.buyer)
104 + .bind(self.seller)
105 + .bind(self.item)
106 + .bind(self.amount_cents)
107 + .bind(self.currency)
108 + .bind(self.status)
109 + .bind(session)
110 + .bind(self.share_contact)
111 + .bind(self.hours_ago)
112 + .execute(pool)
113 + .await
114 + .expect("seed transaction");
115 + }
116 + }
117 +
118 + // ── revenue_stats ──
119 +
120 + #[tokio::test]
121 + async fn project_revenue_sums_completed_sales_and_ignores_every_other_status() {
122 + let db = TestDb::new().await;
123 + let seller = seed_user(&db.pool, "rev_proj_seller").await;
124 + let buyer = seed_user(&db.pool, "rev_proj_buyer").await;
125 + let project = seed_titled_project(&db.pool, seller, "rev-proj", "Rev").await;
126 + let item = seed_item(&db.pool, project, "rev-a").await;
127 +
128 + // Three completed sales at distinct, non-round amounts: 1234 + 5678 + 9012.
129 + // No two of them share a digit pattern that a max/min/first-row read would
130 + // land on, and the total (15924) differs from any single row and from any
131 + // pairwise sum.
132 + //
133 + // One item per completed sale, all three inside this project. The schema
134 + // carries `idx_transactions_buyer_item_completed`, a unique index on
135 + // (buyer_id, item_id) WHERE status = 'completed', so one buyer cannot hold
136 + // two completed rows for the same item; the aggregate groups on the
137 + // project, which is unaffected.
138 + for (i, amount) in [1234, 5678, 9012].into_iter().enumerate() {
139 + let sold = seed_item(&db.pool, project, &format!("rev-sale-{i}")).await;
140 + Sale::completed(buyer, seller, sold, amount)
141 + .insert(&db.pool, &format!("cs_rev_proj_{i}"))
142 + .await;
143 + }
144 + // Three rows that must contribute nothing. Their amounts are distinct from
145 + // each other, so leaking any single one moves the total to a different
146 + // wrong number rather than to a shared one.
147 + for (status, amount) in [("pending", 7777), ("refunded", 4444), ("failed", 3333)] {
148 + Sale {
149 + status,
150 + ..Sale::completed(buyer, seller, item, amount)
151 + }
152 + .insert(&db.pool, &format!("cs_rev_proj_{status}"))
153 + .await;
154 + }
155 +
156 + let (revenue, sales) = transactions::get_revenue_by_project(&db.pool, project)
157 + .await
158 + .expect("project revenue query ok");
159 +
160 + assert_eq!(
161 + revenue.in_currency(SettlementCurrency::Usd),
162 + 15_924,
163 + "completed revenue is the sum of 1234 + 5678 + 9012, with pending, refunded and failed excluded"
164 + );
165 + assert_eq!(
166 + revenue.currency_count(),
167 + 1,
168 + "one seller settling in one currency reports one currency"
169 + );
170 + assert_eq!(
171 + sales, 3,
172 + "the sales count counts completed rows only, not all six"
173 + );
174 + }
175 +
176 + #[tokio::test]
177 + async fn project_revenue_is_empty_rather_than_zero_currency_noise_with_no_sales() {
178 + let db = TestDb::new().await;
179 + let seller = seed_user(&db.pool, "rev_empty_seller").await;
180 + let project = seed_titled_project(&db.pool, seller, "rev-empty", "Empty").await;
181 + seed_item(&db.pool, project, "rev-empty-a").await;
182 +
183 + let (revenue, sales) = transactions::get_revenue_by_project(&db.pool, project)
184 + .await
185 + .expect("project revenue query ok");
186 +
187 + assert!(
188 + revenue.is_empty(),
189 + "a project with no transactions reports no money in any currency"
190 + );
191 + assert_eq!(
192 + revenue.currency_count(),
193 + 0,
194 + "no sales must not invent a zero-valued currency entry"
195 + );
196 + assert_eq!(revenue.in_currency(SettlementCurrency::Usd), 0);
197 + assert_eq!(sales, 0, "no completed rows means no sales");
198 + }
199 +
200 + #[tokio::test]
201 + async fn project_revenue_keeps_two_currencies_apart_instead_of_adding_them() {
202 + let db = TestDb::new().await;
203 + let seller = seed_user(&db.pool, "rev_cur_seller").await;
204 + let buyer = seed_user(&db.pool, "rev_cur_buyer").await;
205 + let project = seed_titled_project(&db.pool, seller, "rev-cur", "Cur").await;
206 +
207 + // Two sales in each currency, so both groups exercise the SUM rather than
208 + // reading a single row: usd 2500 + 1725 = 4225, gbp 3175 + 1025 = 4200.
209 + // The two totals are close but unequal, so swapping the groups is visible,
210 + // and their sum (8425) is a third distinct number.
211 + for (i, (amount, currency)) in [(2500, "usd"), (1725, "usd"), (3175, "gbp"), (1025, "gbp")]
212 + .into_iter()
213 + .enumerate()
214 + {
215 + // One item per completed row: the same buyer may hold only one
216 + // completed transaction per item (idx_transactions_buyer_item_completed).
217 + let sold = seed_item(&db.pool, project, &format!("rev-cur-{i}")).await;
218 + Sale {
219 + currency,
220 + ..Sale::completed(buyer, seller, sold, amount)
221 + }
222 + .insert(&db.pool, &format!("cs_rev_cur_{i}"))
223 + .await;
224 + }
225 +
226 + let (revenue, sales) = transactions::get_revenue_by_project(&db.pool, project)
227 + .await
228 + .expect("project revenue query ok");
229 +
230 + assert_eq!(
231 + revenue.currency_count(),
232 + 2,
233 + "pounds and dollars are reported as two totals, never one"
234 + );
235 + assert_eq!(
236 + revenue.in_currency(SettlementCurrency::Usd),
237 + 4225,
238 + "the dollar total is the dollar rows only"
239 + );
240 + assert_eq!(
241 + revenue.in_currency(SettlementCurrency::Gbp),
242 + 4200,
243 + "the pound total is the pound rows only"
244 + );
245 + assert_eq!(
246 + revenue.in_currency(SettlementCurrency::Eur),
247 + 0,
248 + "a currency with no sales reports nothing, not the other currencies' money"
249 + );
250 + assert_eq!(sales, 4, "the count is currency-free and spans both groups");
251 + }
252 +
253 + #[tokio::test]
254 + async fn user_project_revenue_ranks_earning_projects_and_drops_the_rest() {
255 + let db = TestDb::new().await;
256 + let seller = seed_user(&db.pool, "rev_user_seller").await;
257 + let buyer = seed_user(&db.pool, "rev_user_buyer").await;
258 + let other_seller = seed_user(&db.pool, "rev_user_other").await;
259 +
260 + let top = seed_titled_project(&db.pool, seller, "rev-top", "Top Seller").await;
261 + let mid = seed_titled_project(&db.pool, seller, "rev-mid", "Mid Seller").await;
262 + let quiet = seed_titled_project(&db.pool, seller, "rev-quiet", "Quiet").await;
263 + let foreign = seed_titled_project(&db.pool, other_seller, "rev-foreign", "Foreign").await;
264 +
265 + // Two items per earning project, because one buyer may hold only one
266 + // completed transaction per item (idx_transactions_buyer_item_completed).
267 + // Both sales still land in the same project, which is what the roll-up
268 + // groups on.
269 + let top_item_a = seed_item(&db.pool, top, "top-a").await;
270 + let top_item_b = seed_item(&db.pool, top, "top-b").await;
271 + let mid_item_usd = seed_item(&db.pool, mid, "mid-a").await;
272 + let mid_item_gbp = seed_item(&db.pool, mid, "mid-b").await;
273 + let quiet_item = seed_item(&db.pool, quiet, "quiet-a").await;
274 + let foreign_item = seed_item(&db.pool, foreign, "foreign-a").await;
275 +
276 + // Top: 5075 + 2425 = 7500 usd, two rows so the fold is exercised.
277 + Sale::completed(buyer, seller, top_item_a, 5075)
278 + .insert(&db.pool, "cs_rev_user_top_a")
279 + .await;
280 + Sale::completed(buyer, seller, top_item_b, 2425)
281 + .insert(&db.pool, "cs_rev_user_top_b")
282 + .await;
283 + // Mid: two currencies, both below the top project's single total, so the
284 + // ordering cannot be explained by "whichever project has more rows".
285 + Sale::completed(buyer, seller, mid_item_usd, 4200)
286 + .insert(&db.pool, "cs_rev_user_mid_usd")
287 + .await;
288 + Sale {
289 + currency: "gbp",
290 + ..Sale::completed(buyer, seller, mid_item_gbp, 6000)
291 + }
292 + .insert(&db.pool, "cs_rev_user_mid_gbp")
293 + .await;
294 + // Quiet: a pending sale only. The HAVING clause must drop it entirely.
295 + Sale {
296 + status: "pending",
297 + ..Sale::completed(buyer, seller, quiet_item, 9999)
298 + }
299 + .insert(&db.pool, "cs_rev_user_quiet")
300 + .await;
301 + // Another creator's earning project must not appear in this creator's list.
302 + Sale::completed(buyer, other_seller, foreign_item, 8888)
303 + .insert(&db.pool, "cs_rev_user_foreign")
304 + .await;
305 +
306 + let rows = transactions::get_revenue_by_user_projects(&db.pool, seller)
307 + .await
308 + .expect("user project revenue query ok");
309 +
310 + assert_eq!(
311 + rows.len(),
312 + 2,
313 + "only the two projects with completed revenue are listed, got {:?}",
314 + rows.iter().map(|(_, t, _)| t.clone()).collect::<Vec<_>>()
315 + );
316 + assert_eq!(
317 + rows[0].0, top,
318 + "the largest single-currency total ranks first"
319 + );
320 + assert_eq!(rows[0].1, "Top Seller");
321 + assert_eq!(
322 + rows[0].2.in_currency(SettlementCurrency::Usd),
323 + 7500,
324 + "the top project's two sales are summed, not counted"
325 + );
326 + assert_eq!(rows[1].0, mid);
327 + assert_eq!(
328 + rows[1].2.currency_count(),
329 + 2,
330 + "a project that sold in two currencies keeps both"
331 + );
332 + assert_eq!(rows[1].2.in_currency(SettlementCurrency::Gbp), 6000);
333 + assert_eq!(rows[1].2.in_currency(SettlementCurrency::Usd), 4200);
334 + }
335 +
336 + #[tokio::test]
337 + async fn platform_revenue_stats_separate_currencies_and_count_refunds_apart() {
338 + let db = TestDb::new().await;
339 + let seller = seed_user(&db.pool, "plat_seller").await;
340 + let buyer = seed_user(&db.pool, "plat_buyer").await;
341 + let project = seed_titled_project(&db.pool, seller, "plat", "Platform").await;
342 + let item = seed_item(&db.pool, project, "plat-a").await;
343 +
344 + // usd completed: 1234 + 5678 = 6912. gbp completed: 3175.
345 + // Refunded rows carry their own distinct amounts (4444, 2222): if a refund
346 + // leaked into revenue the dollar total would be 13578 or 11134, never 6912.
347 + for (i, (amount, currency, status)) in [
348 + (1234, "usd", "completed"),
349 + (5678, "usd", "completed"),
350 + (3175, "gbp", "completed"),
351 + (4444, "usd", "refunded"),
352 + (2222, "gbp", "refunded"),
353 + (7777, "usd", "pending"),
354 + ]
355 + .into_iter()
356 + .enumerate()
357 + {
358 + // Completed rows get an item each: one buyer may hold only one
359 + // completed transaction per item (idx_transactions_buyer_item_completed).
360 + // Refunded and pending rows are outside that partial index, and this
361 + // roll-up is platform-wide, so which item they name is immaterial.
362 + let sold = if status == "completed" {
363 + seed_item(&db.pool, project, &format!("plat-{i}")).await
364 + } else {
365 + item
366 + };
367 + Sale {
368 + currency,
369 + status,
370 + ..Sale::completed(buyer, seller, sold, amount)
371 + }
372 + .insert(&db.pool, &format!("cs_plat_{i}"))
373 + .await;
374 + }
375 +
376 + let (revenue, completed, refunded) = transactions::get_platform_revenue_stats(&db.pool)
377 + .await
378 + .expect("platform revenue query ok");
379 +
380 + assert_eq!(
381 + revenue.in_currency(SettlementCurrency::Usd),
382 + 6912,
383 + "dollar revenue counts the two completed dollar sales only"
384 + );
385 + assert_eq!(
386 + revenue.in_currency(SettlementCurrency::Gbp),
387 + 3175,
388 + "pound revenue counts the completed pound sale only"
389 + );
390 + assert_eq!(
391 + revenue.currency_count(),
392 + 2,
393 + "the platform roll-up spans currencies without adding them"
394 + );
395 + assert_eq!(
396 + completed, 3,
397 + "three completed rows across both currencies, counts add where money does not"
398 + );
399 + assert_eq!(
400 + refunded, 2,
401 + "both refunded rows are counted, in whichever currency they were written"
402 + );
403 + }
404 +
405 + #[tokio::test]
406 + async fn item_sales_list_completed_and_refunded_rows_for_that_seller_newest_first() {
407 + let db = TestDb::new().await;
408 + let seller = seed_user(&db.pool, "item_sales_seller").await;
409 + let rogue = seed_user(&db.pool, "item_sales_rogue").await;
410 + let buyer = seed_user(&db.pool, "item_sales_buyer").await;
411 + // A second buyer for the other seller's row on this item: the same buyer
412 + // may hold only one completed transaction per item
413 + // (idx_transactions_buyer_item_completed), and the point of that row is the
414 + // seller predicate, not the buyer.
415 + let rogue_buyer = seed_user(&db.pool, "item_sales_rogue_buyer").await;
416 + let project = seed_titled_project(&db.pool, seller, "item-sales", "Sales").await;
417 + let item = seed_item(&db.pool, project, "sales-a").await;
418 + let other_item = seed_item(&db.pool, project, "sales-b").await;
419 +
420 + // Newest completed sale, then an older refund: both belong in the tab.
421 + Sale {
422 + hours_ago: 1,
423 + ..Sale::completed(buyer, seller, item, 1500)
424 + }
425 + .insert(&db.pool, "cs_item_sales_new")
426 + .await;
427 + Sale {
428 + status: "refunded",
429 + hours_ago: 5,
430 + ..Sale::completed(buyer, seller, item, 2600)
431 + }
432 + .insert(&db.pool, "cs_item_sales_refunded")
433 + .await;
434 + // Excluded: still pending, a different item, and a row tagged to another seller.
435 + Sale {
436 + status: "pending",
437 + hours_ago: 2,
438 + ..Sale::completed(buyer, seller, item, 3700)
439 + }
440 + .insert(&db.pool, "cs_item_sales_pending")
441 + .await;
442 + Sale {
443 + hours_ago: 3,
444 + ..Sale::completed(buyer, seller, other_item, 4800)
445 + }
446 + .insert(&db.pool, "cs_item_sales_other_item")
447 + .await;
448 + Sale {
449 + hours_ago: 4,
450 + ..Sale::completed(rogue_buyer, rogue, item, 5900)
451 + }
452 + .insert(&db.pool, "cs_item_sales_other_seller")
453 + .await;
454 +
455 + let rows = transactions::get_sales_by_item(&db.pool, item, seller)
456 + .await
457 + .expect("item sales query ok");
458 +
459 + let amounts: Vec<i64> = rows.iter().map(|t| t.amount_cents.as_i64()).collect();
460 + assert_eq!(
461 + amounts,
462 + vec![1500, 2600],
463 + "completed and refunded sales of this item by this seller, newest first, got {amounts:?}"
464 + );
465 + assert!(
466 + rows.iter().all(|t| t.seller_id == Some(seller)),
467 + "another seller's row on the same item is never listed"
468 + );
469 + }
470 +
471 + // ── seller_contacts ──
472 +
473 + #[tokio::test]
474 + async fn seller_contacts_aggregate_only_shared_completed_purchases() {
475 + let db = TestDb::new().await;
476 + let seller = seed_user(&db.pool, "contacts_seller").await;
477 + let sharer = seed_user(&db.pool, "contacts_sharer").await;
478 + let revoker = seed_user(&db.pool, "contacts_revoker").await;
479 + let private_buyer = seed_user(&db.pool, "contacts_private").await;
480 + let project = seed_titled_project(&db.pool, seller, "contacts", "Contacts").await;
481 + let item = seed_item(&db.pool, project, "contacts-a").await;
482 +
483 + // The sharer bought twice: 1975 + 2225 = 4200 across two purchases. One
484 + // item each, because the same buyer may hold only one completed
485 + // transaction per item (idx_transactions_buyer_item_completed); the
486 + // contact roll-up groups by buyer, so the split changes nothing it reads.
487 + for (i, (amount, hours_ago)) in [(1975, 6), (2225, 2)].into_iter().enumerate() {
488 + let sold = seed_item(&db.pool, project, &format!("contacts-sharer-{i}")).await;
489 + Sale {
490 + share_contact: true,
491 + hours_ago,
492 + ..Sale::completed(sharer, seller, sold, amount)
493 + }
494 + .insert(&db.pool, &format!("cs_contacts_sharer_{i}"))
495 + .await;
496 + }
497 + // A pending purchase of theirs must not inflate either figure: 9999 would
498 + // be visible in both the total (14199) and the count (3).
499 + Sale {
500 + share_contact: true,
Lines truncated
@@ -1,0 +1,455 @@
1 + //! DB-layer contract tests for the sealed access gate in `db::subscriptions`.
2 + //!
3 + //! `subscriptions.rs` is the biggest money module in `db/` and it carried no
4 + //! test of its own: its `#[cfg(test)]` block holds a test-only gate constructor
5 + //! and nothing else. What the gate decides is who may reach paid content, so
6 + //! these pin its clauses one at a time: active AND unpaused AND inside the paid
7 + //! period, scoped to one subscriber and one target, with every non-active
8 + //! status denied on the other side of the boundary.
9 + //!
10 + //! The webhook-driven lifecycle writes of the same module are
11 + //! `db_subscriptions_lifecycle_layer`.
12 + //!
13 + //! Reach: every function in `db::subscriptions` is `pub(crate)`, so the
14 + //! integration test crate cannot call the module directly. Each test below
15 + //! drives the real production call site nearest to the function under test
16 + //! (db::items::check_item_access for the item gate, whose `subscription` field
17 + //! is filled by `SubscriptionGate::check` and nothing else; the subscribe route
18 + //! for the project-scope `has_access`) and asserts on what those calls answer.
19 + //!
20 + //! Delete this file and the gate's period and pause clauses become silently
21 + //! editable: nothing else asserts them at this layer.
22 +
23 + use crate::harness::db::TestDb;
24 + use crate::harness::{TestHarness, seed_project, seed_user};
25 + use chrono::{DateTime, Duration, Utc};
26 + use makenotwork::db::{self, ItemId, ProjectId, SubscriptionTierId, UserId};
27 + use serde_json::Value;
28 + use sqlx::PgPool;
29 +
30 + // ── seeding ──
31 +
32 + /// An item in `project`. `slug` is unique per project, so callers pass one.
33 + async fn seed_item(pool: &PgPool, project: ProjectId, slug: &str) -> ItemId {
34 + sqlx::query_scalar::<_, ItemId>(
35 + "INSERT INTO items (project_id, title, item_type, price_cents, slug)
36 + VALUES ($1, 'Gated Item', 'audio', 1500, $2) RETURNING id",
37 + )
38 + .bind(project)
39 + .bind(slug)
40 + .fetch_one(pool)
41 + .await
42 + .expect("seed item")
43 + }
44 +
45 + /// An item-scoped tier. `tier_exactly_one_target` forbids setting `project_id`
46 + /// as well, which is why the two seeders below are separate.
47 + async fn seed_item_tier(pool: &PgPool, item: ItemId) -> SubscriptionTierId {
48 + sqlx::query_scalar::<_, SubscriptionTierId>(
49 + "INSERT INTO subscription_tiers (item_id, name, price_cents)
50 + VALUES ($1, 'Item Tier', 1500) RETURNING id",
51 + )
52 + .bind(item)
53 + .fetch_one(pool)
54 + .await
55 + .expect("seed item tier")
56 + }
57 +
58 + async fn seed_project_tier(pool: &PgPool, project: ProjectId) -> SubscriptionTierId {
59 + sqlx::query_scalar::<_, SubscriptionTierId>(
60 + "INSERT INTO subscription_tiers (project_id, name, price_cents)
61 + VALUES ($1, 'Project Tier', 1500) RETURNING id",
62 + )
63 + .bind(project)
64 + .fetch_one(pool)
65 + .await
66 + .expect("seed project tier")
67 + }
68 +
69 + /// An item-scoped subscription row (`project_id` NULL, per `sub_exactly_one_target`).
70 + async fn seed_item_subscription(
71 + pool: &PgPool,
72 + subscriber: UserId,
73 + tier: SubscriptionTierId,
74 + item: ItemId,
75 + stripe_id: &str,
76 + status: &str,
77 + period_end: Option<DateTime<Utc>>,
78 + ) {
79 + sqlx::query(
80 + "INSERT INTO subscriptions
81 + (subscriber_id, tier_id, item_id, stripe_subscription_id, stripe_customer_id,
82 + status, current_period_start, current_period_end)
83 + VALUES ($1, $2, $3, $4, 'cus_gate_seed', $5, NOW() - interval '1 day', $6)",
84 + )
85 + .bind(subscriber)
86 + .bind(tier)
87 + .bind(item)
88 + .bind(stripe_id)
89 + .bind(status)
90 + .bind(period_end)
91 + .execute(pool)
92 + .await
93 + .expect("seed item subscription");
94 + }
95 +
96 + /// A project-scoped subscription row (`item_id` NULL).
97 + async fn seed_project_subscription(
98 + pool: &PgPool,
99 + subscriber: UserId,
100 + tier: SubscriptionTierId,
101 + project: ProjectId,
102 + stripe_id: &str,
103 + status: &str,
104 + period_end: Option<DateTime<Utc>>,
105 + ) {
106 + sqlx::query(
107 + "INSERT INTO subscriptions
108 + (subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id,
109 + status, current_period_start, current_period_end)
110 + VALUES ($1, $2, $3, $4, 'cus_gate_seed', $5, NOW() - interval '1 day', $6)",
111 + )
112 + .bind(subscriber)
113 + .bind(tier)
114 + .bind(project)
115 + .bind(stripe_id)
116 + .bind(status)
117 + .bind(period_end)
118 + .execute(pool)
119 + .await
120 + .expect("seed project subscription");
121 + }
122 +
123 + /// Does the sealed gate currently grant `user` access to `item`?
124 + ///
125 + /// `check_item_access` asks `SubscriptionGate::check` and nothing else for the
126 + /// `subscription` field, so this reads the sealed predicate and not a copy of it.
127 + async fn item_gate_grants(pool: &PgPool, item: ItemId, user: Option<UserId>) -> bool {
128 + db::items::check_item_access(pool, item, user)
129 + .await
130 + .expect("check_item_access ok")
131 + .expect("item exists")
132 + .subscription
133 + .is_some()
134 + }
135 +
136 + // ── the sealed access gate: what a lapsed subscriber may still reach ──
137 +
138 + #[tokio::test]
139 + async fn item_gate_grants_access_only_while_the_paid_period_is_unexpired() {
140 + let db = TestDb::new().await;
141 + let creator = seed_user(&db.pool, "gate_period_creator").await;
142 + let project = seed_project(&db.pool, creator, "gate-period").await;
143 + let item = seed_item(&db.pool, project, "gate-period-item").await;
144 + let tier = seed_item_tier(&db.pool, item).await;
145 + let fan = seed_user(&db.pool, "gate_period_fan").await;
146 +
147 + // Paid through: three days of period left.
148 + seed_item_subscription(
149 + &db.pool,
150 + fan,
151 + tier,
152 + item,
153 + "sub_gate_period",
154 + "active",
155 + Some(Utc::now() + Duration::days(3)),
156 + )
157 + .await;
158 + assert!(
159 + item_gate_grants(&db.pool, item, Some(fan)).await,
160 + "an active subscription three days from renewal must grant access"
161 + );
162 +
163 + // Three days PAST the period end, still `status = 'active'` because the
164 + // `customer.subscription.deleted` webhook was missed or delayed. The
165 + // `current_period_end > NOW()` half of the predicate is the whole reason
166 + // that case does not keep granting access, so both sides are asserted.
167 + sqlx::query(
168 + "UPDATE subscriptions SET current_period_end = $1 WHERE stripe_subscription_id = $2",
169 + )
170 + .bind(Utc::now() - Duration::days(3))
171 + .bind("sub_gate_period")
172 + .execute(&db.pool)
173 + .await
174 + .expect("expire the period");
175 + assert!(
176 + !item_gate_grants(&db.pool, item, Some(fan)).await,
177 + "an active row whose paid period ended three days ago must NOT grant access"
178 + );
179 +
180 + // A NULL period is the "Stripe has not told us a period yet" shape and is
181 + // explicitly permitted by the predicate; without this case the test could
182 + // not tell `> NOW()` from `IS NOT NULL AND > NOW()`.
183 + sqlx::query(
184 + "UPDATE subscriptions SET current_period_end = NULL WHERE stripe_subscription_id = $1",
185 + )
186 + .bind("sub_gate_period")
187 + .execute(&db.pool)
188 + .await
189 + .expect("null the period");
190 + assert!(
191 + item_gate_grants(&db.pool, item, Some(fan)).await,
192 + "a NULL current_period_end must grant access, not deny it"
193 + );
194 + }
195 +
196 + #[tokio::test]
197 + async fn item_gate_denies_a_paused_subscription_and_grants_again_once_resumed() {
198 + let db = TestDb::new().await;
199 + let creator = seed_user(&db.pool, "gate_pause_creator").await;
200 + let project = seed_project(&db.pool, creator, "gate-pause").await;
201 + let item = seed_item(&db.pool, project, "gate-pause-item").await;
202 + let tier = seed_item_tier(&db.pool, item).await;
203 + let fan = seed_user(&db.pool, "gate_pause_fan").await;
204 +
205 + seed_item_subscription(
206 + &db.pool,
207 + fan,
208 + tier,
209 + item,
210 + "sub_gate_pause",
211 + "active",
212 + Some(Utc::now() + Duration::days(20)),
213 + )
214 + .await;
215 + assert!(
216 + item_gate_grants(&db.pool, item, Some(fan)).await,
217 + "an unpaused in-period subscription grants access"
218 + );
219 +
220 + // Pausing is what a creator suspension does to every fan subscription: the
221 + // fan stops being billed, so the fan must also stop having access, even
222 + // though status stays 'active' and the period is still open.
223 + sqlx::query("UPDATE subscriptions SET paused_at = NOW() WHERE stripe_subscription_id = $1")
224 + .bind("sub_gate_pause")
225 + .execute(&db.pool)
226 + .await
227 + .expect("pause the subscription");
228 + assert!(
229 + !item_gate_grants(&db.pool, item, Some(fan)).await,
230 + "a paused subscription must not grant access while the creator is suspended"
231 + );
232 +
233 + sqlx::query("UPDATE subscriptions SET paused_at = NULL WHERE stripe_subscription_id = $1")
234 + .bind("sub_gate_pause")
235 + .execute(&db.pool)
236 + .await
237 + .expect("resume the subscription");
238 + assert!(
239 + item_gate_grants(&db.pool, item, Some(fan)).await,
240 + "resuming must restore access rather than leaving the fan locked out"
241 + );
242 + }
243 +
244 + #[tokio::test]
245 + async fn item_gate_grants_on_active_and_denies_every_other_status() {
246 + let db = TestDb::new().await;
247 + let creator = seed_user(&db.pool, "gate_status_creator").await;
248 + let project = seed_project(&db.pool, creator, "gate-status").await;
249 + let item = seed_item(&db.pool, project, "gate-status-item").await;
250 + let tier = seed_item_tier(&db.pool, item).await;
251 + let fan = seed_user(&db.pool, "gate_status_fan").await;
252 +
253 + seed_item_subscription(
254 + &db.pool,
255 + fan,
256 + tier,
257 + item,
258 + "sub_gate_status",
259 + "active",
260 + Some(Utc::now() + Duration::days(9)),
261 + )
262 + .await;
263 +
264 + // Every non-active status the column can hold. Walking all of them is what
265 + // separates "status = 'active'" from the weaker "status != 'canceled'":
266 + // trialing and past_due would pass the weaker predicate.
267 + for status in [
268 + "trialing",
269 + "incomplete",
270 + "incomplete_expired",
271 + "past_due",
272 + "unpaid",
273 + "canceled",
274 + ] {
275 + sqlx::query("UPDATE subscriptions SET status = $1 WHERE stripe_subscription_id = $2")
276 + .bind(status)
277 + .bind("sub_gate_status")
278 + .execute(&db.pool)
279 + .await
280 + .expect("set status");
281 + assert!(
282 + !item_gate_grants(&db.pool, item, Some(fan)).await,
283 + "status '{status}' must not grant access; only 'active' does"
284 + );
285 + }
286 +
287 + sqlx::query("UPDATE subscriptions SET status = 'active' WHERE stripe_subscription_id = $1")
288 + .bind("sub_gate_status")
289 + .execute(&db.pool)
290 + .await
291 + .expect("restore active");
292 + assert!(
293 + item_gate_grants(&db.pool, item, Some(fan)).await,
294 + "'active' grants access, so the loop above measured the status and not the fixture"
295 + );
296 + }
297 +
298 + #[tokio::test]
299 + async fn item_gate_is_scoped_to_one_subscriber_one_item_and_never_to_anonymous() {
300 + let db = TestDb::new().await;
301 + let creator = seed_user(&db.pool, "gate_scope_creator").await;
302 + let project = seed_project(&db.pool, creator, "gate-scope").await;
303 + let subscribed_item = seed_item(&db.pool, project, "gate-scope-paid").await;
304 + let other_item = seed_item(&db.pool, project, "gate-scope-other").await;
305 + let item_tier = seed_item_tier(&db.pool, subscribed_item).await;
306 + let project_tier = seed_project_tier(&db.pool, project).await;
307 + let fan = seed_user(&db.pool, "gate_scope_fan").await;
308 + let stranger = seed_user(&db.pool, "gate_scope_stranger").await;
309 +
310 + let period_end = Some(Utc::now() + Duration::days(14));
311 + seed_item_subscription(
312 + &db.pool,
313 + fan,
314 + item_tier,
315 + subscribed_item,
316 + "sub_gate_scope_item",
317 + "active",
318 + period_end,
319 + )
320 + .await;
321 + // A live PROJECT subscription held by the same fan. The item arm of the gate
322 + // keys on item_id, so this row must not leak access to a sibling item; if it
323 + // did, an item-priced work would be readable by anyone subscribed to the
324 + // project at any tier.
325 + seed_project_subscription(
326 + &db.pool,
327 + fan,
328 + project_tier,
329 + project,
330 + "sub_gate_scope_project",
331 + "active",
332 + period_end,
333 + )
334 + .await;
335 +
336 + assert!(
337 + item_gate_grants(&db.pool, subscribed_item, Some(fan)).await,
338 + "the fan's own item subscription grants access to that item"
339 + );
340 + assert!(
341 + !item_gate_grants(&db.pool, other_item, Some(fan)).await,
342 + "a subscription to one item must not grant access to a sibling item"
343 + );
344 + assert!(
345 + !item_gate_grants(&db.pool, subscribed_item, Some(stranger)).await,
346 + "another user must not inherit the fan's item subscription"
347 + );
348 + assert!(
349 + !item_gate_grants(&db.pool, subscribed_item, None).await,
350 + "an anonymous viewer holds no subscription and must never be granted one"
351 + );
352 + }
353 +
354 + // ── project-scope `has_access`: a lapsed subscriber is offered checkout again ──
355 +
356 + #[tokio::test]
357 + async fn a_lapsed_project_subscriber_is_sent_back_to_checkout_and_a_current_one_is_not() {
358 + let mut h = TestHarness::with_mocks().await;
359 +
360 + let creator = h
361 + .signup("gatecreator", "gatecreator@test.com", "password123")
362 + .await;
363 + h.grant_creator(creator).await;
364 + h.connect_stripe(creator, "acct_gate_route").await;
365 + h.client.post_form("/logout", "").await;
366 + h.login("gatecreator", "password123").await;
367 +
368 + let resp = h
369 + .client
370 + .post_form("/api/projects", "slug=gateroute&title=Gate+Route")
371 + .await;
372 + assert_eq!(resp.status, 200, "create project failed: {}", resp.text);
373 + let project: Value = resp.json();
374 + let project_id = project["id"].as_str().expect("project id").to_string();
375 + let project_uuid: ProjectId = project_id.parse().expect("project id parses");
376 +
377 + let tier_id: SubscriptionTierId = sqlx::query_scalar(
378 + "INSERT INTO subscription_tiers
379 + (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id)
380 + VALUES ($1, 'Gold', 1500, true, 'prod_gate', 'price_gate') RETURNING id",
381 + )
382 + .bind(project_uuid)
383 + .fetch_one(&h.db)
384 + .await
385 + .expect("seed tier");
386 +
387 + h.client.post_form("/logout", "").await;
388 + let fan = h.signup("gatefan", "gatefan@test.com", "password123").await;
389 +
390 + // A subscription that is paid up for another 30 days.
391 + seed_project_subscription(
392 + &h.db,
393 + fan,
394 + tier_id,
395 + project_uuid,
396 + "sub_gate_route",
397 + "active",
398 + Some(Utc::now() + Duration::days(30)),
399 + )
400 + .await;
401 +
402 + let resp = h
403 + .client
404 + .post_form(&format!("/stripe/subscribe/{tier_id}"), "")
405 + .await;
406 + assert_eq!(
407 + resp.status, 303,
408 + "subscribe should redirect, got {}: {}",
409 + resp.status, resp.text
410 + );
411 + assert_eq!(
412 + resp.header("location"),
413 + Some("/p/gateroute"),
414 + "an already-subscribed fan is bounced to the project page, not to Stripe: {}",
415 + resp.text
416 + );
417 + let checkouts = h.mock_stripe.as_ref().expect("mock stripe").checkouts();
418 + assert!(
419 + checkouts.is_empty(),
420 + "no second checkout session may be created for a current subscriber, found {checkouts:?}"
421 + );
422 +
423 + // Same row, period ended yesterday: the gate no longer grants access, so the
424 + // fan must be able to buy again. The two halves together are what stop both
425 + // a double charge and a permanent lockout.
426 + sqlx::query(
427 + "UPDATE subscriptions SET current_period_end = $1 WHERE stripe_subscription_id = $2",
428 + )
429 + .bind(Utc::now() - Duration::days(1))
430 + .bind("sub_gate_route")
431 + .execute(&h.db)
432 + .await
433 + .expect("expire the period");
434 +
435 + let resp = h
436 + .client
437 + .post_form(&format!("/stripe/subscribe/{tier_id}"), "")
438 + .await;
439 + assert_eq!(
440 + resp.status, 303,
441 + "subscribe should redirect, got {}: {}",
442 + resp.status, resp.text
443 + );
444 + let location = resp.header("location").unwrap_or_default().to_string();
445 + assert!(
446 + location.starts_with("https://checkout.stripe.com/"),
447 + "a lapsed subscriber must be sent to a fresh checkout, went to {location} instead"
448 + );
449 + let checkouts = h.mock_stripe.as_ref().expect("mock stripe").checkouts();
450 + assert_eq!(
451 + checkouts.len(),
452 + 1,
453 + "exactly one checkout session belongs to the lapsed attempt, got {checkouts:?}"
454 + );
455 + }
@@ -1,0 +1,505 @@
1 + //! DB-layer contract tests for the webhook-driven lifecycle writes in
2 + //! `db::subscriptions`.
3 + //!
4 + //! These are the writes Stripe drives, and Stripe redelivers, so what is pinned
5 + //! is what a second delivery must not do:
6 + //!
7 + //! - `create_subscription`'s single-live-row cleanup cancels exactly the
8 + //! lingering `past_due`/`trialing`/`incomplete` rows of the SAME subscriber
9 + //! and project, and nobody else's,
10 + //! - `cancel_subscription` under redelivery keeps the first `canceled_at`
11 + //! rather than restamping it, which is what `COALESCE(canceled_at, NOW())`
12 + //! is for,
13 + //! - `apply_stripe_update`'s period funnel writes a zero-length Stripe window
14 + //! and drops an inverted one while the status still lands.
15 + //!
16 + //! The access gate of the same module is `db_subscriptions_layer`.
17 + //!
18 + //! Reach: every function in `db::subscriptions` is `pub(crate)`, so these drive
19 + //! the Stripe webhook route, the module's own production caller, and assert on
20 + //! the rows the functions leave behind.
21 + //!
22 + //! Delete this file and a redelivered cancellation could slide the cancellation
23 + //! date forward, and the cleanup's WHERE could widen to another fan's rows,
24 + //! with nothing at this layer noticing.
25 +
26 + use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload};
27 + use crate::harness::{TestHarness, seed_project, seed_user};
28 + use chrono::{DateTime, Duration, Utc};
29 + use makenotwork::db::{ProjectId, SubscriptionTierId, UserId};
30 + use serde_json::Value;
31 + use sqlx::PgPool;
32 + use std::collections::HashMap;
33 +
34 + // ── seeding ──
35 +
36 + async fn seed_project_tier(pool: &PgPool, project: ProjectId) -> SubscriptionTierId {
37 + sqlx::query_scalar::<_, SubscriptionTierId>(
38 + "INSERT INTO subscription_tiers (project_id, name, price_cents)
39 + VALUES ($1, 'Project Tier', 1500) RETURNING id",
40 + )
41 + .bind(project)
42 + .fetch_one(pool)
43 + .await
44 + .expect("seed project tier")
45 + }
46 +
47 + /// A project-scoped subscription row (`item_id` NULL).
48 + async fn seed_project_subscription(
49 + pool: &PgPool,
50 + subscriber: UserId,
51 + tier: SubscriptionTierId,
52 + project: ProjectId,
53 + stripe_id: &str,
54 + status: &str,
55 + period_end: Option<DateTime<Utc>>,
56 + ) {
57 + sqlx::query(
58 + "INSERT INTO subscriptions
59 + (subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id,
60 + status, current_period_start, current_period_end)
61 + VALUES ($1, $2, $3, $4, 'cus_gate_seed', $5, NOW() - interval '1 day', $6)",
62 + )
63 + .bind(subscriber)
64 + .bind(tier)
65 + .bind(project)
66 + .bind(stripe_id)
67 + .bind(status)
68 + .bind(period_end)
69 + .execute(pool)
70 + .await
71 + .expect("seed project subscription");
72 + }
73 +
74 + /// Read one subscription row's status, canceled_at and period as text-free values.
75 + async fn read_row(
76 + pool: &PgPool,
77 + stripe_id: &str,
78 + ) -> (
79 + String,
80 + Option<DateTime<Utc>>,
81 + Option<DateTime<Utc>>,
82 + Option<DateTime<Utc>>,
83 + ) {
84 + sqlx::query_as(
85 + "SELECT status, canceled_at, current_period_start, current_period_end
86 + FROM subscriptions WHERE stripe_subscription_id = $1",
87 + )
88 + .bind(stripe_id)
89 + .fetch_one(pool)
90 + .await
91 + .unwrap_or_else(|e| panic!("read subscription {stripe_id}: {e}"))
92 + }
93 +
94 + async fn status_of(pool: &PgPool, stripe_id: &str) -> String {
95 + read_row(pool, stripe_id).await.0
96 + }
97 + // ── webhook-driven lifecycle ──
98 +
99 + /// Sign a Stripe event and POST it to the webhook endpoint.
100 + async fn post_event(
101 + h: &mut TestHarness,
102 + event_id: &str,
103 + event_type: &str,
104 + object: Value,
105 + ) -> crate::harness::client::TestResponse {
106 + let payload = serde_json::json!({
107 + "id": event_id,
108 + "type": event_type,
109 + "data": {"object": object},
110 + })
111 + .to_string();
112 + let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET);
113 + h.client
114 + .request_with_headers(
115 + "POST",
116 + "/stripe/webhook",
117 + Some(&payload),
118 + &[
119 + ("stripe-signature", &signature),
120 + ("content-type", "application/json"),
121 + ],
122 + )
123 + .await
124 + }
125 +
126 + /// A `customer.subscription.updated` / `.deleted` object with one item carrying
127 + /// the given raw Stripe period.
128 + fn subscription_object(stripe_sub_id: &str, status: &str, period: Option<(i64, i64)>) -> Value {
129 + let items = match period {
130 + Some((start, end)) => serde_json::json!([{
131 + "id": "si_dbsl",
132 + "object": "subscription_item",
133 + "subscription": stripe_sub_id,
134 + "current_period_start": start,
135 + "current_period_end": end,
136 + "metadata": {},
137 + }]),
138 + None => serde_json::json!([]),
139 + };
140 + serde_json::json!({
141 + "id": stripe_sub_id,
142 + "object": "subscription",
143 + "status": status,
144 + "cancel_at_period_end": false,
145 + "items": {"object": "list", "data": items},
146 + })
147 + }
148 +
149 + /// Creator with a project and a tier, plus a fan. Returns
150 + /// `(fan_id, project_id, tier_id)`.
151 + async fn webhook_fixture(
152 + h: &mut TestHarness,
153 + tag: &str,
154 + ) -> (UserId, ProjectId, SubscriptionTierId) {
155 + let creator = h
156 + .signup(
157 + &format!("wcreator_{tag}"),
158 + &format!("wcreator_{tag}@test.com"),
159 + "password123",
160 + )
161 + .await;
162 + h.grant_creator(creator).await;
163 + h.client.post_form("/logout", "").await;
164 + h.login(&format!("wcreator_{tag}"), "password123").await;
165 +
166 + let resp = h
167 + .client
168 + .post_form(
169 + "/api/projects",
170 + &format!("slug=whook-{tag}&title=Webhook+{tag}"),
171 + )
172 + .await;
173 + assert_eq!(resp.status, 200, "create project failed: {}", resp.text);
174 + let project: Value = resp.json();
175 + let project_uuid: ProjectId = project["id"]
176 + .as_str()
177 + .expect("project id")
178 + .parse()
179 + .expect("project id parses");
180 + let tier = seed_project_tier(&h.db, project_uuid).await;
181 +
182 + h.client.post_form("/logout", "").await;
183 + let fan = h
184 + .signup(
185 + &format!("wfan_{tag}"),
186 + &format!("wfan_{tag}@test.com"),
187 + "password123",
188 + )
189 + .await;
190 + h.client.post_form("/logout", "").await;
191 +
192 + (fan, project_uuid, tier)
193 + }
194 +
195 + #[tokio::test]
196 + async fn a_new_subscription_cancels_only_the_same_fans_lingering_rows_for_that_project() {
197 + let mut h = TestHarness::with_stripe().await;
198 + let (fan, project, tier) = webhook_fixture(&mut h, "cleanup").await;
199 +
200 + // A second project and a second fan, so the cleanup's WHERE has something
201 + // to get wrong in each direction. Who owns the second project is irrelevant
202 + // here; only the (subscriber, project) pair is.
203 + let other_project = seed_project(&h.db, fan, "cleanup-other").await;
204 + let other_tier = seed_project_tier(&h.db, other_project).await;
205 + let other_fan = seed_user(&h.db, "cleanup_other_fan").await;
206 +
207 + let period = Some(Utc::now() + Duration::days(5));
208 + // Cleaned up: this fan, this project, a status in the cleanup set.
209 + seed_project_subscription(
210 + &h.db,
211 + fan,
212 + tier,
213 + project,
214 + "sub_stale_past_due",
215 + "past_due",
216 + period,
217 + )
218 + .await;
219 + seed_project_subscription(
220 + &h.db,
221 + fan,
222 + tier,
223 + project,
224 + "sub_stale_trialing",
225 + "trialing",
226 + period,
227 + )
228 + .await;
229 + // Left alone: 'unpaid' is deliberately NOT in the cleanup set, so this row
230 + // is what tells "cancel the three named statuses" apart from "cancel
231 + // everything that is not active".
232 + seed_project_subscription(
233 + &h.db,
234 + fan,
235 + tier,
236 + project,
237 + "sub_stale_unpaid",
238 + "unpaid",
239 + period,
240 + )
241 + .await;
242 + // Left alone: another fan, same project.
243 + seed_project_subscription(
244 + &h.db,
245 + other_fan,
246 + tier,
247 + project,
248 + "sub_other_fan",
249 + "past_due",
250 + period,
251 + )
252 + .await;
253 + // Left alone: same fan, another project.
254 + seed_project_subscription(
255 + &h.db,
256 + fan,
257 + other_tier,
258 + other_project,
259 + "sub_other_project",
260 + "past_due",
261 + period,
262 + )
263 + .await;
264 +
265 + let mut meta = HashMap::new();
266 + meta.insert("checkout_type".to_string(), "subscription".to_string());
267 + meta.insert("subscriber_id".to_string(), fan.to_string());
268 + meta.insert("project_id".to_string(), project.to_string());
269 + meta.insert("tier_id".to_string(), tier.to_string());
270 + let session = serde_json::json!({
271 + "id": "cs_dbsl_cleanup",
272 + "object": "checkout_session",
273 + "mode": "subscription",
274 + "metadata": meta,
275 + "subscription": "sub_fresh_cleanup",
276 + "customer": "cus_fresh_cleanup",
277 + });
278 +
279 + let resp = post_event(
280 + &mut h,
281 + "evt_dbsl_cleanup",
282 + "checkout.session.completed",
283 + session,
284 + )
285 + .await;
286 + assert_eq!(
287 + resp.status.as_u16(),
288 + 200,
289 + "subscription checkout webhook failed: {}",
290 + resp.text
291 + );
292 +
293 + let (fresh_status, _, _, _) = read_row(&h.db, "sub_fresh_cleanup").await;
294 + assert_eq!(
295 + fresh_status, "active",
296 + "the new subscription is created active"
297 + );
298 +
299 + for stale in ["sub_stale_past_due", "sub_stale_trialing"] {
300 + let (status, canceled_at, _, _) = read_row(&h.db, stale).await;
301 + assert_eq!(
302 + status, "canceled",
303 + "{stale} is a lingering live row for the resubscribing fan and must be canceled"
304 + );
305 + assert!(
306 + canceled_at.is_some(),
307 + "{stale} was canceled, so canceled_at must be stamped, got {canceled_at:?}"
308 + );
309 + }
310 +
311 + assert_eq!(
312 + status_of(&h.db, "sub_stale_unpaid").await,
313 + "unpaid",
314 + "'unpaid' is outside the cleanup set and must survive untouched"
315 + );
316 + assert_eq!(
317 + status_of(&h.db, "sub_other_fan").await,
318 + "past_due",
319 + "another fan's row on the same project must not be canceled"
320 + );
321 + assert_eq!(
322 + status_of(&h.db, "sub_other_project").await,
323 + "past_due",
324 + "the same fan's row on a different project must not be canceled"
325 + );
326 + }
327 +
328 + #[tokio::test]
329 + async fn a_redelivered_cancellation_keeps_the_first_cancellation_time() {
330 + let mut h = TestHarness::with_stripe().await;
331 + let (fan, project, tier) = webhook_fixture(&mut h, "cancel").await;
332 +
333 + // Already canceled, with a known cancellation instant: this is the row a
334 + // Stripe redelivery of `customer.subscription.deleted` lands on.
335 + let first_cancel = DateTime::parse_from_rfc3339("2026-01-05T06:07:08Z")
336 + .expect("fixed timestamp parses")
337 + .with_timezone(&Utc);
338 + seed_project_subscription(
339 + &h.db,
340 + fan,
341 + tier,
342 + project,
343 + "sub_cancel_replay",
344 + "canceled",
345 + Some(Utc::now() - Duration::days(10)),
346 + )
347 + .await;
348 + sqlx::query("UPDATE subscriptions SET canceled_at = $1 WHERE stripe_subscription_id = $2")
349 + .bind(first_cancel)
350 + .bind("sub_cancel_replay")
351 + .execute(&h.db)
352 + .await
353 + .expect("stamp the original cancellation time");
354 +
355 + // A live row, so the same event type is shown to have an effect at all.
356 + seed_project_subscription(
357 + &h.db,
358 + fan,
359 + tier,
360 + project,
361 + "sub_cancel_fresh",
362 + "active",
363 + Some(Utc::now() + Duration::days(11)),
364 + )
365 + .await;
366 +
367 + let resp = post_event(
368 + &mut h,
369 + "evt_dbsl_cancel_replay",
370 + "customer.subscription.deleted",
371 + subscription_object(
372 + "sub_cancel_replay",
373 + "canceled",
374 + Some((1_700_000_000, 1_702_592_000)),
375 + ),
376 + )
377 + .await;
378 + assert_eq!(
379 + resp.status.as_u16(),
380 + 200,
381 + "redelivered cancellation webhook failed: {}",
382 + resp.text
383 + );
384 +
385 + let (status, canceled_at, _, _) = read_row(&h.db, "sub_cancel_replay").await;
386 + assert_eq!(status, "canceled", "the row stays canceled on redelivery");
387 + assert_eq!(
388 + canceled_at,
389 + Some(first_cancel),
390 + "COALESCE(canceled_at, NOW()) must keep the FIRST cancellation time; a restamp would \
391 + move a fan's end-of-access date forward on every Stripe retry"
392 + );
393 +
394 + let before = Utc::now();
395 + let resp = post_event(
396 + &mut h,
397 + "evt_dbsl_cancel_fresh",
398 + "customer.subscription.deleted",
399 + subscription_object(
400 + "sub_cancel_fresh",
401 + "canceled",
402 + Some((1_700_000_000, 1_702_592_000)),
403 + ),
404 + )
405 + .await;
406 + assert_eq!(
407 + resp.status.as_u16(),
408 + 200,
409 + "first cancellation webhook failed: {}",
410 + resp.text
411 + );
412 + let (status, canceled_at, _, _) = read_row(&h.db, "sub_cancel_fresh").await;
413 + assert_eq!(
414 + status, "canceled",
415 + "a live row is canceled by the same event"
416 + );
417 + let canceled_at = canceled_at.expect("a first cancellation stamps canceled_at");
418 + assert!(
419 + canceled_at >= before,
420 + "a row with no prior canceled_at is stamped now, got {canceled_at} (test began {before})"
421 + );
422 + }
423 +
424 + #[tokio::test]
425 + async fn a_zero_length_stripe_period_is_written_and_an_inverted_one_is_dropped() {
426 + let mut h = TestHarness::with_stripe().await;
427 + let (fan, project, tier) = webhook_fixture(&mut h, "period").await;
428 +
429 + seed_project_subscription(
430 + &h.db,
431 + fan,
432 + tier,
433 + project,
434 + "sub_period_funnel",
435 + "active",
436 + Some(Utc::now() + Duration::days(4)),
437 + )
438 + .await;
439 +
440 + // start == end is the boundary the funnel accepts (`end > 0 && start <= end`).
441 + // Picking equal values is what separates `<=` from `<`.
442 + let boundary = 1_767_225_600_i64; // 2026-01-01T00:00:00Z
443 + let resp = post_event(
444 + &mut h,
445 + "evt_dbsl_period_equal",
446 + "customer.subscription.updated",
447 + subscription_object("sub_period_funnel", "active", Some((boundary, boundary))),
448 + )
449 + .await;
450 + assert_eq!(
451 + resp.status.as_u16(),
452 + 200,
453 + "zero-length period webhook failed: {}",
454 + resp.text
455 + );
456 +
457 + let (status, _, start, end) = read_row(&h.db, "sub_period_funnel").await;
458 + assert_eq!(status, "active", "the status update lands");
459 + assert_eq!(
460 + start.map(|t| t.timestamp()),
461 + Some(boundary),
462 + "a zero-length window is a legal Stripe shape and must be written, got {start:?}"
463 + );
464 + assert_eq!(
465 + end.map(|t| t.timestamp()),
466 + Some(boundary),
467 + "a zero-length window is a legal Stripe shape and must be written, got {end:?}"
468 + );
469 +
470 + // Inverted: end one second BEFORE start. The funnel drops the period, and
471 + // COALESCE keeps what is already there, but the status half still applies.
472 + let resp = post_event(
473 + &mut h,
474 + "evt_dbsl_period_inverted",
475 + "customer.subscription.updated",
476 + subscription_object(
477 + "sub_period_funnel",
478 + "past_due",
479 + Some((boundary + 86_400, boundary + 86_399)),
480 + ),
481 + )
482 + .await;
483 + assert_eq!(
484 + resp.status.as_u16(),
485 + 200,
486 + "inverted period webhook failed: {}",
487 + resp.text
488 + );
489 +
490 + let (status, _, start, end) = read_row(&h.db, "sub_period_funnel").await;
491 + assert_eq!(
492 + status, "past_due",
493 + "an inverted period drops only the period; the status still lands"
494 + );
495 + assert_eq!(
496 + start.map(|t| t.timestamp()),
497 + Some(boundary),
498 + "an inverted window must leave the existing period alone, got {start:?}"
499 + );
500 + assert_eq!(
Lines truncated
@@ -1,0 +1,747 @@
1 + //! DB-layer contract tests for `db::synckit::blobs`, the SyncKit blob rows and
2 + //! the storage accounting hung off them.
3 + //!
4 + //! `db_synckit_layer` touches two blob cases in passing (owner scoping and the
5 + //! internal re-confirm). What had no test anywhere is the arithmetic those rows
6 + //! feed, which is where being wrong costs a developer money or hands a user
7 + //! free storage:
8 + //!
9 + //! - `storage_used_bytes` sums the authoritative rows rather than a counter,
10 + //! - `confirm_internal_blob` compares `used + size` against the subscription
11 + //! cap with `>`, so a blob landing exactly on the cap is allowed and one
12 + //! byte more is refused, and a NULL cap fails closed at zero,
13 + //! - `confirm_developer_blob` enforces the bulk app-wide cap, the per-key cap,
14 + //! and the defensive app aggregate that catches a key which is itself empty,
15 + //! - both confirm paths are idempotent under redelivery: a repeated confirm
16 + //! of the same `(app, user, hash)` returns `AlreadyStored` and moves no
17 + //! counter, because an upload retry must not eat the cap twice,
18 + //! - `delete_sync_blob` is the only shrink path: it refunds the app-wide and
19 + //! per-key counters by exactly the blob's size, dead-letters the S3 object
20 + //! in the same transaction, and a second delete is a `NotFound` no-op that
21 + //! refunds nothing a second time,
22 + //! - `count_sync_devices` counts one `(app, user)` pair and no other.
23 + //!
24 + //! Delete this file and the caps stop being tested at their boundaries: an
25 + //! off-by-one in either direction, a double refund on a repeated delete, and a
26 + //! redelivered confirm charging twice all pass silently.
27 +
28 + use crate::harness::db::TestDb;
29 + use crate::harness::seed_user;
30 +
31 + use makenotwork::db::synckit;
32 + use makenotwork::db::synckit::{BlobConfirm, BlobDelete};
33 + use makenotwork::db::{SyncAppId, SyncEnforcementMode, UserId};
34 +
35 + /// One gibibyte. Written out rather than read back from
36 + /// `synckit_billing::storage_cap_bytes`, so the boundary cases below compare
37 + /// against an independent number instead of re-deriving the one under test.
38 + const GIB: i64 = 1024 * 1024 * 1024;
39 +
40 + /// Seed a sync app owned by `user`, with the `sync_app_usage_current` row the
41 + /// developer-billing path locks `FOR UPDATE`.
42 + async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
43 + synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None)
44 + .await
45 + .expect("seed sync app")
46 + .id
47 + }
48 +
49 + /// Mark an app first-party, so the end-user subscription model applies.
50 + async fn make_internal(pool: &sqlx::PgPool, app: SyncAppId) {
51 + sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1")
52 + .bind(app)
53 + .execute(pool)
54 + .await
55 + .expect("mark internal");
56 + }
57 +
58 + /// Give `user` an active end-user subscription on `app` capped at `limit_bytes`.
59 + async fn seed_subscription(
60 + pool: &sqlx::PgPool,
61 + user: UserId,
62 + app: SyncAppId,
63 + sub_id: &str,
64 + limit_bytes: Option<i64>,
65 + ) {
66 + sqlx::query(
67 + "INSERT INTO app_sync_subscriptions
68 + (user_id, app_id, stripe_subscription_id, stripe_customer_id, tier,
69 + status, storage_limit_bytes)
70 + VALUES ($1, $2, $3, 'cus_test', 'monthly', 'active', $4)",
71 + )
72 + .bind(user)
73 + .bind(app)
74 + .bind(sub_id)
75 + .bind(limit_bytes)
76 + .execute(pool)
77 + .await
78 + .expect("seed subscription");
79 + }
80 +
81 + /// The app-wide running counter the developer-billing path maintains.
82 + async fn app_bytes(pool: &sqlx::PgPool, app: SyncAppId) -> i64 {
83 + sqlx::query_scalar::<_, i64>(
84 + "SELECT bytes_stored FROM sync_app_usage_current WHERE app_id = $1",
85 + )
86 + .bind(app)
87 + .fetch_one(pool)
88 + .await
89 + .expect("read app bytes_stored")
90 + }
91 +
92 + /// The per-key running counter, or `None` when the key has no row yet.
93 + async fn key_bytes(pool: &sqlx::PgPool, app: SyncAppId, key: &str) -> Option<i64> {
94 + sqlx::query_scalar::<_, i64>(
95 + "SELECT bytes_stored FROM sync_key_usage_current WHERE app_id = $1 AND key = $2",
96 + )
97 + .bind(app)
98 + .bind(key)
99 + .fetch_optional(pool)
100 + .await
101 + .expect("read key bytes_stored")
102 + }
103 +
104 + /// Count of blob rows for one `(app, user)`.
105 + async fn blob_rows(pool: &sqlx::PgPool, app: SyncAppId, user: UserId) -> i64 {
106 + sqlx::query_scalar::<_, i64>(
107 + "SELECT COUNT(*) FROM sync_blobs WHERE app_id = $1 AND user_id = $2",
108 + )
109 + .bind(app)
110 + .bind(user)
111 + .fetch_one(pool)
112 + .await
113 + .expect("count blob rows")
114 + }
115 +
116 + /// Every dead-lettered S3 key queued so far, with its bucket and source.
117 + async fn pending_deletions(pool: &sqlx::PgPool) -> Vec<(String, String, String)> {
118 + sqlx::query_as::<_, (String, String, String)>(
119 + "SELECT s3_key, bucket, source FROM pending_s3_deletions ORDER BY s3_key",
120 + )
121 + .fetch_all(pool)
122 + .await
123 + .expect("read pending deletions")
124 + }
125 +
126 + /// Confirm a blob on a bulk-billed developer app with the given GB cap.
127 + #[allow(clippy::too_many_arguments)]
128 + async fn confirm_bulk(
129 + pool: &sqlx::PgPool,
130 + app: SyncAppId,
131 + user: UserId,
132 + hash: &str,
133 + size: i64,
134 + key: &str,
135 + gb_cap: i32,
136 + ) -> BlobConfirm {
137 + synckit::confirm_developer_blob(
138 + pool,
139 + app,
140 + user,
141 + hash,
142 + size,
143 + &format!("s3/{hash}"),
144 + key,
145 + SyncEnforcementMode::Bulk,
146 + Some(gb_cap),
147 + None,
148 + None,
149 + )
150 + .await
151 + .expect("bulk confirm")
152 + }
153 +
154 + /// Confirm a blob on a per-key-billed developer app: `key_cap` keys, `gb_per_key`
155 + /// GB each.
156 + #[allow(clippy::too_many_arguments)]
157 + async fn confirm_per_key(
158 + pool: &sqlx::PgPool,
159 + app: SyncAppId,
160 + user: UserId,
161 + hash: &str,
162 + size: i64,
163 + key: &str,
164 + key_cap: i32,
165 + gb_per_key: i32,
166 + ) -> BlobConfirm {
167 + synckit::confirm_developer_blob(
168 + pool,
169 + app,
170 + user,
171 + hash,
172 + size,
173 + &format!("s3/{hash}"),
174 + key,
175 + SyncEnforcementMode::PerKey,
176 + None,
177 + Some(key_cap),
178 + Some(gb_per_key),
179 + )
180 + .await
181 + .expect("per-key confirm")
182 + }
183 +
184 + // ── storage_used_bytes ──────────────────────────────────────────────────────
185 +
186 + /// Three distinct, non-round sizes, so a sum is distinguishable from a count, a
187 + /// max, or the size of the first row.
188 + #[tokio::test]
189 + async fn storage_used_bytes_sums_every_blob_of_one_user_and_no_one_elses() {
190 + let db = TestDb::new().await;
191 + let alice = seed_user(&db.pool, "skblobs_sum_alice").await;
192 + let bob = seed_user(&db.pool, "skblobs_sum_bob").await;
193 + let app = seed_app(&db.pool, alice, "blobsum").await;
194 + make_internal(&db.pool, app).await;
195 + seed_subscription(&db.pool, alice, app, "sub_sum_alice", Some(100_000)).await;
196 + seed_subscription(&db.pool, bob, app, "sub_sum_bob", Some(100_000)).await;
197 +
198 + for (hash, size) in [("h-a", 3000), ("h-b", 700), ("h-c", 41)] {
199 + let out =
200 + synckit::confirm_internal_blob(&db.pool, app, alice, hash, size, "s3/a", "default")
201 + .await
202 + .expect("confirm");
203 + assert_eq!(out, BlobConfirm::Stored, "seeding {hash} must store");
204 + }
205 + let out = synckit::confirm_internal_blob(&db.pool, app, bob, "h-d", 500, "s3/d", "default")
206 + .await
207 + .expect("confirm");
208 + assert_eq!(out, BlobConfirm::Stored, "bob's own blob stores");
209 +
210 + assert_eq!(
211 + synckit::storage_used_bytes(&db.pool, app, alice)
212 + .await
213 + .expect("alice usage"),
214 + 3741,
215 + "3000 + 700 + 41: the sum of the rows, not their count or their max"
216 + );
217 + assert_eq!(
218 + synckit::storage_used_bytes(&db.pool, app, bob)
219 + .await
220 + .expect("bob usage"),
221 + 500,
222 + "bob is charged for his row alone"
223 + );
224 + }
225 +
226 + // ── confirm_internal_blob: the end-user cap ─────────────────────────────────
227 +
228 + /// The gate is `used + size > limit`, so exactly-on-the-cap is allowed and one
229 + /// byte past it is refused. Both sides are asserted: a test that only checked
230 + /// the refusal could not tell `>` from `>=`.
231 + #[tokio::test]
232 + async fn an_internal_blob_landing_exactly_on_the_cap_is_stored_and_one_byte_more_is_refused() {
233 + let db = TestDb::new().await;
234 + let user = seed_user(&db.pool, "skblobs_cap").await;
235 + let app = seed_app(&db.pool, user, "blobcap").await;
236 + make_internal(&db.pool, app).await;
237 + seed_subscription(&db.pool, user, app, "sub_cap", Some(5000)).await;
238 +
239 + let first = synckit::confirm_internal_blob(&db.pool, app, user, "h-1", 3000, "s3/1", "default")
240 + .await
241 + .expect("first confirm");
242 + assert_eq!(first, BlobConfirm::Stored, "3000 of a 5000 cap fits");
243 +
244 + // 3000 + 2000 == 5000: the boundary itself, which must be allowed.
245 + let exact = synckit::confirm_internal_blob(&db.pool, app, user, "h-2", 2000, "s3/2", "default")
246 + .await
247 + .expect("boundary confirm");
248 + assert_eq!(
249 + exact,
250 + BlobConfirm::Stored,
251 + "a blob that fills the cap exactly is within it"
252 + );
253 +
254 + // One byte past it is not.
255 + let over = synckit::confirm_internal_blob(&db.pool, app, user, "h-3", 1, "s3/3", "default")
256 + .await
257 + .expect("over-cap confirm");
258 + assert_eq!(
259 + over,
260 + BlobConfirm::QuotaExceeded {
261 + dimension: "storage",
262 + used: 5000,
263 + limit: 5000,
264 + key: None,
265 + },
266 + "one byte past a full cap is refused, and the reason names the real numbers"
267 + );
268 +
269 + assert!(
270 + synckit::get_sync_blob_by_hash(&db.pool, app, user, "h-3")
271 + .await
272 + .expect("lookup")
273 + .is_none(),
274 + "a refused confirm writes no row"
275 + );
276 + assert_eq!(
277 + synckit::storage_used_bytes(&db.pool, app, user)
278 + .await
279 + .expect("usage"),
280 + 5000,
281 + "and charges nothing"
282 + );
283 + }
284 +
285 + /// A NULL `storage_limit_bytes` becomes a zero cap (`unwrap_or(0)`), so the path
286 + /// fails closed rather than treating "no cap recorded" as unlimited.
287 + #[tokio::test]
288 + async fn an_internal_blob_is_refused_when_the_subscription_records_no_cap() {
289 + let db = TestDb::new().await;
290 + let user = seed_user(&db.pool, "skblobs_nullcap").await;
291 + let app = seed_app(&db.pool, user, "blobnullcap").await;
292 + make_internal(&db.pool, app).await;
293 + seed_subscription(&db.pool, user, app, "sub_nullcap", None).await;
294 +
295 + let out = synckit::confirm_internal_blob(&db.pool, app, user, "h-n", 4096, "s3/n", "default")
296 + .await
297 + .expect("confirm");
298 + assert_eq!(
299 + out,
300 + BlobConfirm::QuotaExceeded {
301 + dimension: "storage",
302 + used: 0,
303 + limit: 0,
304 + key: None,
305 + },
306 + "a missing cap is zero, not infinity"
307 + );
308 + assert_eq!(
309 + blob_rows(&db.pool, app, user).await,
310 + 0,
311 + "nothing was written"
312 + );
313 + }
314 +
315 + /// Paid-only: no subscription row at all, and a row that is not `active`, are
316 + /// both `NoSubscription`, and neither writes.
317 + #[tokio::test]
318 + async fn an_internal_blob_needs_an_active_subscription_to_be_stored() {
319 + let db = TestDb::new().await;
320 + let user = seed_user(&db.pool, "skblobs_nosub").await;
321 + let app = seed_app(&db.pool, user, "blobnosub").await;
322 + make_internal(&db.pool, app).await;
323 +
324 + let missing =
325 + synckit::confirm_internal_blob(&db.pool, app, user, "h-m", 2048, "s3/m", "default")
326 + .await
327 + .expect("confirm without a subscription");
328 + assert_eq!(
329 + missing,
330 + BlobConfirm::NoSubscription,
331 + "no subscription row means no write"
332 + );
333 +
334 + seed_subscription(&db.pool, user, app, "sub_nosub", Some(100_000)).await;
335 + synckit::update_app_sync_subscription_status(&db.pool, "sub_nosub", "canceled", None)
336 + .await
337 + .expect("cancel");
338 +
339 + let canceled =
340 + synckit::confirm_internal_blob(&db.pool, app, user, "h-m", 2048, "s3/m", "default")
341 + .await
342 + .expect("confirm on a canceled subscription");
343 + assert_eq!(
344 + canceled,
345 + BlobConfirm::NoSubscription,
346 + "a canceled subscription closes writes even though the row exists"
347 + );
348 + assert_eq!(
349 + blob_rows(&db.pool, app, user).await,
350 + 0,
351 + "neither refusal wrote a row"
352 + );
353 + }
354 +
355 + // ── confirm_developer_blob: bulk mode ───────────────────────────────────────
356 +
357 + /// Bulk mode caps the app as a whole. Same boundary pair as the internal path,
358 + /// against a cap expressed in GB, plus the two counters the path maintains.
359 + #[tokio::test]
360 + async fn bulk_mode_fills_the_app_cap_exactly_then_refuses_the_next_byte() {
361 + let db = TestDb::new().await;
362 + let dev = seed_user(&db.pool, "skblobs_bulk_dev").await;
363 + let user = seed_user(&db.pool, "skblobs_bulk_user").await;
364 + let app = seed_app(&db.pool, dev, "blobbulk").await;
365 + let cap = 2 * GIB;
366 +
367 + // Two blobs on two different keys, so the app-wide counter is visibly the
368 + // sum of the keys rather than either one of them.
369 + let first = confirm_bulk(&db.pool, app, user, "b-1", cap - 500, "alpha", 2).await;
370 + assert_eq!(first, BlobConfirm::Stored, "the first blob fits under 2 GB");
371 +
372 + let exact = confirm_bulk(&db.pool, app, user, "b-2", 500, "beta", 2).await;
373 + assert_eq!(
374 + exact,
375 + BlobConfirm::Stored,
376 + "the blob that fills the cap exactly is within it"
377 + );
378 +
379 + let over = confirm_bulk(&db.pool, app, user, "b-3", 1, "beta", 2).await;
380 + assert_eq!(
381 + over,
382 + BlobConfirm::QuotaExceeded {
383 + dimension: "storage",
384 + used: cap,
385 + limit: cap,
386 + key: None,
387 + },
388 + "bulk mode reports the app dimension with no key attached"
389 + );
390 +
391 + assert_eq!(
392 + app_bytes(&db.pool, app).await,
393 + cap,
394 + "the app counter is the sum of both blobs"
395 + );
396 + assert_eq!(
397 + key_bytes(&db.pool, app, "alpha").await,
398 + Some(cap - 500),
399 + "each key is charged its own blob"
400 + );
401 + assert_eq!(key_bytes(&db.pool, app, "beta").await, Some(500));
402 + assert_eq!(
403 + blob_rows(&db.pool, app, user).await,
404 + 2,
405 + "the refused confirm wrote no third row"
406 + );
407 + }
408 +
409 + /// Stripe-style redelivery: the same confirm arriving twice must be a no-op the
410 + /// second time, not a second charge against the cap.
411 + #[tokio::test]
412 + async fn a_redelivered_developer_confirm_is_a_no_op_and_charges_nothing_twice() {
413 + let db = TestDb::new().await;
414 + let dev = seed_user(&db.pool, "skblobs_replay_dev").await;
415 + let user = seed_user(&db.pool, "skblobs_replay_user").await;
416 + let app = seed_app(&db.pool, dev, "blobreplay").await;
417 +
418 + let first = confirm_bulk(&db.pool, app, user, "r-1", 7000, "alpha", 10).await;
419 + assert_eq!(first, BlobConfirm::Stored);
420 +
421 + // The identical delivery again, and a third that claims a different size and
422 + // key: the hash is the identity, so both are refused a second charge.
423 + let second = confirm_bulk(&db.pool, app, user, "r-1", 7000, "alpha", 10).await;
424 + assert_eq!(
425 + second,
426 + BlobConfirm::AlreadyStored,
427 + "a redelivery of the same (app, user, hash) is idempotent"
428 + );
429 + let third = confirm_bulk(&db.pool, app, user, "r-1", 3300, "beta", 10).await;
430 + assert_eq!(
431 + third,
432 + BlobConfirm::AlreadyStored,
433 + "the hash decides, so a redelivery carrying different metadata is still a no-op"
434 + );
435 +
436 + assert_eq!(
437 + app_bytes(&db.pool, app).await,
438 + 7000,
439 + "the app counter moved once, not three times"
440 + );
441 + assert_eq!(
442 + key_bytes(&db.pool, app, "alpha").await,
443 + Some(7000),
444 + "and the key counter with it"
445 + );
446 + assert_eq!(
447 + key_bytes(&db.pool, app, "beta").await,
448 + None,
449 + "the redelivery's other key was never charged"
450 + );
451 + assert_eq!(blob_rows(&db.pool, app, user).await, 1);
452 + }
453 +
454 + // ── confirm_developer_blob: per-key mode ────────────────────────────────────
455 +
456 + /// Per-key mode caps each key at `gb_per_key`. A key that fills its own cap is
457 + /// refused while a different key keeps working, which is the whole point of the
458 + /// mode: one full key must not degrade the app.
459 + #[tokio::test]
460 + async fn per_key_mode_refuses_the_key_that_is_full_and_leaves_the_others_writable() {
461 + let db = TestDb::new().await;
462 + let dev = seed_user(&db.pool, "skblobs_pk_dev").await;
463 + let user = seed_user(&db.pool, "skblobs_pk_user").await;
464 + let app = seed_app(&db.pool, dev, "blobperkey").await;
465 + // 3 keys x 1 GB: per-key limit 1 GiB, app aggregate 3 GiB.
466 + let (key_cap, gb) = (3, 1);
467 +
468 + let first = confirm_per_key(&db.pool, app, user, "p-1", GIB - 700, "alpha", key_cap, gb).await;
469 + assert_eq!(first, BlobConfirm::Stored, "under alpha's 1 GB");
470 +
471 + let exact = confirm_per_key(&db.pool, app, user, "p-2", 700, "alpha", key_cap, gb).await;
472 + assert_eq!(
473 + exact,
474 + BlobConfirm::Stored,
475 + "a blob filling the key cap exactly is within it"
476 + );
477 +
478 + let over = confirm_per_key(&db.pool, app, user, "p-3", 1, "alpha", key_cap, gb).await;
479 + assert_eq!(
480 + over,
481 + BlobConfirm::QuotaExceeded {
482 + dimension: "storage_per_key",
483 + used: GIB,
484 + limit: GIB,
485 + key: Some("alpha".to_string()),
486 + },
487 + "the refusal names the key it applies to, so the caller can tell the developer which one"
488 + );
489 +
490 + // The app aggregate still has 2 GiB free, so another key writes fine.
491 + let other = confirm_per_key(&db.pool, app, user, "p-4", 4096, "beta", key_cap, gb).await;
492 + assert_eq!(
493 + other,
494 + BlobConfirm::Stored,
495 + "a full key must not close the whole app"
496 + );
497 +
498 + assert_eq!(key_bytes(&db.pool, app, "alpha").await, Some(GIB));
499 + assert_eq!(key_bytes(&db.pool, app, "beta").await, Some(4096));
500 + assert_eq!(
Lines truncated
@@ -1,0 +1,779 @@
1 + //! DB-layer contract tests for `db::synckit::devices` and `db::synckit::keys`,
2 + //! the device registry a paired client is recognised by and the encrypted
3 + //! master-key record every one of that user's rows is readable through.
4 + //!
5 + //! Both files carry query-shaped contracts that no route test can see, because
6 + //! a route test authenticates as one user against one app and so cannot tell a
7 + //! correctly scoped query from an unscoped one, nor `MIN` from `MAX`, nor a
8 + //! window from no window. What is pinned here:
9 + //!
10 + //! - devices: the upsert replacing platform and reported SDK version on
11 + //! re-registration, the listing's newest-seen-first order, a touch moving
12 + //! `last_seen_at` without moving the pull cursor, a pull upgrading the
13 + //! recorded client version, and `client_version_distribution`'s grouping,
14 + //! `MAX(last_seen_at)` per group, its window on both sides, and its tie
15 + //! ordering with the never-reported bucket last.
16 + //! - keys: key generations advancing strictly by one and never rewinding under
17 + //! a stale expected version, a pending rotation being reported beside the
18 + //! still-active key rather than in place of it, the per-app device and log
19 + //! counts a creator's dashboard reads, and the two log-shrinking paths,
20 + //! retention pruning and cursor compaction, at both their age and their
21 + //! sequence boundaries.
22 + //!
23 + //! Delete this file and the surviving synckit coverage
24 + //! (`db_synckit_accounts_layer`, `db_synckit_layer`) still asserts owner
25 + //! scoping and the monotonic cursor, but nothing asserts that compaction stops
26 + //! at the slowest device rather than the fastest, that pruning spares an entry
27 + //! inside the horizon, or that the field-version readout is a window at all.
28 + //! Each of those is a silent data-loss or wrong-answer bug.
29 +
30 + use crate::harness::db::TestDb;
31 + use crate::harness::seed_user;
32 +
33 + use makenotwork::db::synckit;
34 + use makenotwork::db::{SyncAppId, SyncDeviceId, SyncPlatform, UserId};
35 + use uuid::Uuid;
36 +
37 + /// Seed a sync app owned by `user`, with its usage row.
38 + async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
39 + synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None)
40 + .await
41 + .expect("seed sync app")
42 + .id
43 + }
44 +
45 + async fn seed_device(
46 + pool: &sqlx::PgPool,
47 + app: SyncAppId,
48 + user: UserId,
49 + name: &str,
50 + ) -> SyncDeviceId {
51 + synckit::upsert_sync_device(pool, app, user, name, SyncPlatform::Macos, None)
52 + .await
53 + .expect("seed device")
54 + .id
55 + }
56 +
57 + /// Register a device already reporting an SDK version.
58 + async fn seed_device_on_version(
59 + pool: &sqlx::PgPool,
60 + app: SyncAppId,
61 + user: UserId,
62 + name: &str,
63 + version: Option<&str>,
64 + ) -> SyncDeviceId {
65 + synckit::upsert_sync_device(pool, app, user, name, SyncPlatform::Linux, version)
66 + .await
67 + .expect("seed device")
68 + .id
69 + }
70 +
71 + /// Move a device's `last_seen_at` back by `hours`, returning the stamp written.
72 + /// Every last-seen assertion here compares against a value the database itself
73 + /// produced, so no test depends on the host clock agreeing with Postgres.
74 + async fn set_last_seen(
75 + pool: &sqlx::PgPool,
76 + device: SyncDeviceId,
77 + hours: i32,
78 + ) -> chrono::DateTime<chrono::Utc> {
79 + sqlx::query_scalar(
80 + "UPDATE sync_devices SET last_seen_at = NOW() - make_interval(hours => $2::int)
81 + WHERE id = $1 RETURNING last_seen_at",
82 + )
83 + .bind(device)
84 + .bind(hours)
85 + .fetch_one(pool)
86 + .await
87 + .expect("backdate last_seen_at")
88 + }
89 +
90 + async fn read_last_seen(
91 + pool: &sqlx::PgPool,
92 + device: SyncDeviceId,
93 + ) -> chrono::DateTime<chrono::Utc> {
94 + sqlx::query_scalar("SELECT last_seen_at FROM sync_devices WHERE id = $1")
95 + .bind(device)
96 + .fetch_one(pool)
97 + .await
98 + .expect("read last_seen_at")
99 + }
100 +
101 + async fn read_cursor(pool: &sqlx::PgPool, device: SyncDeviceId) -> i64 {
102 + sqlx::query_scalar("SELECT last_pulled_seq FROM sync_devices WHERE id = $1")
103 + .bind(device)
104 + .fetch_one(pool)
105 + .await
106 + .expect("read cursor")
107 + }
108 +
109 + async fn read_client_version(pool: &sqlx::PgPool, device: SyncDeviceId) -> Option<String> {
110 + sqlx::query_scalar("SELECT client_version FROM sync_devices WHERE id = $1")
111 + .bind(device)
112 + .fetch_one(pool)
113 + .await
114 + .expect("read client_version")
115 + }
116 +
117 + /// One INSERT change tuple in the shape `push_sync_changes` expects.
118 + fn change(
119 + table: &str,
120 + row: &str,
121 + ) -> (
122 + String,
123 + String,
124 + String,
125 + chrono::DateTime<chrono::Utc>,
126 + Option<serde_json::Value>,
127 + ) {
128 + (
129 + table.to_string(),
130 + "INSERT".to_string(),
131 + row.to_string(),
132 + chrono::Utc::now(),
133 + Some(serde_json::json!({ "row": row })),
134 + )
135 + }
136 +
137 + /// Push `n` entries one batch at a time, returning their seqs in order.
138 + async fn push_entries(
139 + pool: &sqlx::PgPool,
140 + app: SyncAppId,
141 + user: UserId,
142 + device: SyncDeviceId,
143 + n: usize,
144 + ) -> Vec<i64> {
145 + let mut seqs = Vec::with_capacity(n);
146 + for i in 0..n {
147 + let seq = synckit::push_sync_changes(
148 + pool,
149 + app,
150 + user,
151 + device,
152 + Uuid::new_v4(),
153 + &[change("tasks", &format!("row{i}"))],
154 + )
155 + .await
156 + .expect("push change");
157 + seqs.push(seq);
158 + }
159 + seqs
160 + }
161 +
162 + /// Age a single log entry by `days`, so retention and compaction margins can be
163 + /// asserted on both sides of their boundary without sleeping.
164 + async fn backdate_entry(pool: &sqlx::PgPool, seq: i64, days: i32) {
165 + sqlx::query(
166 + "UPDATE sync_log SET created_at = NOW() - make_interval(days => $2::int) WHERE seq = $1",
167 + )
168 + .bind(seq)
169 + .bind(days)
170 + .execute(pool)
171 + .await
172 + .expect("backdate log entry");
173 + }
174 +
175 + async fn surviving_seqs(pool: &sqlx::PgPool, app: SyncAppId, user: UserId) -> Vec<i64> {
176 + sqlx::query_scalar("SELECT seq FROM sync_log WHERE app_id = $1 AND user_id = $2 ORDER BY seq")
177 + .bind(app)
178 + .bind(user)
179 + .fetch_all(pool)
180 + .await
181 + .expect("read surviving seqs")
182 + }
183 +
184 + // ── devices ─────────────────────────────────────────────────────────────────
185 +
186 + #[tokio::test]
187 + async fn re_registering_a_device_replaces_its_platform_and_reported_version() {
188 + let db = TestDb::new().await;
189 + let user = seed_user(&db.pool, "skdk_reregister").await;
190 + let app = seed_app(&db.pool, user, "reregister").await;
191 +
192 + let first = synckit::upsert_sync_device(
193 + &db.pool,
194 + app,
195 + user,
196 + "workstation",
197 + SyncPlatform::Macos,
198 + Some("1.2.0"),
199 + )
200 + .await
201 + .expect("first registration");
202 +
203 + // The same device name re-registering after an OS reinstall and an SDK
204 + // upgrade. `EXCLUDED` wins for both columns, so a client that moved from
205 + // macOS to Linux is not still reported as a Mac running 1.2.0.
206 + let second = synckit::upsert_sync_device(
207 + &db.pool,
208 + app,
209 + user,
210 + "workstation",
211 + SyncPlatform::Linux,
212 + Some("1.5.0"),
213 + )
214 + .await
215 + .expect("re-registration");
216 +
217 + assert_eq!(
218 + second.id, first.id,
219 + "the unique (app, user, device_name) key means a re-register updates the \
220 + existing row, got first {first:?} then {second:?}"
221 + );
222 + assert_eq!(
223 + second.platform,
224 + SyncPlatform::Linux,
225 + "the new platform replaces the old one, row: {second:?}"
226 + );
227 + assert_eq!(
228 + second.client_version.as_deref(),
229 + Some("1.5.0"),
230 + "and a reported upgrade replaces the recorded version, row: {second:?}"
231 + );
232 +
233 + // A different name is a different device, not another update of this one.
234 + let other = synckit::upsert_sync_device(
235 + &db.pool,
236 + app,
237 + user,
238 + "phone",
239 + SyncPlatform::Ios,
240 + Some("1.5.0"),
241 + )
242 + .await
243 + .expect("second device");
244 + assert_ne!(
245 + other.id, first.id,
246 + "distinct device names are distinct rows, got {other:?} against {first:?}"
247 + );
248 + let devices = synckit::get_sync_devices(&db.pool, app, user)
249 + .await
250 + .expect("list devices");
251 + assert_eq!(
252 + devices.len(),
253 + 2,
254 + "two names, two devices, got {:?}",
255 + devices.iter().map(|d| &d.device_name).collect::<Vec<_>>()
256 + );
257 + }
258 +
259 + #[tokio::test]
260 + async fn device_listing_is_newest_seen_first_and_scoped_to_one_owner_and_app() {
261 + let db = TestDb::new().await;
262 + let alice = seed_user(&db.pool, "skdk_list_alice").await;
263 + let bob = seed_user(&db.pool, "skdk_list_bob").await;
264 + let app = seed_app(&db.pool, alice, "listapp").await;
265 + let other_app = seed_app(&db.pool, alice, "listapp2").await;
266 +
267 + // Three distinct last-seen stamps, so DESC and ASC give different answers
268 + // and neither can be mistaken for the insertion order: the middle device is
269 + // registered first and the oldest is registered last.
270 + let laptop = seed_device(&db.pool, app, alice, "laptop").await;
271 + set_last_seen(&db.pool, laptop, 72).await;
272 + let phone = seed_device(&db.pool, app, alice, "phone").await;
273 + set_last_seen(&db.pool, phone, 1).await;
274 + let tablet = seed_device(&db.pool, app, alice, "tablet").await;
275 + set_last_seen(&db.pool, tablet, 240).await;
276 +
277 + // Rows that must not appear: another user in this app, and this user in
278 + // another app.
279 + seed_device(&db.pool, app, bob, "bob-laptop").await;
280 + seed_device(&db.pool, other_app, alice, "alice-elsewhere").await;
281 +
282 + let listed = synckit::get_sync_devices(&db.pool, app, alice)
283 + .await
284 + .expect("list devices");
285 + let ids = listed.iter().map(|d| d.id).collect::<Vec<_>>();
286 + assert_eq!(
287 + ids,
288 + vec![phone, laptop, tablet],
289 + "devices come back most-recently-seen first, got {:?}",
290 + listed
291 + .iter()
292 + .map(|d| (&d.device_name, d.last_seen_at))
293 + .collect::<Vec<_>>()
294 + );
295 +
296 + let bobs = synckit::get_sync_devices(&db.pool, app, bob)
297 + .await
298 + .expect("list bob's devices");
299 + assert_eq!(
300 + bobs.len(),
301 + 1,
302 + "bob sees only his own device in this app, got {:?}",
303 + bobs.iter().map(|d| &d.device_name).collect::<Vec<_>>()
304 + );
305 + assert_eq!(
306 + bobs[0].device_name, "bob-laptop",
307 + "and it is his, got {bobs:?}"
308 + );
309 + }
310 +
311 + #[tokio::test]
312 + async fn touching_a_device_moves_last_seen_without_moving_its_cursor() {
313 + let db = TestDb::new().await;
314 + let user = seed_user(&db.pool, "skdk_touch").await;
315 + let app = seed_app(&db.pool, user, "touchapp").await;
316 + let device = seed_device(&db.pool, app, user, "laptop").await;
317 +
318 + // A cursor well past 1: an off-by-one or a reset to zero is visible.
319 + synckit::touch_and_advance_cursor(&db.pool, device, 37, None)
320 + .await
321 + .expect("advance cursor");
322 + let stale = set_last_seen(&db.pool, device, 96).await;
323 +
324 + synckit::touch_sync_device(&db.pool, device)
325 + .await
326 + .expect("touch device");
327 +
328 + let seen = read_last_seen(&db.pool, device).await;
329 + assert!(
330 + seen > stale,
331 + "a touch refreshes last_seen_at, was {stale}, now {seen}"
332 + );
333 + assert_eq!(
334 + read_cursor(&db.pool, device).await,
335 + 37,
336 + "and a touch is not a pull, so the cursor is untouched"
337 + );
338 + }
339 +
340 + #[tokio::test]
341 + async fn a_pull_upgrades_the_recorded_client_version_and_keeps_the_cursor_forward() {
342 + let db = TestDb::new().await;
343 + let user = seed_user(&db.pool, "skdk_pullver").await;
344 + let app = seed_app(&db.pool, user, "pullver").await;
345 + let device = seed_device_on_version(&db.pool, app, user, "laptop", Some("1.0.0")).await;
346 +
347 + // The device upgrades its SDK and pulls: the stored version must follow the
348 + // request, which `COALESCE($3, client_version)` does and the reversed
349 + // `COALESCE(client_version, $3)` would not.
350 + synckit::touch_and_advance_cursor(&db.pool, device, 12, Some("2.0.0"))
351 + .await
352 + .expect("pull with a new version");
353 + assert_eq!(
354 + read_client_version(&db.pool, device).await.as_deref(),
355 + Some("2.0.0"),
356 + "a reported upgrade replaces the recorded version"
357 + );
358 + assert_eq!(read_cursor(&db.pool, device).await, 12);
359 +
360 + // A later pull from a build that sends no User-Agent leaves the version
361 + // alone rather than blanking a fleet readout.
362 + synckit::touch_and_advance_cursor(&db.pool, device, 29, None)
363 + .await
364 + .expect("pull without a version");
365 + assert_eq!(
366 + read_client_version(&db.pool, device).await.as_deref(),
367 + Some("2.0.0"),
368 + "a version-less pull is quiet, not a downgrade to unknown"
369 + );
370 + assert_eq!(
371 + read_cursor(&db.pool, device).await,
372 + 29,
373 + "and the cursor still advanced"
374 + );
375 + }
376 +
377 + #[tokio::test]
378 + async fn version_distribution_groups_by_version_inside_the_window_and_orders_by_fleet_size() {
379 + let db = TestDb::new().await;
380 + let user = seed_user(&db.pool, "skdk_dist").await;
381 + let app = seed_app(&db.pool, user, "distapp").await;
382 +
383 + // Two devices on 1.4.0 inside the window, seen at different times so MAX
384 + // and MIN over the group give different answers.
385 + let recent_a = seed_device_on_version(&db.pool, app, user, "a1", Some("1.4.0")).await;
386 + let newest_1_4 = set_last_seen(&db.pool, recent_a, 24).await;
387 + let recent_b = seed_device_on_version(&db.pool, app, user, "a2", Some("1.4.0")).await;
388 + set_last_seen(&db.pool, recent_b, 72).await;
389 +
390 + // A third 1.4.0 device that has not synced in 31 days. It is outside a
391 + // 30-day window, so its presence changes the group's count from 2 to 3 if
392 + // the window is missing or inverted.
393 + let lapsed = seed_device_on_version(&db.pool, app, user, "a3", Some("1.4.0")).await;
394 + set_last_seen(&db.pool, lapsed, 31 * 24).await;
395 +
396 + // Two on 1.2.0, tying 1.4.0's in-window count, so the version tiebreak is
397 + // the only thing that can order them.
398 + for name in ["c1", "c2"] {
399 + let d = seed_device_on_version(&db.pool, app, user, name, Some("1.2.0")).await;
400 + set_last_seen(&db.pool, d, 5).await;
401 + }
402 +
403 + // Just inside the window: 29 days.
404 + let edge = seed_device_on_version(&db.pool, app, user, "d1", Some("1.3.0")).await;
405 + set_last_seen(&db.pool, edge, 29 * 24).await;
406 +
407 + // A client old enough to send no version at all.
408 + let quiet = seed_device_on_version(&db.pool, app, user, "e1", None).await;
409 + set_last_seen(&db.pool, quiet, 2).await;
410 +
411 + let rows = synckit::client_version_distribution(&db.pool, 30)
412 + .await
413 + .expect("version distribution");
414 + let shape = rows
415 + .iter()
416 + .map(|r| (r.client_version.clone(), r.devices))
417 + .collect::<Vec<_>>();
418 + assert_eq!(
419 + shape,
420 + vec![
421 + (Some("1.4.0".to_string()), 2),
422 + (Some("1.2.0".to_string()), 2),
423 + (Some("1.3.0".to_string()), 1),
424 + (None, 1),
425 + ],
426 + "counts are per version inside the window, ties break on the higher \
427 + version, and the never-reported bucket sorts last, got {rows:?}"
428 + );
429 + assert_eq!(
430 + rows[0].last_seen_at, newest_1_4,
431 + "the stamp on a group is the most recent sync in it, not the oldest, got {:?}",
432 + rows[0]
433 + );
434 +
435 + // Widening the window past the lapsed device brings it back into its group,
436 + // which is the other side of the same boundary.
437 + let wide = synckit::client_version_distribution(&db.pool, 60)
438 + .await
439 + .expect("wide distribution");
440 + let lapsed_group = wide
441 + .iter()
442 + .find(|r| r.client_version.as_deref() == Some("1.4.0"))
443 + .unwrap_or_else(|| panic!("1.4.0 group missing from {wide:?}"));
444 + assert_eq!(
445 + lapsed_group.devices, 3,
446 + "a 60-day window includes the device last seen at 31 days, got {wide:?}"
447 + );
448 + }
449 +
450 + // ── keys ────────────────────────────────────────────────────────────────────
451 +
452 + #[tokio::test]
453 + async fn key_generations_advance_by_one_in_order_and_a_stale_writer_cannot_rewind_them() {
454 + let db = TestDb::new().await;
455 + let user = seed_user(&db.pool, "skdk_gens").await;
456 + let app = seed_app(&db.pool, user, "gensapp").await;
457 +
458 + // Walk three generations past the insert, so the sequence 1, 2, 3, 4 is
459 + // long enough that a version that stuck, doubled, or counted down would
460 + // read differently. Two generations cannot tell +1 from *2.
461 + let mut versions = Vec::new();
462 + for (n, envelope) in ["env_g0", "env_g1", "env_g2", "env_g3"].iter().enumerate() {
463 + let expected = i32::try_from(n).expect("small generation");
464 + assert!(
465 + synckit::upsert_sync_key(&db.pool, app, user, envelope, expected)
466 + .await
467 + .expect("upsert key"),
468 + "generation {n} expected version {expected} and should have written {envelope}"
469 + );
470 + let info = synckit::get_sync_key(&db.pool, app, user)
471 + .await
472 + .expect("read key")
473 + .expect("key exists");
474 + assert_eq!(
475 + info.encrypted_key, *envelope,
476 + "the envelope just written is the one read back at generation {n}"
477 + );
478 + versions.push(info.key_version);
479 + }
480 + assert_eq!(
481 + versions,
482 + vec![1, 2, 3, 4],
483 + "each accepted rotation advances the generation by exactly one, got {versions:?}"
484 + );
485 + assert!(
486 + versions.windows(2).all(|w| w[1] > w[0]),
487 + "and generations are strictly increasing, got {versions:?}"
488 + );
489 +
490 + // A device that slept through two rotations tries to write against the
491 + // generation it remembers. The WHERE clause on the upsert refuses it.
492 + assert!(
493 + !synckit::upsert_sync_key(&db.pool, app, user, "env_stale", 2)
494 + .await
495 + .expect("stale upsert"),
496 + "an expected version of 2 against a stored 4 is a conflict, not a write"
497 + );
498 + let after = synckit::get_sync_key(&db.pool, app, user)
499 + .await
500 + .expect("read key")
Lines truncated
@@ -1,0 +1,686 @@
1 + //! DB-layer contract tests for `db::synckit::log`: the pull side, where a
2 + //! dropped entry is invisible.
3 + //!
4 + //! The change log is the sync engine's source of truth, so the failure this
5 + //! file exists to catch is the silent one: a pull that drops an entry at a page
6 + //! boundary, a cursor that is inclusive at one end and exclusive at the other,
7 + //! or a filter that turns a full drain into a partial one. Every pagination
8 + //! test here asserts the same thing from two directions, that draining the log
9 + //! a page at a time yields exactly what one unpaginated pull yields, because
10 + //! that equality is what a client's convergence depends on and neither half
11 + //! alone would notice a missing row.
12 + //!
13 + //! Pinned here: cursor exclusivity at both ends, the empty-list table filter
14 + //! meaning "nothing" rather than "everything", `since` being inclusive of its
15 + //! own boundary, pull ordering being arrival order and not the client clock,
16 + //! and the two filters composing as AND rather than OR.
17 + //!
18 + //! The write side of the same module is `db_synckit_log_push_layer`, and the
19 + //! audit trail is `db_synckit_security_layer`. Neighbouring ground is
20 + //! deliberately not repeated: `db_synckit_layer` pins append order, per-user
21 + //! scoping, blobs and keys; `db_synckit_groups` pins group membership and the
22 + //! structural isolation of `sync_group_log` from personal pulls.
23 + //!
24 + //! Delete this file and the pagination contract of the sync protocol is
25 + //! unasserted at this layer.
26 +
27 + use crate::harness::db::TestDb;
28 + use crate::harness::seed_user;
29 +
30 + use makenotwork::db::synckit;
31 + use makenotwork::db::{SyncAppId, SyncDeviceId, UserId};
32 + use uuid::Uuid;
33 +
34 + /// One change tuple in the shape `push_sync_changes` and `push_group_changes`
35 + /// both take.
36 + type Change = (
37 + String,
38 + String,
39 + String,
40 + chrono::DateTime<chrono::Utc>,
41 + Option<serde_json::Value>,
42 + );
43 +
44 + /// Seed a sync app owned by `user`.
45 + async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
46 + sqlx::query_scalar::<_, SyncAppId>(
47 + "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix)
48 + VALUES ($1, $2, $3, $4) RETURNING id",
49 + )
50 + .bind(user)
51 + .bind(name)
52 + .bind(format!("hash_{name}"))
53 + .bind(&name[..name.len().min(8)])
54 + .fetch_one(pool)
55 + .await
56 + .expect("seed sync app")
57 + }
58 +
59 + /// Seed a device row for a user within an app.
60 + async fn seed_device(
61 + pool: &sqlx::PgPool,
62 + app: SyncAppId,
63 + user: UserId,
64 + name: &str,
65 + ) -> SyncDeviceId {
66 + sqlx::query_scalar::<_, SyncDeviceId>(
67 + "INSERT INTO sync_devices (app_id, user_id, device_name, platform)
68 + VALUES ($1, $2, $3, 'macos') RETURNING id",
69 + )
70 + .bind(app)
71 + .bind(user)
72 + .bind(name)
73 + .fetch_one(pool)
74 + .await
75 + .expect("seed device")
76 + }
77 +
78 + /// A fixed instant offset by whole hours. Fixed rather than `Utc::now()` so a
79 + /// `since` boundary can be asserted for equality without depending on the
80 + /// wall clock.
81 + fn ts(offset_hours: i64) -> chrono::DateTime<chrono::Utc> {
82 + "2024-03-01T12:00:00Z"
83 + .parse::<chrono::DateTime<chrono::Utc>>()
84 + .expect("fixed base timestamp parses")
85 + + chrono::Duration::hours(offset_hours)
86 + }
87 +
88 + /// An INSERT change on `table` for `row`, client-stamped at `ts(offset_hours)`.
89 + fn change_at(table: &str, row: &str, offset_hours: i64) -> Change {
90 + (
91 + table.to_string(),
92 + "INSERT".to_string(),
93 + row.to_string(),
94 + ts(offset_hours),
95 + Some(serde_json::json!({ "row": row })),
96 + )
97 + }
98 +
99 + /// An INSERT change on `table` for `row` at the base instant.
100 + fn change(table: &str, row: &str) -> Change {
101 + change_at(table, row, 0)
102 + }
103 +
104 + /// Push `changes` as one fresh batch, returning the cursor the push reports.
105 + async fn push(
106 + pool: &sqlx::PgPool,
107 + app: SyncAppId,
108 + user: UserId,
109 + device: SyncDeviceId,
110 + changes: &[Change],
111 + ) -> i64 {
112 + synckit::push_sync_changes(pool, app, user, device, Uuid::new_v4(), changes)
113 + .await
114 + .expect("push sync changes")
115 + }
116 +
117 + /// Walk the personal log `limit` entries at a time the way a client does,
118 + /// carrying the last returned seq forward as the next cursor. Returns the
119 + /// row ids in the order they were handed over.
120 + ///
121 + /// The iteration guard is what makes a non-terminating cursor fail as a test
122 + /// rather than hang the suite.
123 + async fn drain(
124 + pool: &sqlx::PgPool,
125 + app: SyncAppId,
126 + user: UserId,
127 + limit: i64,
128 + tables: Option<&[String]>,
129 + since: Option<chrono::DateTime<chrono::Utc>>,
130 + ) -> Vec<String> {
131 + let mut seen: Vec<String> = Vec::new();
132 + let mut cursor = 0i64;
133 + let mut pages = 0;
134 + loop {
135 + let page =
136 + synckit::pull_sync_changes_filtered(pool, app, user, cursor, limit, tables, since)
137 + .await
138 + .expect("paginated pull");
139 + if page.is_empty() {
140 + break;
141 + }
142 + assert!(
143 + i64::try_from(page.len()).expect("page length fits i64") <= limit,
144 + "a page must never exceed the limit it was asked for: got {} for limit {limit}",
145 + page.len()
146 + );
147 + cursor = page.last().expect("non-empty page has a last entry").seq;
148 + seen.extend(page.into_iter().map(|e| e.row_id));
149 + pages += 1;
150 + assert!(
151 + pages <= 64,
152 + "the cursor did not terminate after 64 pages of limit {limit}; seen so far: {seen:?}"
153 + );
154 + }
155 + seen
156 + }
157 +
158 + fn names(v: &[String]) -> Vec<&str> {
159 + v.iter().map(String::as_str).collect()
160 + }
161 + // ── log: pagination and cursor termination ──────────────────────────────────
162 +
163 + #[tokio::test]
164 + async fn a_paginated_drain_yields_exactly_what_one_unpaginated_pull_yields() {
165 + let db = TestDb::new().await;
166 + let user = seed_user(&db.pool, "sklogp_drain").await;
167 + let app = seed_app(&db.pool, user, "logdrain").await;
168 + let device = seed_device(&db.pool, app, user, "laptop").await;
169 +
170 + // Seven entries across three batches of unequal size, so the batch
171 + // boundaries do not line up with any of the page sizes below.
172 + push(
173 + &db.pool,
174 + app,
175 + user,
176 + device,
177 + &[change("tasks", "r0"), change("tasks", "r1")],
178 + )
179 + .await;
180 + push(
181 + &db.pool,
182 + app,
183 + user,
184 + device,
185 + &[
186 + change("tasks", "r2"),
187 + change("tasks", "r3"),
188 + change("tasks", "r4"),
189 + ],
190 + )
191 + .await;
192 + push(
193 + &db.pool,
194 + app,
195 + user,
196 + device,
197 + &[change("tasks", "r5"), change("tasks", "r6")],
198 + )
199 + .await;
200 +
201 + let whole = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 1000, None, None)
202 + .await
203 + .unwrap();
204 + let whole_rows: Vec<String> = whole.iter().map(|e| e.row_id.clone()).collect();
205 + assert_eq!(
206 + names(&whole_rows),
207 + ["r0", "r1", "r2", "r3", "r4", "r5", "r6"],
208 + "one unpaginated pull is the reference: {whole_rows:?}"
209 + );
210 +
211 + // 2 and 3 leave a short final page; 7 is an exact multiple, which is the
212 + // case where a client only stops because the pull after the last full page
213 + // comes back empty.
214 + for limit in [2i64, 3, 7] {
215 + let paged = drain(&db.pool, app, user, limit, None, None).await;
216 + assert_eq!(
217 + paged, whole_rows,
218 + "draining {limit} at a time must lose and duplicate nothing: {paged:?}"
219 + );
220 + }
221 + }
222 +
223 + #[tokio::test]
224 + async fn the_pull_cursor_is_exclusive_at_both_ends() {
225 + let db = TestDb::new().await;
226 + let user = seed_user(&db.pool, "sklogp_cursor").await;
227 + let app = seed_app(&db.pool, user, "logcursor").await;
228 + let device = seed_device(&db.pool, app, user, "laptop").await;
229 +
230 + push(
231 + &db.pool,
232 + app,
233 + user,
234 + device,
235 + &[
236 + change("tasks", "r0"),
237 + change("tasks", "r1"),
238 + change("tasks", "r2"),
239 + change("tasks", "r3"),
240 + change("tasks", "r4"),
241 + ],
242 + )
243 + .await;
244 +
245 + let all = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None)
246 + .await
247 + .unwrap();
248 + assert_eq!(all.len(), 5, "five entries were pushed: {all:?}");
249 +
250 + // Resuming at the seq of r1 must hand back r2 onward. An inclusive `>=`
251 + // would re-deliver r1 and return four entries, which is why the count and
252 + // the first row id are both asserted.
253 + let after_second =
254 + synckit::pull_sync_changes_filtered(&db.pool, app, user, all[1].seq, 100, None, None)
255 + .await
256 + .unwrap();
257 + let rows: Vec<&str> = after_second.iter().map(|e| e.row_id.as_str()).collect();
258 + assert_eq!(
259 + rows,
260 + ["r2", "r3", "r4"],
261 + "a cursor is the last entry already seen, not the next one to send: {rows:?}"
262 + );
263 +
264 + // The cursor a client holds after a full drain returns nothing, which is
265 + // the only reason the drain loop above terminates.
266 + let at_end = synckit::pull_sync_changes_filtered(
267 + &db.pool,
268 + app,
269 + user,
270 + all.last().unwrap().seq,
271 + 100,
272 + None,
273 + None,
274 + )
275 + .await
276 + .unwrap();
277 + assert!(
278 + at_end.is_empty(),
279 + "the final cursor must drain empty, got {at_end:?}"
280 + );
281 +
282 + // A cursor past the end (a log truncated behind a client, or a client that
283 + // held a cursor from another app) is empty rather than an error or a rewind.
284 + let past_end = synckit::pull_sync_changes_filtered(
285 + &db.pool,
286 + app,
287 + user,
288 + all.last().unwrap().seq + 5_000,
289 + 100,
290 + None,
291 + None,
292 + )
293 + .await
294 + .unwrap();
295 + assert!(
296 + past_end.is_empty(),
297 + "a cursor beyond the highest seq returns nothing, got {past_end:?}"
298 + );
299 + }
300 +
301 + #[tokio::test]
302 + async fn a_limit_bounds_one_page_and_a_zero_limit_returns_no_page_at_all() {
303 + let db = TestDb::new().await;
304 + let user = seed_user(&db.pool, "sklogp_limit").await;
305 + let app = seed_app(&db.pool, user, "loglimit").await;
306 + let device = seed_device(&db.pool, app, user, "laptop").await;
307 +
308 + push(
309 + &db.pool,
310 + app,
311 + user,
312 + device,
313 + &[
314 + change("tasks", "r0"),
315 + change("tasks", "r1"),
316 + change("tasks", "r2"),
317 + change("tasks", "r3"),
318 + change("tasks", "r4"),
319 + ],
320 + )
321 + .await;
322 +
323 + let page = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 3, None, None)
324 + .await
325 + .unwrap();
326 + let rows: Vec<&str> = page.iter().map(|e| e.row_id.as_str()).collect();
327 + assert_eq!(
328 + rows,
329 + ["r0", "r1", "r2"],
330 + "a limit takes the lowest seqs, not an arbitrary three: {rows:?}"
331 + );
332 +
333 + // A limit larger than the log is not an error and does not pad.
334 + let over = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 500, None, None)
335 + .await
336 + .unwrap();
337 + assert_eq!(over.len(), 5, "a generous limit returns all five: {over:?}");
338 +
339 + // Pinned because it is a live hazard for a caller that computes a page
340 + // size: a zero limit is an empty page, indistinguishable from "drained",
341 + // so a client that ever asks for zero silently stops syncing.
342 + let none = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 0, None, None)
343 + .await
344 + .unwrap();
345 + assert!(
346 + none.is_empty(),
347 + "a zero limit reads as a finished drain, got {none:?}"
348 + );
349 + }
350 +
351 + #[tokio::test]
352 + async fn the_unfiltered_pull_matches_the_filtered_pull_with_no_filters() {
353 + let db = TestDb::new().await;
354 + let user = seed_user(&db.pool, "sklogp_parity").await;
355 + let app = seed_app(&db.pool, user, "logparity").await;
356 + let device = seed_device(&db.pool, app, user, "laptop").await;
357 +
358 + push(
359 + &db.pool,
360 + app,
361 + user,
362 + device,
363 + &[
364 + change("tasks", "r0"),
365 + change("notes", "r1"),
366 + change("tasks", "r2"),
367 + change("tags", "r3"),
368 + ],
369 + )
370 + .await;
371 +
372 + let legacy = synckit::pull_sync_changes(&db.pool, app, user, 0, 100)
373 + .await
374 + .unwrap();
375 + let filtered = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None)
376 + .await
377 + .unwrap();
378 +
379 + let legacy_rows: Vec<&str> = legacy.iter().map(|e| e.row_id.as_str()).collect();
380 + let filtered_rows: Vec<&str> = filtered.iter().map(|e| e.row_id.as_str()).collect();
381 + assert_eq!(
382 + legacy_rows, filtered_rows,
383 + "passing no filters is documented as identical to the older pull: {legacy_rows:?} vs {filtered_rows:?}"
384 + );
385 + assert_eq!(
386 + legacy.iter().map(|e| e.seq).collect::<Vec<_>>(),
387 + filtered.iter().map(|e| e.seq).collect::<Vec<_>>(),
388 + "and hands back the same cursors"
389 + );
390 + assert_eq!(legacy_rows, ["r0", "r1", "r2", "r3"]);
391 + }
392 +
393 + #[tokio::test]
394 + async fn pull_order_is_arrival_order_and_not_the_client_clock() {
395 + let db = TestDb::new().await;
396 + let user = seed_user(&db.pool, "sklogp_order").await;
397 + let app = seed_app(&db.pool, user, "logorder").await;
398 + let device = seed_device(&db.pool, app, user, "laptop").await;
399 +
400 + // Client timestamps descend as the entries arrive, so ordering by
401 + // client_timestamp would reverse this list. A device with a skewed clock
402 + // is the real case: its entries must still replay in the order the server
403 + // accepted them.
404 + push(
405 + &db.pool,
406 + app,
407 + user,
408 + device,
409 + &[
410 + change_at("tasks", "arrived-first", 9),
411 + change_at("tasks", "arrived-second", 5),
412 + change_at("tasks", "arrived-third", 1),
413 + ],
414 + )
415 + .await;
416 +
417 + let entries = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None)
418 + .await
419 + .unwrap();
420 + let rows: Vec<&str> = entries.iter().map(|e| e.row_id.as_str()).collect();
421 + assert_eq!(
422 + rows,
423 + ["arrived-first", "arrived-second", "arrived-third"],
424 + "seq order, not client_timestamp order: {rows:?}"
425 + );
426 + assert!(
427 + entries.windows(2).all(|w| w[0].seq < w[1].seq),
428 + "and seq is strictly increasing: {:?}",
429 + entries.iter().map(|e| e.seq).collect::<Vec<_>>()
430 + );
431 + }
432 + // ── log: filters ────────────────────────────────────────────────────────────
433 +
434 + #[tokio::test]
435 + async fn the_table_filter_selects_the_named_tables_and_an_empty_list_selects_none() {
436 + let db = TestDb::new().await;
437 + let user = seed_user(&db.pool, "sklogp_tables").await;
438 + let app = seed_app(&db.pool, user, "logtables").await;
439 + let device = seed_device(&db.pool, app, user, "laptop").await;
440 +
441 + push(
442 + &db.pool,
443 + app,
444 + user,
445 + device,
446 + &[
447 + change("tasks", "t0"),
448 + change("notes", "n0"),
449 + change("tasks", "t1"),
450 + change("tags", "g0"),
451 + change("notes", "n1"),
452 + change("tasks", "t2"),
453 + ],
454 + )
455 + .await;
456 +
457 + let tasks = vec!["tasks".to_string()];
458 + let picked = synckit::pull_sync_changes_filtered(
459 + &db.pool,
460 + app,
461 + user,
462 + 0,
463 + 100,
464 + Some(tasks.as_slice()),
465 + None,
466 + )
467 + .await
468 + .unwrap();
469 + let rows: Vec<&str> = picked.iter().map(|e| e.row_id.as_str()).collect();
470 + assert_eq!(
471 + rows,
472 + ["t0", "t1", "t2"],
473 + "one named table yields three of the six: {rows:?}"
474 + );
475 +
476 + // Two names, so a filter that only ever honoured the first element would
477 + // return three here instead of four.
478 + let two = vec!["tasks".to_string(), "tags".to_string()];
479 + let picked_two = synckit::pull_sync_changes_filtered(
480 + &db.pool,
481 + app,
482 + user,
483 + 0,
484 + 100,
485 + Some(two.as_slice()),
486 + None,
487 + )
488 + .await
489 + .unwrap();
490 + let rows_two: Vec<&str> = picked_two.iter().map(|e| e.row_id.as_str()).collect();
491 + assert_eq!(
492 + rows_two,
493 + ["t0", "t1", "g0", "t2"],
494 + "every named table is honoured, in seq order: {rows_two:?}"
495 + );
496 +
497 + // An empty list is not NULL, so it means "no table matches", not "no
498 + // filter". A caller that builds the list from a user's selection and lets
499 + // it come back empty gets nothing, not everything.
500 + let empty: Vec<String> = Vec::new();
Lines truncated
@@ -1,0 +1,610 @@
1 + //! DB-layer contract tests for `db::synckit::log`: the write side, personal
2 + //! and group.
3 + //!
4 + //! A push is what a client's local edit becomes, so a push that appends a
5 + //! second copy under redelivery, or reports a cursor the client then stores as
6 + //! its own, corrupts or rewinds a device with no error anywhere. Pinned here: a
7 + //! replayed batch appends nothing and reports that batch's own cursor rather
8 + //! than the log's current maximum, an empty push writes nothing, entries carry
9 + //! the key generation active at push time, a `None` payload lands as JSON null
10 + //! rather than SQL NULL so `operation` stays the only delete signal, and the
11 + //! group log's twin of each, including entries keeping the generation they were
12 + //! sealed under across a rotation.
13 + //!
14 + //! The read side of the same module is `db_synckit_log_layer`.
15 + //! `db_synckit_groups` pins group membership and the structural isolation of
16 + //! `sync_group_log` from personal pulls, and is not repeated.
17 + //!
18 + //! Delete this file and replay safety on the write path stops being asserted at
19 + //! this layer: a redelivered batch could append a second copy of every change,
20 + //! and a rotation could re-stamp entries that are no longer readable.
21 +
22 + use crate::harness::db::TestDb;
23 + use crate::harness::seed_user;
24 +
25 + use makenotwork::db::synckit;
26 + use makenotwork::db::{SyncAppId, SyncDeviceId, SyncGroupId, SyncOperation, UserId};
27 + use uuid::Uuid;
28 +
29 + /// One change tuple in the shape `push_sync_changes` and `push_group_changes`
30 + /// both take.
31 + type Change = (
32 + String,
33 + String,
34 + String,
35 + chrono::DateTime<chrono::Utc>,
36 + Option<serde_json::Value>,
37 + );
38 +
39 + /// Seed a sync app owned by `user`.
40 + async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
41 + sqlx::query_scalar::<_, SyncAppId>(
42 + "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix)
43 + VALUES ($1, $2, $3, $4) RETURNING id",
44 + )
45 + .bind(user)
46 + .bind(name)
47 + .bind(format!("hash_{name}"))
48 + .bind(&name[..name.len().min(8)])
49 + .fetch_one(pool)
50 + .await
51 + .expect("seed sync app")
52 + }
53 +
54 + /// Seed a device row for a user within an app.
55 + async fn seed_device(
56 + pool: &sqlx::PgPool,
57 + app: SyncAppId,
58 + user: UserId,
59 + name: &str,
60 + ) -> SyncDeviceId {
61 + sqlx::query_scalar::<_, SyncDeviceId>(
62 + "INSERT INTO sync_devices (app_id, user_id, device_name, platform)
63 + VALUES ($1, $2, $3, 'macos') RETURNING id",
64 + )
65 + .bind(app)
66 + .bind(user)
67 + .bind(name)
68 + .fetch_one(pool)
69 + .await
70 + .expect("seed device")
71 + }
72 +
73 + /// A fixed instant offset by whole hours. Fixed rather than `Utc::now()` so a
74 + /// `since` boundary can be asserted for equality without depending on the
75 + /// wall clock.
76 + fn ts(offset_hours: i64) -> chrono::DateTime<chrono::Utc> {
77 + "2024-03-01T12:00:00Z"
78 + .parse::<chrono::DateTime<chrono::Utc>>()
79 + .expect("fixed base timestamp parses")
80 + + chrono::Duration::hours(offset_hours)
81 + }
82 +
83 + /// An INSERT change on `table` for `row`, client-stamped at `ts(offset_hours)`.
84 + fn change_at(table: &str, row: &str, offset_hours: i64) -> Change {
85 + (
86 + table.to_string(),
87 + "INSERT".to_string(),
88 + row.to_string(),
89 + ts(offset_hours),
90 + Some(serde_json::json!({ "row": row })),
91 + )
92 + }
93 +
94 + /// An INSERT change on `table` for `row` at the base instant.
95 + fn change(table: &str, row: &str) -> Change {
96 + change_at(table, row, 0)
97 + }
98 +
99 + /// Push `changes` as one fresh batch, returning the cursor the push reports.
100 + async fn push(
101 + pool: &sqlx::PgPool,
102 + app: SyncAppId,
103 + user: UserId,
104 + device: SyncDeviceId,
105 + changes: &[Change],
106 + ) -> i64 {
107 + synckit::push_sync_changes(pool, app, user, device, Uuid::new_v4(), changes)
108 + .await
109 + .expect("push sync changes")
110 + }
111 +
112 + /// The group-log twin of [`drain`].
113 + async fn drain_group(
114 + pool: &sqlx::PgPool,
115 + app: SyncAppId,
116 + group: SyncGroupId,
117 + limit: i64,
118 + tables: Option<&[String]>,
119 + since: Option<chrono::DateTime<chrono::Utc>>,
120 + ) -> Vec<String> {
121 + let mut seen: Vec<String> = Vec::new();
122 + let mut cursor = 0i64;
123 + let mut pages = 0;
124 + loop {
125 + let page =
126 + synckit::pull_group_changes_filtered(pool, app, group, cursor, limit, tables, since)
127 + .await
128 + .expect("paginated group pull");
129 + if page.is_empty() {
130 + break;
131 + }
132 + assert!(
133 + i64::try_from(page.len()).expect("page length fits i64") <= limit,
134 + "a group page must never exceed the limit it was asked for: got {} for limit {limit}",
135 + page.len()
136 + );
137 + cursor = page.last().expect("non-empty page has a last entry").seq;
138 + seen.extend(page.into_iter().map(|e| e.row_id));
139 + pages += 1;
140 + assert!(
141 + pages <= 64,
142 + "the group cursor did not terminate after 64 pages of limit {limit}; seen: {seen:?}"
143 + );
144 + }
145 + seen
146 + }
147 +
148 + fn names(v: &[String]) -> Vec<&str> {
149 + v.iter().map(String::as_str).collect()
150 + }
151 + // ── log: push semantics ─────────────────────────────────────────────────────
152 +
153 + #[tokio::test]
154 + async fn a_replayed_batch_appends_nothing_and_returns_that_batch_own_cursor() {
155 + let db = TestDb::new().await;
156 + let user = seed_user(&db.pool, "sklogp_replay").await;
157 + let app = seed_app(&db.pool, user, "logreplay").await;
158 + let device = seed_device(&db.pool, app, user, "laptop").await;
159 +
160 + let first_batch = Uuid::new_v4();
161 + let first_changes = [change("tasks", "a0"), change("tasks", "a1")];
162 + let first_cursor =
163 + synckit::push_sync_changes(&db.pool, app, user, device, first_batch, &first_changes)
164 + .await
165 + .unwrap();
166 +
167 + // A later batch moves the log's maximum past the first batch, so the
168 + // replay below can tell "the cursor of the replayed batch" apart from
169 + // "whatever the highest seq happens to be now".
170 + let later_cursor = push(
171 + &db.pool,
172 + app,
173 + user,
174 + device,
175 + &[
176 + change("tasks", "b0"),
177 + change("tasks", "b1"),
178 + change("tasks", "b2"),
179 + ],
180 + )
181 + .await;
182 + assert!(
183 + later_cursor > first_cursor,
184 + "the second batch must sit above the first: {later_cursor} vs {first_cursor}"
185 + );
186 +
187 + // The redelivery carries different payloads under the same batch id, the
188 + // way a client that retried after mutating its outbox would. Nothing from
189 + // it may land.
190 + let replay_changes = [change("tasks", "tampered0"), change("tasks", "tampered1")];
191 + let replayed =
192 + synckit::push_sync_changes(&db.pool, app, user, device, first_batch, &replay_changes)
193 + .await
194 + .unwrap();
195 + assert_eq!(
196 + replayed, first_cursor,
197 + "a replay reports the cursor the batch already had, not the log's maximum ({later_cursor})"
198 + );
199 +
200 + let entries = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None)
201 + .await
202 + .unwrap();
203 + let rows: Vec<&str> = entries.iter().map(|e| e.row_id.as_str()).collect();
204 + assert_eq!(
205 + rows,
206 + ["a0", "a1", "b0", "b1", "b2"],
207 + "the redelivery is a no-op, not a second write: {rows:?}"
208 + );
209 + }
210 +
211 + #[tokio::test]
212 + async fn an_empty_push_writes_nothing_and_reports_zero_rather_than_the_current_cursor() {
213 + let db = TestDb::new().await;
214 + let user = seed_user(&db.pool, "sklogp_empty").await;
215 + let app = seed_app(&db.pool, user, "logempty").await;
216 + let device = seed_device(&db.pool, app, user, "laptop").await;
217 +
218 + let real_cursor = push(
219 + &db.pool,
220 + app,
221 + user,
222 + device,
223 + &[
224 + change("tasks", "r0"),
225 + change("tasks", "r1"),
226 + change("tasks", "r2"),
227 + change("tasks", "r3"),
228 + ],
229 + )
230 + .await;
231 + assert!(
232 + real_cursor > 0,
233 + "four entries were pushed, so the cursor is above zero: {real_cursor}"
234 + );
235 +
236 + let empty = synckit::push_sync_changes(&db.pool, app, user, device, Uuid::new_v4(), &[])
237 + .await
238 + .unwrap();
239 + // Pinned as the contract it has, not the one a caller might want: an empty
240 + // push answers 0, so a client that stores the reply as its cursor rewinds
241 + // to the start of the log and re-pulls everything.
242 + assert_eq!(
243 + empty, 0,
244 + "an empty push reports 0, not the log's current cursor ({real_cursor})"
245 + );
246 +
247 + let entries = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None)
248 + .await
249 + .unwrap();
250 + assert_eq!(
251 + entries.len(),
252 + 4,
253 + "and it wrote nothing: {:?}",
254 + entries
255 + .iter()
256 + .map(|e| e.row_id.as_str())
257 + .collect::<Vec<_>>()
258 + );
259 + }
260 +
261 + #[tokio::test]
262 + async fn pushed_entries_are_stamped_with_the_key_id_active_at_push_time() {
263 + let db = TestDb::new().await;
264 + let user = seed_user(&db.pool, "sklogp_keyid").await;
265 + let app = seed_app(&db.pool, user, "logkeyid").await;
266 + let device = seed_device(&db.pool, app, user, "laptop").await;
267 +
268 + // Before any key exists the stamp is NULL, which is what marks an entry as
269 + // predating encryption setup.
270 + push(&db.pool, app, user, device, &[change("tasks", "pre-key")]).await;
271 +
272 + synckit::upsert_sync_key(&db.pool, app, user, "env_v1", 0)
273 + .await
274 + .unwrap();
275 + // key_id 4 rather than the default 1, so a stamp that hardcoded 1 or
276 + // reused key_version would disagree.
277 + sqlx::query("UPDATE sync_keys SET key_id = 4 WHERE app_id = $1 AND user_id = $2")
278 + .bind(app)
279 + .bind(user)
280 + .execute(&db.pool)
281 + .await
282 + .unwrap();
283 +
284 + push(&db.pool, app, user, device, &[change("tasks", "post-key")]).await;
285 +
286 + let entries = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None)
287 + .await
288 + .unwrap();
289 + assert_eq!(entries.len(), 2, "two entries were pushed: {entries:?}");
290 + assert_eq!(entries[0].row_id, "pre-key");
291 + assert_eq!(
292 + entries[0].key_id, None,
293 + "an entry pushed before any key exists carries no key id: {:?}",
294 + entries[0]
295 + );
296 + assert_eq!(entries[1].row_id, "post-key");
297 + assert_eq!(
298 + entries[1].key_id,
299 + Some(4),
300 + "and one pushed after carries the active key id: {:?}",
301 + entries[1]
302 + );
303 + }
304 +
305 + #[tokio::test]
306 + async fn a_change_with_no_payload_is_stored_as_json_null_and_keeps_its_operation() {
307 + let db = TestDb::new().await;
308 + let user = seed_user(&db.pool, "sklogp_payload").await;
309 + let app = seed_app(&db.pool, user, "logpayload").await;
310 + let device = seed_device(&db.pool, app, user, "laptop").await;
311 +
312 + let body = serde_json::json!({ "cipher": "abc", "n": 42 });
313 + push(
314 + &db.pool,
315 + app,
316 + user,
317 + device,
318 + &[
319 + (
320 + "tasks".to_string(),
321 + "UPDATE".to_string(),
322 + "kept".to_string(),
323 + ts(0),
324 + Some(body.clone()),
325 + ),
326 + (
327 + "tasks".to_string(),
328 + "DELETE".to_string(),
329 + "gone".to_string(),
330 + ts(1),
331 + None,
332 + ),
333 + ],
334 + )
335 + .await;
336 +
337 + let entries = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None)
338 + .await
339 + .unwrap();
340 + assert_eq!(entries.len(), 2, "both changes landed: {entries:?}");
341 +
342 + assert_eq!(entries[0].operation, SyncOperation::Update);
343 + assert_eq!(
344 + entries[0].data,
345 + Some(body),
346 + "an encrypted payload round-trips byte for byte: {:?}",
347 + entries[0].data
348 + );
349 + assert_eq!(entries[0].client_timestamp, ts(0));
350 +
351 + assert_eq!(entries[1].operation, SyncOperation::Delete);
352 + // The push converts a missing payload to JSON null, so this reads back as
353 + // Some(Null) and not None. A client that tests for a SQL NULL to detect a
354 + // delete would never fire; the operation column is the signal.
355 + assert_eq!(
356 + entries[1].data,
357 + Some(serde_json::Value::Null),
358 + "a None payload is stored as JSON null, not SQL NULL: {:?}",
359 + entries[1].data
360 + );
361 + }
362 + // ── log: the group changelog ────────────────────────────────────────────────
363 +
364 + #[tokio::test]
365 + async fn a_paginated_group_drain_yields_exactly_what_one_unpaginated_group_pull_yields() {
366 + let db = TestDb::new().await;
367 + let admin = seed_user(&db.pool, "skglogp_drain").await;
368 + let app = seed_app(&db.pool, admin, "glogdrain").await;
369 + let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk")
370 + .await
371 + .unwrap();
372 + let device = seed_device(&db.pool, app, admin, "laptop").await;
373 +
374 + for chunk in [
375 + vec![change("tasks", "g0"), change("tasks", "g1")],
376 + vec![
377 + change("tasks", "g2"),
378 + change("tasks", "g3"),
379 + change("tasks", "g4"),
380 + ],
381 + ] {
382 + synckit::push_group_changes(
383 + &db.pool,
384 + app,
385 + group.id,
386 + admin,
387 + device,
388 + Uuid::new_v4(),
389 + &chunk,
390 + )
391 + .await
392 + .unwrap();
393 + }
394 +
395 + let whole = synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 1000, None, None)
396 + .await
397 + .unwrap();
398 + let whole_rows: Vec<String> = whole.iter().map(|e| e.row_id.clone()).collect();
399 + assert_eq!(
400 + names(&whole_rows),
401 + ["g0", "g1", "g2", "g3", "g4"],
402 + "the group reference pull: {whole_rows:?}"
403 + );
404 +
405 + for limit in [2i64, 5] {
406 + let paged = drain_group(&db.pool, app, group.id, limit, None, None).await;
407 + assert_eq!(
408 + paged, whole_rows,
409 + "a group drain at {limit} per page must equal the whole pull: {paged:?}"
410 + );
411 + }
412 + }
413 +
414 + #[tokio::test]
415 + async fn a_replayed_group_batch_returns_that_batch_own_cursor_and_appends_nothing() {
416 + let db = TestDb::new().await;
417 + let admin = seed_user(&db.pool, "skglogp_replay").await;
418 + let app = seed_app(&db.pool, admin, "glogreplay").await;
419 + let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk")
420 + .await
421 + .unwrap();
422 + let device = seed_device(&db.pool, app, admin, "laptop").await;
423 +
424 + let first_batch = Uuid::new_v4();
425 + let first_cursor = synckit::push_group_changes(
426 + &db.pool,
427 + app,
428 + group.id,
429 + admin,
430 + device,
431 + first_batch,
432 + &[change("tasks", "a0"), change("tasks", "a1")],
433 + )
434 + .await
435 + .unwrap();
436 +
437 + let later_cursor = synckit::push_group_changes(
438 + &db.pool,
439 + app,
440 + group.id,
441 + admin,
442 + device,
443 + Uuid::new_v4(),
444 + &[change("tasks", "b0"), change("tasks", "b1")],
445 + )
446 + .await
447 + .unwrap();
448 + assert!(
449 + later_cursor > first_cursor,
450 + "the second group batch sits above the first: {later_cursor} vs {first_cursor}"
451 + );
452 +
453 + let replayed = synckit::push_group_changes(
454 + &db.pool,
455 + app,
456 + group.id,
457 + admin,
458 + device,
459 + first_batch,
460 + &[change("tasks", "tampered")],
461 + )
462 + .await
463 + .unwrap();
464 + assert_eq!(
465 + replayed, first_cursor,
466 + "a group replay reports its own cursor, not the log maximum ({later_cursor})"
467 + );
468 +
469 + let entries = synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 100, None, None)
470 + .await
471 + .unwrap();
472 + let rows: Vec<&str> = entries.iter().map(|e| e.row_id.as_str()).collect();
473 + assert_eq!(
474 + rows,
475 + ["a0", "a1", "b0", "b1"],
476 + "the redelivery wrote nothing: {rows:?}"
477 + );
478 + }
479 +
480 + #[tokio::test]
481 + async fn group_entries_keep_the_generation_they_were_sealed_under_across_a_rotation() {
482 + let db = TestDb::new().await;
483 + let admin = seed_user(&db.pool, "skglogp_gen").await;
484 + let app = seed_app(&db.pool, admin, "gloggen").await;
485 + let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk")
486 + .await
487 + .unwrap();
488 + let device = seed_device(&db.pool, app, admin, "laptop").await;
489 + assert_eq!(group.gck_version, 1, "a new group starts at generation 1");
490 +
491 + synckit::push_group_changes(
492 + &db.pool,
493 + app,
494 + group.id,
495 + admin,
496 + device,
497 + Uuid::new_v4(),
498 + &[change("tasks", "under-1a"), change("tasks", "under-1b")],
499 + )
500 + .await
Lines truncated
@@ -1,0 +1,245 @@
1 + //! DB-layer contract tests for `db::synckit::security`, the audit trail and the
2 + //! sync-token revocation line.
3 + //!
4 + //! An audit log that dedupes reports a repeated attack as one attempt, and a
5 + //! revocation that misses is a stolen token that keeps working. Pinned here:
6 + //! the trail is append-only under an argument-for-argument repeat, it is scoped
7 + //! to one app, a row outlives the user it names (the subject is nulled rather
8 + //! than the row cascaded away with the account, which is what would erase the
9 + //! evidence), revoking sync tokens leaves the website session's own stamp
10 + //! alone, and a second revocation moves the line forward so a token minted
11 + //! between the two cannot survive it.
12 + //!
13 + //! `db_synckit_accounts_layer` pins the audit log's basic append and the
14 + //! cross-user token revocation, and is not repeated here.
15 + //!
16 + //! Delete this file and the trail could start deduping, cascade away with the
17 + //! account it describes, or stop advancing, none of it visible to a route
18 + //! test.
19 +
20 + use crate::harness::db::TestDb;
21 + use crate::harness::seed_user;
22 +
23 + use makenotwork::db::synckit;
24 + use makenotwork::db::{SyncAppId, UserId};
25 +
26 + /// Seed a sync app owned by `user`.
27 + async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
28 + sqlx::query_scalar::<_, SyncAppId>(
29 + "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix)
30 + VALUES ($1, $2, $3, $4) RETURNING id",
31 + )
32 + .bind(user)
33 + .bind(name)
34 + .bind(format!("hash_{name}"))
35 + .bind(&name[..name.len().min(8)])
36 + .fetch_one(pool)
37 + .await
38 + .expect("seed sync app")
39 + }
40 + // ── security ────────────────────────────────────────────────────────────────
41 +
42 + #[tokio::test]
43 + async fn the_audit_log_appends_an_identical_event_twice_and_scopes_it_to_one_app() {
44 + let db = TestDb::new().await;
45 + let user = seed_user(&db.pool, "sksecp_append").await;
46 + let app = seed_app(&db.pool, user, "secappend").await;
47 + let other_app = seed_app(&db.pool, user, "secother").await;
48 +
49 + // The same event twice, argument for argument. An audit log that deduped
50 + // would hide a repeated attack as one attempt.
51 + for _ in 0..2 {
52 + synckit::record_security_event(
53 + &db.pool,
54 + app,
55 + Some(user),
56 + synckit::sync_security_event::AUTH_FAILURE,
57 + Some(serde_json::json!({ "attempt": "same" })),
58 + Some("198.51.100.7"),
59 + )
60 + .await
61 + .unwrap();
62 + }
63 + synckit::record_security_event(
64 + &db.pool,
65 + other_app,
66 + Some(user),
67 + synckit::sync_security_event::KEY_ROTATION_COMPLETED,
68 + None,
69 + None,
70 + )
71 + .await
72 + .unwrap();
73 +
74 + let here: Vec<(String, Option<String>)> = sqlx::query_as(
75 + "SELECT event_type, ip FROM sync_security_events WHERE app_id = $1 ORDER BY id",
76 + )
77 + .bind(app)
78 + .fetch_all(&db.pool)
79 + .await
80 + .unwrap();
81 + assert_eq!(
82 + here.len(),
83 + 2,
84 + "append-only: two identical events are two rows: {here:?}"
85 + );
86 + assert!(
87 + here.iter()
88 + .all(|(e, ip)| e == "auth_failure" && ip.as_deref() == Some("198.51.100.7")),
89 + "both rows keep what was recorded: {here:?}"
90 + );
91 +
92 + let there: Vec<(String, Option<String>)> = sqlx::query_as(
93 + "SELECT event_type, ip FROM sync_security_events WHERE app_id = $1 ORDER BY id",
94 + )
95 + .bind(other_app)
96 + .fetch_all(&db.pool)
97 + .await
98 + .unwrap();
99 + assert_eq!(
100 + there.len(),
101 + 1,
102 + "another app's audit trail is its own: {there:?}"
103 + );
104 + // The literal strings are the contract with whoever queries this table by
105 + // hand, so they are asserted as literals rather than against the constant.
106 + assert_eq!(there[0].0, "key_rotation_completed", "{there:?}");
107 + }
108 +
109 + #[tokio::test]
110 + async fn an_audit_row_outlives_the_user_it_names() {
111 + let db = TestDb::new().await;
112 + let owner = seed_user(&db.pool, "sksecp_owner").await;
113 + let subject = seed_user(&db.pool, "sksecp_subject").await;
114 + // The app belongs to someone else, so removing the subject cannot take the
115 + // app (and its events) down by cascade.
116 + let app = seed_app(&db.pool, owner, "secoutlive").await;
117 +
118 + synckit::record_security_event(
119 + &db.pool,
120 + app,
121 + Some(subject),
122 + synckit::sync_security_event::DEVICE_REMOVED,
123 + Some(serde_json::json!({ "device": "old-laptop" })),
124 + Some("203.0.113.4"),
125 + )
126 + .await
127 + .unwrap();
128 +
129 + sqlx::query("DELETE FROM users WHERE id = $1")
130 + .bind(subject)
131 + .execute(&db.pool)
132 + .await
133 + .expect("remove the audited user");
134 +
135 + let rows: Vec<(String, Option<UserId>, Option<serde_json::Value>)> = sqlx::query_as(
136 + "SELECT event_type, user_id, detail FROM sync_security_events WHERE app_id = $1",
137 + )
138 + .bind(app)
139 + .fetch_all(&db.pool)
140 + .await
141 + .unwrap();
142 + assert_eq!(
143 + rows.len(),
144 + 1,
145 + "the trail survives the account it describes: {rows:?}"
146 + );
147 + assert_eq!(rows[0].0, "device_removed");
148 + assert_eq!(
149 + rows[0].1, None,
150 + "the subject is nulled rather than the row deleted: {rows:?}"
151 + );
152 + assert_eq!(
153 + rows[0].2,
154 + Some(serde_json::json!({ "device": "old-laptop" })),
155 + "and the detail an operator would investigate is still there: {rows:?}"
156 + );
157 + }
158 +
159 + #[tokio::test]
160 + async fn revoking_sync_tokens_leaves_the_website_session_alone() {
161 + let db = TestDb::new().await;
162 + let user = seed_user(&db.pool, "sksecp_split").await;
163 +
164 + let before: (
165 + Option<chrono::DateTime<chrono::Utc>>,
166 + Option<chrono::DateTime<chrono::Utc>>,
167 + ) = sqlx::query_as(
168 + "SELECT sync_jwt_invalidated_at, jwt_invalidated_at FROM users WHERE id = $1",
169 + )
170 + .bind(user)
171 + .fetch_one(&db.pool)
172 + .await
173 + .unwrap();
174 + assert_eq!(
175 + (before.0, before.1),
176 + (None, None),
177 + "a fresh user has neither stamp set"
178 + );
179 +
180 + synckit::invalidate_user_sync_tokens(&db.pool, user)
181 + .await
182 + .unwrap();
183 +
184 + let after: (
185 + Option<chrono::DateTime<chrono::Utc>>,
186 + Option<chrono::DateTime<chrono::Utc>>,
187 + ) = sqlx::query_as(
188 + "SELECT sync_jwt_invalidated_at, jwt_invalidated_at FROM users WHERE id = $1",
189 + )
190 + .bind(user)
191 + .fetch_one(&db.pool)
192 + .await
193 + .unwrap();
194 + assert!(
195 + after.0.is_some(),
196 + "the sync sessions must be forced to re-authenticate: {after:?}"
197 + );
198 + // The two stamps are deliberately separate: removing a sync device must
199 + // not log the creator out of the website they are working in.
200 + assert_eq!(
201 + after.1, None,
202 + "and the website session stamp must not be touched: {after:?}"
203 + );
204 + }
205 +
206 + #[tokio::test]
207 + async fn revoking_sync_tokens_again_moves_the_stamp_forward() {
208 + let db = TestDb::new().await;
209 + let user = seed_user(&db.pool, "sksecp_forward").await;
210 +
211 + // Backdated by a day so the comparison below cannot turn on clock
212 + // resolution: the second stamp has to be later by roughly that day.
213 + sqlx::query(
214 + "UPDATE users SET sync_jwt_invalidated_at = NOW() - INTERVAL '1 day' WHERE id = $1",
215 + )
216 + .bind(user)
217 + .execute(&db.pool)
218 + .await
219 + .unwrap();
220 + let old: Option<chrono::DateTime<chrono::Utc>> =
221 + sqlx::query_scalar("SELECT sync_jwt_invalidated_at FROM users WHERE id = $1")
222 + .bind(user)
223 + .fetch_one(&db.pool)
224 + .await
225 + .unwrap();
226 + let old = old.expect("the backdated stamp is set");
227 +
228 + synckit::invalidate_user_sync_tokens(&db.pool, user)
229 + .await
230 + .unwrap();
231 +
232 + let new: Option<chrono::DateTime<chrono::Utc>> =
233 + sqlx::query_scalar("SELECT sync_jwt_invalidated_at FROM users WHERE id = $1")
234 + .bind(user)
235 + .fetch_one(&db.pool)
236 + .await
237 + .unwrap();
238 + let new = new.expect("the stamp is still set");
239 + // A second revocation has to move the line forward, or a token issued
240 + // between the two revocations would survive the second one.
241 + assert!(
242 + new > old,
243 + "the revocation line must advance: {new} is not after {old}"
244 + );
245 + }
@@ -1,0 +1,695 @@
1 + //! DB-layer contract tests for `db::synckit::subscriptions`, the end-user
2 + //! entitlement for first-party sync.
3 + //!
4 + //! This module decides who is allowed to sync and what storage they hold, and
5 + //! its writes all arrive from Stripe webhooks, which redeliver. The pieces
6 + //! pinned here are the ones a wrong answer costs money or data on:
7 + //!
8 + //! - `internal_write_allowed` is a status-only gate: exactly `active` opens
9 + //! writes, every other lifecycle status (`trialing`, `past_due`, `unpaid`,
10 + //! `incomplete`, `canceled`) denies, and the boundary is asserted on both
11 + //! sides. A lapsed `current_period_end` is deliberately NOT consulted, so
12 + //! that is pinned too rather than assumed.
13 + //! - the gate is scoped to the exact (app, user) pair: another user's
14 + //! subscription, or the same user's subscription on a different first-party
15 + //! app, opens nothing.
16 + //! - `create_app_sync_subscription` is the checkout webhook's write, so it is
17 + //! exercised under replay: an identical redelivery is a no-op that returns
18 + //! `false` and adds no second row, while each arm of the guard (a new Stripe
19 + //! subscription id, or a non-active row) does reactivate.
20 + //! - `update_app_sync_subscription_status`, `set_pending_storage_cap`,
21 + //! `set_storage_cap_now` and `apply_pending_storage_cap` are asserted to hit
22 + //! one row and leave every sibling row alone, which a missing key predicate
23 + //! would break silently.
24 + //!
25 + //! Deleting this file would leave the paid-sync gate and the cap-change ladder
26 + //! asserted only through HTTP and webhook flows, where a wrong row or a second
27 + //! credit is invisible.
28 + //!
29 + //! Not covered here: `get_user_app_subscription`'s per-user/per-app scoping and
30 + //! the terminal-`canceled` and epoch-period guards on the status setter, which
31 + //! `db_synckit_accounts_layer` already asserts.
32 +
33 + use crate::harness::db::TestDb;
34 + use crate::harness::seed_user;
35 +
36 + use makenotwork::db::synckit;
37 + use makenotwork::db::synckit::NewAppSyncSubscription;
38 + use makenotwork::db::{SyncAppId, UserId};
39 +
40 + /// Seed a first-party (internal) sync app owned by `user`. Only internal apps
41 + /// use the end-user subscription model, so every gate test needs one.
42 + async fn seed_internal_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
43 + let app =
44 + synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None)
45 + .await
46 + .expect("seed sync app")
47 + .id;
48 + sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1")
49 + .bind(app)
50 + .execute(pool)
51 + .await
52 + .expect("mark app first-party");
53 + app
54 + }
55 +
56 + /// Insert a subscription through the production checkout write, asserting the
57 + /// first delivery is a real insert.
58 + async fn subscribe(
59 + pool: &sqlx::PgPool,
60 + user: UserId,
61 + app: SyncAppId,
62 + sub_id: &str,
63 + interval: &str,
64 + limit_bytes: i64,
65 + ) {
66 + let created = synckit::create_app_sync_subscription(
67 + pool,
68 + &NewAppSyncSubscription {
69 + user_id: user,
70 + app_id: app,
71 + stripe_subscription_id: sub_id,
72 + stripe_customer_id: "cus_seed",
73 + interval,
74 + storage_limit_bytes: limit_bytes,
75 + },
76 + )
77 + .await
78 + .expect("seed subscription");
79 + assert!(created, "the first checkout delivery inserts a row");
80 + }
81 +
82 + /// How many subscription rows exist for the (user, app) pair. Used to prove a
83 + /// replay adds nothing rather than merely returning `false`.
84 + async fn row_count(pool: &sqlx::PgPool, user: UserId, app: SyncAppId) -> i64 {
85 + sqlx::query_scalar::<_, i64>(
86 + "SELECT COUNT(*) FROM app_sync_subscriptions WHERE user_id = $1 AND app_id = $2",
87 + )
88 + .bind(user)
89 + .bind(app)
90 + .fetch_one(pool)
91 + .await
92 + .expect("count subscription rows")
93 + }
94 +
95 + /// Read `canceled_at`, which the struct returned by `get_user_app_subscription`
96 + /// does not carry but reactivation is contractually required to clear.
97 + async fn canceled_at_is_set(pool: &sqlx::PgPool, sub_id: &str) -> bool {
98 + sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
99 + "SELECT canceled_at FROM app_sync_subscriptions WHERE stripe_subscription_id = $1",
100 + )
101 + .bind(sub_id)
102 + .fetch_one(pool)
103 + .await
104 + .expect("read canceled_at")
105 + .is_some()
106 + }
107 +
108 + // ── internal_write_allowed: the entitlement boundary, both sides ─────────────
109 +
110 + #[tokio::test]
111 + async fn only_the_active_status_opens_writes() {
112 + let db = TestDb::new().await;
113 + let user = seed_user(&db.pool, "sks_status").await;
114 + let app = seed_internal_app(&db.pool, user, "sksstatus").await;
115 + subscribe(&db.pool, user, app, "sub_status", "monthly", 25_000_000_000).await;
116 +
117 + assert!(
118 + synckit::internal_write_allowed(&db.pool, app, user)
119 + .await
120 + .expect("gate query ok"),
121 + "an active subscription is the one status that syncs"
122 + );
123 +
124 + // Every other Stripe lifecycle status sits on the closed side of the gate.
125 + // `trialing` and `past_due` are the ones a plausible "not canceled" reading
126 + // of the rule would let through, so they are asserted individually rather
127 + // than as a family.
128 + for lapsed in ["trialing", "past_due", "unpaid", "incomplete", "canceled"] {
129 + synckit::update_app_sync_subscription_status(&db.pool, "sub_status", lapsed, None)
130 + .await
131 + .expect("status write ok");
132 + assert!(
133 + !synckit::internal_write_allowed(&db.pool, app, user)
134 + .await
135 + .expect("gate query ok"),
136 + "status {lapsed} must not open first-party sync"
137 + );
138 + // `canceled` is terminal, so it has to be the last status tried: nothing
139 + // after it could move the row back.
140 + if lapsed == "canceled" {
141 + break;
142 + }
143 + synckit::update_app_sync_subscription_status(&db.pool, "sub_status", "active", None)
144 + .await
145 + .expect("restore active ok");
146 + assert!(
147 + synckit::internal_write_allowed(&db.pool, app, user)
148 + .await
149 + .expect("gate query ok"),
150 + "returning to active reopens writes after {lapsed}"
151 + );
152 + }
153 + }
154 +
155 + #[tokio::test]
156 + async fn a_lapsed_billing_period_does_not_close_the_gate_on_its_own() {
157 + let db = TestDb::new().await;
158 + let user = seed_user(&db.pool, "sks_period").await;
159 + let app = seed_internal_app(&db.pool, user, "sksperiod").await;
160 + subscribe(&db.pool, user, app, "sub_period", "annual", 7_500_000_000).await;
161 +
162 + // 2020-09-13, long past. The gate reads `status` alone, so entitlement
163 + // survives a stale period and it is Stripe flipping the status (past_due,
164 + // then canceled) that ends sync. Pinned because the alternative reading,
165 + // "deny once the period end is behind us", would lock out every user during
166 + // the ordinary gap between a renewal and its webhook.
167 + synckit::update_app_sync_subscription_status(
168 + &db.pool,
169 + "sub_period",
170 + "active",
171 + Some(1_600_000_000),
172 + )
173 + .await
174 + .expect("stamp a past period");
175 +
176 + let sub = synckit::get_user_app_subscription(&db.pool, user, app)
177 + .await
178 + .expect("read subscription")
179 + .expect("row exists");
180 + assert_eq!(
181 + sub.current_period_end,
182 + chrono::DateTime::from_timestamp(1_600_000_000, 0),
183 + "the raw Stripe seconds round-trip exactly"
184 + );
185 + assert!(
186 + synckit::internal_write_allowed(&db.pool, app, user)
187 + .await
188 + .expect("gate query ok"),
189 + "an active row with a lapsed period still syncs: status is the gate"
190 + );
191 +
192 + // And the far side of the boundary: the same row, one status change later.
193 + synckit::update_app_sync_subscription_status(&db.pool, "sub_period", "past_due", None)
194 + .await
195 + .expect("status write ok");
196 + assert!(
197 + !synckit::internal_write_allowed(&db.pool, app, user)
198 + .await
199 + .expect("gate query ok"),
200 + "the status is what closes it"
201 + );
202 + }
203 +
204 + #[tokio::test]
205 + async fn one_subscription_entitles_exactly_one_user_on_exactly_one_app() {
206 + let db = TestDb::new().await;
207 + let payer = seed_user(&db.pool, "sks_payer").await;
208 + let freeloader = seed_user(&db.pool, "sks_freeloader").await;
209 + let paid_app = seed_internal_app(&db.pool, payer, "skspaid").await;
210 + let other_app = seed_internal_app(&db.pool, payer, "sksother").await;
211 +
212 + subscribe(
213 + &db.pool,
214 + payer,
215 + paid_app,
216 + "sub_scoped",
217 + "monthly",
218 + 25_000_000_000,
219 + )
220 + .await;
221 +
222 + assert!(
223 + synckit::internal_write_allowed(&db.pool, paid_app, payer)
224 + .await
225 + .expect("gate query ok"),
226 + "the payer syncs the app they paid for"
227 + );
228 + // A gate that dropped the user predicate (EXISTS any active sub on the app)
229 + // would let this through, and a gate that dropped the app predicate would
230 + // let the next one through. Both are one deleted line away.
231 + assert!(
232 + !synckit::internal_write_allowed(&db.pool, paid_app, freeloader)
233 + .await
234 + .expect("gate query ok"),
235 + "another user's subscription entitles nobody else"
236 + );
237 + assert!(
238 + !synckit::internal_write_allowed(&db.pool, other_app, payer)
239 + .await
240 + .expect("gate query ok"),
241 + "paying for one first-party app entitles nothing on another"
242 + );
243 +
244 + // The freeloader subscribing opens only their own pair, and leaves the
245 + // payer's row untouched.
246 + subscribe(
247 + &db.pool,
248 + freeloader,
249 + paid_app,
250 + "sub_scoped_two",
251 + "monthly",
252 + 7_500_000_000,
253 + )
254 + .await;
255 + assert!(
256 + synckit::internal_write_allowed(&db.pool, paid_app, freeloader)
257 + .await
258 + .expect("gate query ok")
259 + );
260 + assert!(
261 + !synckit::internal_write_allowed(&db.pool, other_app, freeloader)
262 + .await
263 + .expect("gate query ok"),
264 + "the second subscription is scoped the same way as the first"
265 + );
266 + }
267 +
268 + // ── create_app_sync_subscription: the checkout webhook, under replay ─────────
269 +
270 + #[tokio::test]
271 + async fn a_redelivered_checkout_webhook_writes_nothing_a_second_time() {
272 + let db = TestDb::new().await;
273 + let user = seed_user(&db.pool, "sks_replay").await;
274 + let app = seed_internal_app(&db.pool, user, "sksreplay").await;
275 + let params = NewAppSyncSubscription {
276 + user_id: user,
277 + app_id: app,
278 + stripe_subscription_id: "sub_replay",
279 + stripe_customer_id: "cus_replay",
280 + interval: "annual",
281 + storage_limit_bytes: 25_000_000_000,
282 + };
283 +
284 + assert!(
285 + synckit::create_app_sync_subscription(&db.pool, &params)
286 + .await
287 + .expect("first checkout ok"),
288 + "the first delivery inserts"
289 + );
290 + // Stripe redelivers; the guard WHERE makes an unchanged active row a no-op.
291 + assert!(
292 + !synckit::create_app_sync_subscription(&db.pool, &params)
293 + .await
294 + .expect("replay ok"),
295 + "a redelivered checkout for an unchanged active row writes nothing"
296 + );
297 + assert!(
298 + !synckit::create_app_sync_subscription(&db.pool, &params)
299 + .await
300 + .expect("second replay ok"),
301 + "and it stays a no-op however many times Stripe retries"
302 + );
303 +
304 + assert_eq!(
305 + row_count(&db.pool, user, app).await,
306 + 1,
307 + "the replays added no second subscription row"
308 + );
309 + let sub = synckit::get_user_app_subscription(&db.pool, user, app)
310 + .await
311 + .expect("read subscription")
312 + .expect("row exists");
313 + assert_eq!(sub.stripe_subscription_id, "sub_replay");
314 + assert_eq!(sub.interval, "annual");
315 + assert_eq!(sub.status, "active");
316 + assert_eq!(
317 + sub.storage_limit_bytes,
318 + Some(25_000_000_000),
319 + "the cap the user paid for is unchanged by the replays"
320 + );
321 + }
322 +
323 + #[tokio::test]
324 + async fn a_cap_change_delivered_as_a_checkout_replay_is_refused() {
325 + let db = TestDb::new().await;
326 + let user = seed_user(&db.pool, "sks_capreplay").await;
327 + let app = seed_internal_app(&db.pool, user, "skscapreplay").await;
328 + subscribe(
329 + &db.pool,
330 + user,
331 + app,
332 + "sub_capreplay",
333 + "monthly",
334 + 25_000_000_000,
335 + )
336 + .await;
337 +
338 + // Same active row, same Stripe subscription, different cap and interval.
339 + // Neither arm of the guard fires, so the checkout path declines to move the
340 + // cap: cap changes are the `set_storage_cap_now` / `set_pending_storage_cap`
341 + // ladder's job, and letting a stale redelivery rewrite a cap would undo a
342 + // change the user already paid for.
343 + let changed = synckit::create_app_sync_subscription(
344 + &db.pool,
345 + &NewAppSyncSubscription {
346 + user_id: user,
347 + app_id: app,
348 + stripe_subscription_id: "sub_capreplay",
349 + stripe_customer_id: "cus_seed",
350 + interval: "annual",
351 + storage_limit_bytes: 7_500_000_000,
352 + },
353 + )
354 + .await
355 + .expect("checkout write ok");
356 + assert!(!changed, "an unchanged-subscription checkout is a no-op");
357 +
358 + let sub = synckit::get_user_app_subscription(&db.pool, user, app)
359 + .await
360 + .expect("read subscription")
361 + .expect("row exists");
362 + assert_eq!(
363 + sub.storage_limit_bytes,
364 + Some(25_000_000_000),
365 + "the cap on record is the one the ladder set, not the replayed one"
366 + );
367 + assert_eq!(sub.interval, "monthly", "and the interval is untouched too");
368 + }
369 +
370 + #[tokio::test]
371 + async fn a_new_stripe_subscription_replaces_the_row_it_conflicts_with() {
372 + let db = TestDb::new().await;
373 + let user = seed_user(&db.pool, "sks_swap").await;
374 + let app = seed_internal_app(&db.pool, user, "sksswap").await;
375 + subscribe(
376 + &db.pool,
377 + user,
378 + app,
379 + "sub_swap_old",
380 + "monthly",
381 + 7_500_000_000,
382 + )
383 + .await;
384 +
385 + // The user re-checks-out on a different Stripe subscription (the old one
386 + // ended at Stripe's side). The id differs, so the first arm of the guard
387 + // fires and every billing field moves together.
388 + let replaced = synckit::create_app_sync_subscription(
389 + &db.pool,
390 + &NewAppSyncSubscription {
391 + user_id: user,
392 + app_id: app,
393 + stripe_subscription_id: "sub_swap_new",
394 + stripe_customer_id: "cus_swap_new",
395 + interval: "annual",
396 + storage_limit_bytes: 25_000_000_000,
397 + },
398 + )
399 + .await
400 + .expect("re-checkout ok");
401 + assert!(
402 + replaced,
403 + "a different Stripe subscription id is a real write"
404 + );
405 +
406 + assert_eq!(
407 + row_count(&db.pool, user, app).await,
408 + 1,
409 + "the (user, app) pair still holds exactly one subscription"
410 + );
411 + let sub = synckit::get_user_app_subscription(&db.pool, user, app)
412 + .await
413 + .expect("read subscription")
414 + .expect("row exists");
415 + assert_eq!(sub.stripe_subscription_id, "sub_swap_new");
416 + assert_eq!(sub.interval, "annual");
417 + assert_eq!(sub.storage_limit_bytes, Some(25_000_000_000));
418 +
419 + // The old id is no longer a route to this row, and the new one is.
420 + assert_eq!(
421 + synckit::get_subscription_by_stripe_id(&db.pool, "sub_swap_old")
422 + .await
423 + .expect("lookup ok"),
424 + None,
425 + "a webhook on the retired subscription finds nothing to update"
426 + );
427 + assert_eq!(
428 + synckit::get_subscription_by_stripe_id(&db.pool, "sub_swap_new")
429 + .await
430 + .expect("lookup ok"),
431 + Some((user, app))
432 + );
433 + }
434 +
435 + #[tokio::test]
436 + async fn re_subscribing_after_a_cancellation_reactivates_at_checkout() {
437 + let db = TestDb::new().await;
438 + let user = seed_user(&db.pool, "sks_resub").await;
439 + let app = seed_internal_app(&db.pool, user, "sksresub").await;
440 + subscribe(&db.pool, user, app, "sub_resub", "monthly", 7_500_000_000).await;
441 +
442 + synckit::update_app_sync_subscription_status(&db.pool, "sub_resub", "canceled", None)
443 + .await
444 + .expect("cancel ok");
445 + assert!(
446 + canceled_at_is_set(&db.pool, "sub_resub").await,
447 + "cancellation stamps canceled_at"
448 + );
449 + assert!(
450 + !synckit::internal_write_allowed(&db.pool, app, user)
451 + .await
452 + .expect("gate query ok"),
453 + "a canceled subscription closes writes"
454 + );
455 +
456 + // The user pays again on the same Stripe subscription id. The second arm of
457 + // the guard (status != 'active') fires, so the row reactivates at checkout
458 + // rather than waiting for a later `customer.subscription.updated`.
459 + let revived = synckit::create_app_sync_subscription(
460 + &db.pool,
461 + &NewAppSyncSubscription {
462 + user_id: user,
463 + app_id: app,
464 + stripe_subscription_id: "sub_resub",
465 + stripe_customer_id: "cus_seed",
466 + interval: "annual",
467 + storage_limit_bytes: 25_000_000_000,
468 + },
469 + )
470 + .await
471 + .expect("re-subscribe ok");
472 + assert!(revived, "a paid re-subscribe is a write");
473 +
474 + let sub = synckit::get_user_app_subscription(&db.pool, user, app)
475 + .await
476 + .expect("read subscription")
477 + .expect("row exists");
478 + assert_eq!(sub.status, "active");
479 + assert_eq!(
480 + sub.interval, "annual",
481 + "the reactivating checkout carries the new interval"
482 + );
483 + assert_eq!(sub.storage_limit_bytes, Some(25_000_000_000));
484 + assert!(
485 + !canceled_at_is_set(&db.pool, "sub_resub").await,
486 + "reactivation clears canceled_at rather than leaving a canceled-looking row"
487 + );
488 + assert!(
489 + synckit::internal_write_allowed(&db.pool, app, user)
490 + .await
491 + .expect("gate query ok"),
492 + "and sync is open again"
493 + );
494 +
495 + // The very next redelivery of that same checkout is a no-op again.
496 + assert!(
497 + !synckit::create_app_sync_subscription(
498 + &db.pool,
499 + &NewAppSyncSubscription {
500 + user_id: user,
Lines truncated
@@ -1,0 +1,786 @@
1 + //! Route-layer contract tests for `routes::storage::downloads` and
2 + //! `routes::storage::versions`,
3 + //! the handlers that decide who is handed the bytes of a creator's file and what
4 + //! a version upload costs the creator's storage quota.
5 + //!
6 + //! Pinned here: the presign window (twice the track duration, clamped to an hour
7 + //! at the floor and a day at the ceiling, 3600 when unknown); the entitlement
8 + //! ladder on both download routes (anonymous on a paid item is 401, a logged-in
9 + //! non-buyer is 403, a completed transaction opens the gate, an unpublished item
10 + //! is 404 for everyone but its creator whether or not they paid); the scan gate
11 + //! (a creator previews their own held file, nobody gets a quarantined one);
12 + //! `version_download`'s 303 and its counters; and `version_confirm_upload` under
13 + //! redelivery, where the same key twice charges storage once and a replacement
14 + //! swaps the charge rather than adding to it.
15 + //!
16 + //! Delete this file and nothing checks that a stranger is refused a paid file,
17 + //! that a replayed confirm is a no-op, or that a replaced file stops being
18 + //! billed. `streaming.rs` covers the stream response shape and the middle of the
19 + //! expiry range; this file covers the clamps and the version half.
20 +
21 + use crate::harness::client::TestResponse;
22 + use crate::harness::{CreatorSetup, TestHarness};
23 + use makenotwork::db::UserId;
24 + use serde_json::{Value, json};
25 +
26 + /// A creator with a published item carrying one confirmed version file.
27 + struct VersionFixture {
28 + creator: UserId,
29 + item_id: String,
30 + version_id: String,
31 + s3_key: String,
32 + /// The download route for this version, spelled once.
33 + url: String,
34 + }
35 +
36 + /// Log out whoever is on `h.client` and log the named seeded user back in.
37 + /// Every user in this file is seeded with the same password.
38 + async fn become_user(h: &mut TestHarness, username: &str) {
39 + h.client.post_form("/logout", "").await;
40 + h.login(username, "password123").await;
41 + }
42 +
43 + /// Sign up a second account and leave it logged in on `h.client`.
44 + async fn seed_stranger(h: &mut TestHarness, username: &str) -> UserId {
45 + h.client.post_form("/logout", "").await;
46 + let id = h
47 + .signup(username, &format!("{username}@test.com"), "password123")
48 + .await;
49 + h.login(username, "password123").await;
50 + id
51 + }
52 +
53 + /// Presign a version upload, put `size` bytes at the returned key, and confirm.
54 + /// Returns the staging key. Goes through the real routes.
55 + async fn upload_version_file(h: &mut TestHarness, version_id: &str, size: usize) -> String {
56 + let resp = h
57 + .client
58 + .post_json(
59 + &format!("/api/versions/{version_id}/upload/presign"),
60 + &json!({"file_name": "release.zip", "content_type": "application/zip"}).to_string(),
61 + )
62 + .await;
63 + assert_eq!(resp.status, 200, "presign failed: {}", resp.text);
64 + let body: Value = resp.json();
65 + let s3_key = body["s3_key"]
66 + .as_str()
67 + .expect("presign returns s3_key")
68 + .to_string();
69 +
70 + h.storage.as_ref().unwrap().put(&s3_key, vec![7u8; size]);
71 +
72 + let resp = confirm_version(h, version_id, &s3_key).await;
73 + assert_eq!(resp.status, 200, "confirm failed: {}", resp.text);
74 + s3_key
75 + }
76 +
77 + /// Creator (trusted, Small Files tier) + published project and item + one
78 + /// version whose file is uploaded, confirmed, and marked clean. The verdict is
79 + /// set directly because the download tests vary it independently of the upload.
80 + async fn published_item_with_version(
81 + h: &mut TestHarness,
82 + username: &str,
83 + price_cents: i64,
84 + ) -> VersionFixture {
85 + let (setup, version_id) = creator_with_version(h, username, price_cents, true).await;
86 + let s3_key = upload_version_file(h, &version_id, 4_096).await;
87 + set_version_scan_status(h, &version_id, "clean").await;
88 + h.publish_project_and_item(&setup.project_id, &setup.item_id)
89 + .await;
90 +
91 + VersionFixture {
92 + creator: setup.user_id,
93 + item_id: setup.item_id,
94 + url: format!("/api/versions/{version_id}/download"),
95 + version_id,
96 + s3_key,
97 + }
98 + }
99 +
100 + /// A creator on the Small Files tier (the cheapest tier allowed to upload) with
101 + /// one item and one empty version row. `trusted` decides the verdict a later
102 + /// confirm assigns: with no scanner, a trusted upload lands clean, others held.
103 + async fn creator_with_version(
104 + h: &mut TestHarness,
105 + username: &str,
106 + price_cents: i64,
107 + trusted: bool,
108 + ) -> (CreatorSetup, String) {
109 + let setup = h
110 + .create_creator_with_item(username, "digital", price_cents)
111 + .await;
112 + if trusted {
113 + h.trust_user(setup.user_id).await;
114 + }
115 + h.grant_tier(setup.user_id, "small_files").await;
116 +
117 + let resp = h
118 + .client
119 + .post_json(
120 + &format!("/api/items/{}/versions", setup.item_id),
121 + r#"{"version_number": "1.0.0", "changelog": "First cut"}"#,
122 + )
123 + .await;
124 + assert_eq!(resp.status, 200, "create version failed: {}", resp.text);
125 + let version_id = resp.json::<Value>()["id"]
126 + .as_str()
127 + .expect("version id")
128 + .to_string();
129 + (setup, version_id)
130 + }
131 +
132 + /// A creator with a published audio item whose object is in storage, for the
133 + /// stream tests, which need an item with bytes behind it.
134 + async fn published_audio_item(
135 + h: &mut TestHarness,
136 + username: &str,
137 + price_cents: i64,
138 + ) -> (CreatorSetup, String) {
139 + let setup = h
140 + .create_creator_with_item(username, "audio", price_cents)
141 + .await;
142 + let key = format!("test/{}/audio/track.mp3", setup.item_id);
143 + sqlx::query("UPDATE items SET audio_s3_key = $1, scan_status = 'clean' WHERE id = $2::uuid")
144 + .bind(&key)
145 + .bind(&setup.item_id)
146 + .execute(&h.db)
147 + .await
148 + .unwrap();
149 + h.storage.as_ref().unwrap().put(&key, b"audio".to_vec());
150 + h.publish_project_and_item(&setup.project_id, &setup.item_id)
151 + .await;
152 + (setup, key)
153 + }
154 +
155 + /// The version row's key, size and scan verdict in one read.
156 + async fn version_row(h: &TestHarness, version_id: &str) -> (Option<String>, Option<i64>, String) {
157 + sqlx::query_as("SELECT s3_key, file_size_bytes, scan_status FROM versions WHERE id = $1::uuid")
158 + .bind(version_id)
159 + .fetch_one(&h.db)
160 + .await
161 + .unwrap()
162 + }
163 +
164 + /// POST the confirm for `version_id` naming `key`.
165 + async fn confirm_version(h: &mut TestHarness, version_id: &str, key: &str) -> TestResponse {
166 + h.client
167 + .post_json(
168 + &format!("/api/versions/{version_id}/upload/confirm"),
169 + &json!({ "s3_key": key }).to_string(),
170 + )
171 + .await
172 + }
173 +
174 + /// Set a version's scan verdict directly, which no route lets a test do.
175 + async fn set_version_scan_status(h: &TestHarness, version_id: &str, status: &str) {
176 + sqlx::query("UPDATE versions SET scan_status = $1 WHERE id = $2::uuid")
177 + .bind(status)
178 + .bind(version_id)
179 + .execute(&h.db)
180 + .await
181 + .unwrap();
182 + }
183 +
184 + /// Set the item's duration and read back the `expires_in` the stream route
185 + /// answers with.
186 + async fn stream_expires_in(h: &mut TestHarness, item_id: &str, duration: Option<i32>) -> u64 {
187 + sqlx::query("UPDATE items SET duration_seconds = $1 WHERE id = $2::uuid")
188 + .bind(duration)
189 + .bind(item_id)
190 + .execute(&h.db)
191 + .await
192 + .unwrap();
193 + let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
194 + assert_eq!(
195 + resp.status, 200,
196 + "stream of a free clean item: {}",
197 + resp.text
198 + );
199 + let body: Value = resp.json();
200 + body["expires_in"]
201 + .as_u64()
202 + .unwrap_or_else(|| panic!("expires_in must be a number, body: {}", resp.text))
203 + }
204 +
205 + /// Record a completed purchase of `item_id` by `buyer`. This is what
206 + /// `check_item_access` reads as `has_purchased`.
207 + async fn record_purchase(h: &TestHarness, buyer: UserId, seller: UserId, item_id: &str) {
208 + sqlx::query(
209 + "INSERT INTO transactions
210 + (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents,
211 + stripe_checkout_session_id, status, completed_at, item_title,
212 + seller_username, share_contact)
213 + VALUES ($1, $2, $3::uuid, 1500, 0, $4, 'completed', NOW(), 'Test Item',
214 + 'seller', false)",
215 + )
216 + .bind(buyer)
217 + .bind(seller)
218 + .bind(item_id)
219 + .bind(format!("cs_{buyer}"))
220 + .execute(&h.db)
221 + .await
222 + .expect("record purchase");
223 + }
224 +
225 + async fn storage_used(h: &TestHarness, user: UserId) -> i64 {
226 + sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1")
227 + .bind(user)
228 + .fetch_one(&h.db)
229 + .await
230 + .unwrap()
231 + }
232 +
233 + async fn queued_deletions(h: &TestHarness, key: &str) -> i64 {
234 + sqlx::query_scalar("SELECT COUNT(*) FROM pending_s3_deletions WHERE s3_key = $1")
235 + .bind(key)
236 + .fetch_one(&h.db)
237 + .await
238 + .unwrap()
239 + }
240 +
241 + async fn pending_uploads(h: &TestHarness, user: UserId, key: &str) -> i64 {
242 + sqlx::query_scalar("SELECT COUNT(*) FROM pending_uploads WHERE user_id = $1 AND s3_key = $2")
243 + .bind(user)
244 + .bind(key)
245 + .fetch_one(&h.db)
246 + .await
247 + .unwrap()
248 + }
249 +
250 + // routes::storage::downloads, stream_url
251 +
252 + /// The presign window is `duration * 2`, clamped to [3600, 86400]. Each duration
253 + /// is chosen so a wrong operator answers differently: 5400 doubles to 10800,
254 + /// which no clamp touches and which `duration + 3600`, `duration` and
255 + /// `duration * 3` all miss; 1200 doubles under the floor, 50000 over the
256 + /// ceiling, so a missing clamp shows up on either side.
257 + #[tokio::test]
258 + async fn stream_expiry_is_twice_the_duration_clamped_between_an_hour_and_a_day() {
259 + let mut h = TestHarness::with_storage().await;
260 + let (setup, _key) = published_audio_item(&mut h, "expclamp", 0).await;
261 + let item_id = setup.item_id.clone();
262 +
263 + assert_eq!(
264 + stream_expires_in(&mut h, &item_id, Some(5400)).await,
265 + 10_800,
266 + "a 90 minute track presigns for twice its duration"
267 + );
268 + assert_eq!(
269 + stream_expires_in(&mut h, &item_id, Some(1200)).await,
270 + 3_600,
271 + "2400s is below the one hour floor and must be lifted to it"
272 + );
273 + assert_eq!(
274 + stream_expires_in(&mut h, &item_id, Some(50_000)).await,
275 + 86_400,
276 + "100000s is above the 24 hour ceiling and must be cut to it"
277 + );
278 + assert_eq!(
279 + stream_expires_in(&mut h, &item_id, None).await,
280 + 3_600,
281 + "an unknown duration presigns for the default hour"
282 + );
283 + }
284 +
285 + /// The entitlement ladder on the stream route. The distinction between 401 and
286 + /// 403 is the contract: anonymous means "logging in might help", a logged-in
287 + /// non-buyer means it will not.
288 + #[tokio::test]
289 + async fn stream_refuses_the_anonymous_and_the_unpaid_and_admits_the_buyer() {
290 + let mut h = TestHarness::with_storage().await;
291 + // 1250 cents, chosen away from 0 and 1 so a price read as a boolean or an
292 + // off-by-one free check still reads as paid.
293 + let (setup, key) = published_audio_item(&mut h, "paidstream", 1250).await;
294 + let item_id = setup.item_id.clone();
295 + let stream = format!("/api/stream/{item_id}");
296 +
297 + // The creator always reaches their own paid file.
298 + let resp = h.client.get(&stream).await;
299 + assert_eq!(
300 + resp.status, 200,
301 + "creator reaches own paid item: {}",
302 + resp.text
303 + );
304 +
305 + let mut anon = h.client.fork_fresh();
306 + let resp = anon.get(&stream).await;
307 + assert_eq!(
308 + resp.status, 401,
309 + "anon on a paid item is 401: {}",
310 + resp.text
311 + );
312 +
313 + let buyer = seed_stranger(&mut h, "paidstranger").await;
314 + let resp = h.client.get(&stream).await;
315 + assert_eq!(resp.status, 403, "non-buyer is 403: {}", resp.text);
316 +
317 + record_purchase(&h, buyer, setup.user_id, &item_id).await;
318 + let resp = h.client.get(&stream).await;
319 + assert_eq!(resp.status, 200, "a purchase opens the gate: {}", resp.text);
320 + let body: Value = resp.json();
321 + assert!(
322 + body["stream_url"]
323 + .as_str()
324 + .is_some_and(|u| u.contains(&key)),
325 + "the presigned URL must reference the item's own key, got: {}",
326 + resp.text
327 + );
328 + }
329 +
330 + // routes::storage::downloads, version_download
331 +
332 + /// The redirect and the counters. Two downloads by one user must move the two
333 + /// aggregate counters twice and the per-user record once, which is the whole
334 + /// difference between "downloads" and "people who downloaded".
335 + #[tokio::test]
336 + async fn version_download_redirects_and_counts_every_download_but_one_downloader() {
337 + let mut h = TestHarness::with_storage().await;
338 + let fx = published_item_with_version(&mut h, "dlcount", 0).await;
339 +
340 + for attempt in 1..=2 {
341 + let resp = h.client.get(&fx.url).await;
342 + assert_eq!(
343 + resp.status, 303,
344 + "download {attempt} answers 303, not JSON: {}",
345 + resp.text
346 + );
347 + let location = resp
348 + .header("location")
349 + .unwrap_or_else(|| panic!("303 must carry a Location header, body: {}", resp.text));
350 + assert!(
351 + location.contains(&fx.s3_key),
352 + "Location must point at this version's own object, got {location}"
353 + );
354 + }
355 +
356 + let version_count: i32 =
357 + sqlx::query_scalar("SELECT download_count FROM versions WHERE id = $1::uuid")
358 + .bind(&fx.version_id)
359 + .fetch_one(&h.db)
360 + .await
361 + .unwrap();
362 + assert_eq!(
363 + version_count, 2,
364 + "each download increments the version counter, so two downloads is 2"
365 + );
366 +
367 + let item_count: i32 =
368 + sqlx::query_scalar("SELECT download_count FROM items WHERE id = $1::uuid")
369 + .bind(&fx.item_id)
370 + .fetch_one(&h.db)
371 + .await
372 + .unwrap();
373 + assert_eq!(
374 + item_count, 2,
375 + "the item-level counter tracks the version counter"
376 + );
377 +
378 + let per_user: i64 = sqlx::query_scalar(
379 + "SELECT COUNT(*) FROM user_downloads WHERE user_id = $1 AND version_id = $2::uuid",
380 + )
381 + .bind(fx.creator)
382 + .bind(&fx.version_id)
383 + .fetch_one(&h.db)
384 + .await
385 + .unwrap();
386 + assert_eq!(
387 + per_user, 1,
388 + "the per-user download row is recorded once however often the file is fetched"
389 + );
390 + }
391 +
392 + /// Payment and publication are separate gates, and unpublishing revokes a file
393 + /// the buyer already paid for: a gate that only consulted the purchase would
394 + /// still hand over the bytes.
395 + #[tokio::test]
396 + async fn version_download_gates_on_payment_and_on_publication_separately() {
397 + let mut h = TestHarness::with_storage().await;
398 + let fx = published_item_with_version(&mut h, "dlgate", 1500).await;
399 +
400 + let mut anon = h.client.fork_fresh();
401 + let resp = anon.get(&fx.url).await;
402 + assert_eq!(
403 + resp.status, 401,
404 + "anon on a paid version is 401: {}",
405 + resp.text
406 + );
407 +
408 + let buyer = seed_stranger(&mut h, "dlgatefan").await;
409 + let resp = h.client.get(&fx.url).await;
410 + assert_eq!(resp.status, 403, "non-buyer is 403: {}", resp.text);
411 +
412 + record_purchase(&h, buyer, fx.creator, &fx.item_id).await;
413 + let resp = h.client.get(&fx.url).await;
414 + assert_eq!(
415 + resp.status, 303,
416 + "the buyer gets the redirect: {}",
417 + resp.text
418 + );
419 +
420 + // The creator unpublishes. The purchase is untouched, but the file is no
421 + // longer public, and only the creator may still reach it.
422 + sqlx::query("UPDATE items SET is_public = false WHERE id = $1::uuid")
423 + .bind(&fx.item_id)
424 + .execute(&h.db)
425 + .await
426 + .unwrap();
427 +
428 + let resp = h.client.get(&fx.url).await;
429 + assert_eq!(
430 + resp.status, 404,
431 + "unpublished is 404 even for a buyer: {}",
432 + resp.text
433 + );
434 +
435 + become_user(&mut h, "dlgate").await;
436 + let resp = h.client.get(&fx.url).await;
437 + assert_eq!(
438 + resp.status, 303,
439 + "creator previews own draft: {}",
440 + resp.text
441 + );
442 + }
443 +
444 + /// The scan gate is not symmetric. A creator may pull their own file back while
445 + /// it waits for review, but a quarantined verdict is final for everyone, the
446 + /// creator included, so a confirmed-malicious object is never served.
447 + #[tokio::test]
448 + async fn version_download_lets_a_creator_preview_a_held_file_but_never_a_quarantined_one() {
449 + let mut h = TestHarness::with_storage().await;
450 + // Free item, so nothing but the scan status can explain a refusal.
451 + let fx = published_item_with_version(&mut h, "dlscan", 0).await;
452 +
453 + set_version_scan_status(&h, &fx.version_id, "held_for_review").await;
454 + let resp = h.client.get(&fx.url).await;
455 + assert_eq!(
456 + resp.status, 303,
457 + "creator previews own held file: {}",
458 + resp.text
459 + );
460 +
461 + let mut anon = h.client.fork_fresh();
462 + let resp = anon.get(&fx.url).await;
463 + assert_eq!(
464 + resp.status, 404,
465 + "a held file is 404 to others: {}",
466 + resp.text
467 + );
468 +
469 + set_version_scan_status(&h, &fx.version_id, "quarantined").await;
470 + let resp = h.client.get(&fx.url).await;
471 + assert_eq!(
472 + resp.status, 404,
473 + "quarantine is 404 to its creator: {}",
474 + resp.text
475 + );
476 + }
477 +
478 + // routes::storage::versions
479 +
480 + /// Presign proves ownership through version -> item -> project, and leaves the
481 + /// `pending_uploads` row that is the only thing authorizing the later confirm
482 + /// of an opaque `staging/{uuid}` key.
483 + #[tokio::test]
484 + async fn version_presign_records_a_pending_upload_and_refuses_a_stranger() {
485 + let mut h = TestHarness::with_storage().await;
486 + let (setup, version_id) = creator_with_version(&mut h, "vpresign", 0, false).await;
487 +
488 + let resp = h
489 + .client
490 + .post_json(
491 + &format!("/api/versions/{version_id}/upload/presign"),
492 + &json!({
493 + "file_name": "bundle.zip",
494 + "content_type": "application/zip",
495 + "file_size_bytes": 12_345,
496 + })
497 + .to_string(),
498 + )
499 + .await;
500 + assert_eq!(resp.status, 200, "the owner may presign: {}", resp.text);
Lines truncated
@@ -1,0 +1,696 @@
1 + //! Route-layer contract tests for the buyer-facing purchase handlers:
2 + //! `routes::stripe::checkout::item` and `routes::stripe::checkout::cart`.
3 + //!
4 + //! These two routes decide, from a form post, how many cents a buyer is about
5 + //! to be charged. Every test pins that number exactly: the pending row a handler
6 + //! writes before redirecting to the provider must carry the amount the provider
7 + //! session was built from, in integer cents. A handler that shipped dollars
8 + //! where cents were meant, or the listed price where the buyer's own
9 + //! pay-what-you-want amount was meant, would pass a test that only asserted "a
10 + //! session was created" and fails every test below.
11 + //!
12 + //! Oracle note: the mock provider records a session id and URL but not the
13 + //! amount, so the amount is read back from the row the handler wrote in the same
14 + //! breath (transactions.amount_cents) and tied to the provider by asserting that
15 + //! row's session id is the session the buyer was redirected to. Both halves are
16 + //! needed: the amount alone would not prove it belongs to this session, the
17 + //! session alone would not prove the amount.
18 + //!
19 + //! The tip handler is `stripe_tip_checkout_routes`.
20 + //!
21 + //! Delete this file and two things stop being checked anywhere: the per-line
22 + //! price fidelity of a multi-item cart, and that re-submitting a checkout form
23 + //! does not open a second charge for the same item.
24 +
25 + use crate::harness::TestHarness;
26 + use crate::harness::client::TestResponse;
27 + use crate::harness::stripe::MockCheckout;
28 + use makenotwork::db;
29 + use serde_json::Value;
30 +
31 + // Helpers
32 +
33 + /// Assert an exact status, reporting the body when it does not hold.
34 + fn assert_status(resp: &TestResponse, expected: u16, what: &str) {
35 + assert_eq!(
36 + resp.status, expected,
37 + "{what}: got {} with body {}",
38 + resp.status, resp.text
39 + );
40 + }
41 +
42 + /// Assert the exact redirect target, reporting the body when it does not hold.
43 + fn assert_redirect(resp: &TestResponse, target: &str, what: &str) {
44 + assert_eq!(
45 + resp.header("location"),
46 + Some(target),
47 + "{what}, body: {}",
48 + resp.text
49 + );
50 + }
51 +
52 + /// Sessions the mock provider was asked to open, in call order.
53 + fn sessions(h: &TestHarness) -> Vec<MockCheckout> {
54 + h.mock_stripe
55 + .as_ref()
56 + .expect("mock stripe configured")
57 + .checkouts()
58 + }
59 +
60 + /// Assert how many provider sessions have been opened so far.
61 + fn assert_sessions(h: &TestHarness, expected: usize, what: &str) {
62 + let opened = sessions(h);
63 + assert_eq!(opened.len(), expected, "{what}, got {opened:?}");
64 + }
65 +
66 + /// A buyer's open charge amounts, ascending. Ascending rather than insertion
67 + /// order so a test can state the exact multiset of line prices without
68 + /// depending on which row landed first.
69 + async fn pending_amounts(h: &TestHarness, buyer_id: db::UserId) -> Vec<i32> {
70 + sqlx::query_scalar(
71 + "SELECT amount_cents FROM transactions \
72 + WHERE buyer_id = $1 AND status = 'pending' ORDER BY amount_cents",
73 + )
74 + .bind(buyer_id)
75 + .fetch_all(&h.db)
76 + .await
77 + .expect("read pending amounts")
78 + }
79 +
80 + /// Assert the buyer's open charges are exactly these cent amounts.
81 + async fn assert_pending(h: &TestHarness, buyer_id: db::UserId, expected: &[i32], what: &str) {
82 + assert_eq!(pending_amounts(h, buyer_id).await, expected, "{what}");
83 + }
84 +
85 + /// The provider session id recorded on a buyer's single pending row.
86 + async fn pending_session_id(h: &TestHarness, buyer_id: db::UserId) -> String {
87 + sqlx::query_scalar(
88 + "SELECT stripe_checkout_session_id FROM transactions \
89 + WHERE buyer_id = $1 AND status = 'pending'",
90 + )
91 + .bind(buyer_id)
92 + .fetch_one(&h.db)
93 + .await
94 + .expect("read pending session id")
95 + }
96 +
97 + /// Create a Stripe-connected creator with one published paid item, then log
98 + /// out. Returns `(seller_id, project_id, item_id)`.
99 + async fn connected_seller_with_item(
100 + h: &mut TestHarness,
101 + username: &str,
102 + price_cents: i64,
103 + ) -> (db::UserId, String, String) {
104 + let setup = h
105 + .create_creator_with_item(username, "audio", price_cents)
106 + .await;
107 + h.connect_stripe(setup.user_id, &format!("acct_test_{username}"))
108 + .await;
109 + h.publish_project_and_item(&setup.project_id, &setup.item_id)
110 + .await;
111 + h.client.post_form("/logout", "").await;
112 + (setup.user_id, setup.project_id, setup.item_id)
113 + }
114 +
115 + /// Add a second published item to an existing project. The owning creator must
116 + /// be logged in.
117 + async fn add_published_item(
118 + h: &mut TestHarness,
119 + project_id: &str,
120 + title: &str,
121 + price_cents: i64,
122 + ) -> String {
123 + let resp = h
124 + .client
125 + .post_form(
126 + &format!("/api/projects/{project_id}/items"),
127 + &format!("title={title}&item_type=audio&price_cents={price_cents}"),
128 + )
129 + .await;
130 + assert_status(&resp, 200, "create extra item");
131 + let item: Value = resp.json();
132 + let item_id = item["id"]
133 + .as_str()
134 + .expect("item id in response")
135 + .to_string();
136 +
137 + let resp = h
138 + .client
139 + .put_form(&format!("/api/items/{item_id}"), "is_public=true")
140 + .await;
141 + assert_status(&resp, 200, "publish extra item");
142 + item_id
143 + }
144 +
145 + /// Turn an existing item into a pay-what-you-want item with the given minimum.
146 + async fn make_pwyw(h: &TestHarness, item_id: &str, min_cents: i32) {
147 + sqlx::query("UPDATE items SET pwyw_enabled = true, pwyw_min_cents = $2 WHERE id = $1::uuid")
148 + .bind(item_id)
149 + .bind(min_cents)
150 + .execute(&h.db)
151 + .await
152 + .expect("enable pwyw");
153 + }
154 +
155 + /// Add one item to the logged-in buyer's cart.
156 + async fn add_to_cart(h: &mut TestHarness, item_id: &str) {
157 + let resp = h
158 + .client
159 + .post_form(&format!("/api/cart/{item_id}"), "")
160 + .await;
161 + assert_status(&resp, 200, "add to cart");
162 + }
163 +
164 + // routes::stripe::checkout::item
165 +
166 + /// The pending charge is the item's price in cents, unrounded and unscaled, and
167 + /// it belongs to the session the buyer was redirected to. 1234 is chosen so a
168 + /// dollars/cents mix-up (12 or 123400) and a round to the nearest dollar (1200)
169 + /// each give a different answer than the correct one.
170 + #[tokio::test]
171 + async fn item_checkout_charges_the_listed_price_in_cents() {
172 + let mut h = TestHarness::with_mocks().await;
173 + let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "itemseller", 1234).await;
174 + let buyer_id = h
175 + .signup("itembuyer", "itembuyer@test.com", "pass1234")
176 + .await;
177 +
178 + let resp = h
179 + .client
180 + .post_form(
181 + &format!("/stripe/checkout/{item_id}"),
182 + "share_contact=false",
183 + )
184 + .await;
185 + assert_status(&resp, 303, "paid item checkout must redirect to Stripe");
186 +
187 + let opened = sessions(&h);
188 + assert_sessions(&h, 1, "one submission opens exactly one provider session");
189 + assert_redirect(
190 + &resp,
191 + &opened[0].url,
192 + "buyer goes to the session just opened",
193 + );
194 + assert_pending(
195 + &h,
196 + buyer_id,
197 + &[1234],
198 + "the charge is the item's exact cent price",
199 + )
200 + .await;
201 + assert_eq!(
202 + pending_session_id(&h, buyer_id).await,
203 + opened[0].id,
204 + "the pending row must reference the session the buyer was sent to"
205 + );
206 + }
207 +
208 + /// A pay-what-you-want item is charged at the buyer's amount, not at the listed
209 + /// `price_cents`. The two differ (763 vs 1234) so reading the wrong field shows.
210 + #[tokio::test]
211 + async fn pwyw_checkout_charges_the_buyer_chosen_amount_not_the_listed_price() {
212 + let mut h = TestHarness::with_mocks().await;
213 + let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "pwywseller", 1234).await;
214 + make_pwyw(&h, &item_id, 500).await;
215 + let buyer_id = h
216 + .signup("pwywbuyer", "pwywbuyer@test.com", "pass1234")
217 + .await;
218 +
219 + let path = format!("/stripe/checkout/{item_id}");
220 + let resp = h
221 + .client
222 + .post_form(&path, "share_contact=false&amount_cents=763")
223 + .await;
224 + assert_status(&resp, 303, "pwyw checkout above the minimum must redirect");
225 + assert_pending(
226 + &h,
227 + buyer_id,
228 + &[763],
229 + "pwyw charges the buyer's amount, not the list",
230 + )
231 + .await;
232 + }
233 +
234 + /// Both sides of the creator's pay-what-you-want floor: one cent under is
235 + /// refused with no row written, the floor itself goes through at that amount.
236 + #[tokio::test]
237 + async fn pwyw_checkout_refuses_below_the_creator_minimum_and_accepts_the_minimum_itself() {
238 + let mut h = TestHarness::with_mocks().await;
239 + let (_seller, _project, item_id) =
240 + connected_seller_with_item(&mut h, "pwywminsell", 1234).await;
241 + make_pwyw(&h, &item_id, 500).await;
242 + let buyer_id = h
243 + .signup("pwywminbuy", "pwywminbuy@test.com", "pass1234")
244 + .await;
245 + let url = format!("/stripe/checkout/{item_id}");
246 +
247 + let resp = h
248 + .client
249 + .post_form(&url, "share_contact=false&amount_cents=499")
250 + .await;
251 + assert_status(
252 + &resp,
253 + 400,
254 + "one cent under the pwyw minimum must be refused",
255 + );
256 + assert_pending(&h, buyer_id, &[], "a refused pwyw amount writes no charge").await;
257 +
258 + let resp = h
259 + .client
260 + .post_form(&url, "share_contact=false&amount_cents=500")
261 + .await;
262 + assert_status(&resp, 303, "the pwyw minimum itself must be accepted");
263 + assert_pending(
264 + &h,
265 + buyer_id,
266 + &[500],
267 + "the accepted amount is exact, not rounded",
268 + )
269 + .await;
270 + }
271 +
272 + /// Both sides of the $10,000 pay-what-you-want ceiling. The cap exists so a
273 + /// mistyped amount cannot open a mega-charge, so the cent above it must fail
274 + /// while the cap itself still succeeds.
275 + #[tokio::test]
276 + async fn pwyw_checkout_refuses_above_the_ten_thousand_dollar_cap_and_accepts_the_cap_itself() {
277 + let mut h = TestHarness::with_mocks().await;
278 + let (_seller, _project, item_id) =
279 + connected_seller_with_item(&mut h, "pwywcapsell", 1234).await;
280 + make_pwyw(&h, &item_id, 500).await;
281 + let buyer_id = h
282 + .signup("pwywcapbuy", "pwywcapbuy@test.com", "pass1234")
283 + .await;
284 + let url = format!("/stripe/checkout/{item_id}");
285 +
286 + let resp = h
287 + .client
288 + .post_form(&url, "share_contact=false&amount_cents=1000001")
289 + .await;
290 + assert_status(&resp, 400, "one cent over the $10,000 cap must be refused");
291 + assert_pending(
292 + &h,
293 + buyer_id,
294 + &[],
295 + "a refused over-cap amount writes no charge",
296 + )
297 + .await;
298 +
299 + let resp = h
300 + .client
301 + .post_form(&url, "share_contact=false&amount_cents=1000000")
302 + .await;
303 + assert_status(&resp, 303, "the cap itself must be accepted");
304 + assert_pending(
305 + &h,
306 + buyer_id,
307 + &[1_000_000],
308 + "the cap amount is charged exactly",
309 + )
310 + .await;
311 + }
312 +
313 + /// Both sides of the 50 cent USD provider minimum. Under it the buyer gets a
314 + /// clean refusal and no pending row; at it the charge opens at 50 cents.
315 + #[tokio::test]
316 + async fn item_checkout_refuses_a_price_under_the_stripe_minimum_and_accepts_the_minimum_itself() {
317 + let mut h = TestHarness::with_mocks().await;
318 + let (_seller, project_id, cheap_item) = connected_seller_with_item(&mut h, "minsell", 49).await;
319 +
320 + // Second item priced at exactly the minimum, under the same seller.
321 + h.login("minsell", "password123").await;
322 + let at_min_item = add_published_item(&mut h, &project_id, "At+Minimum", 50).await;
323 + h.client.post_form("/logout", "").await;
324 + let buyer_id = h.signup("minbuyer", "minbuyer@test.com", "pass1234").await;
325 +
326 + let path = format!("/stripe/checkout/{cheap_item}");
327 + let resp = h.client.post_form(&path, "share_contact=false").await;
328 + assert_status(&resp, 400, "49 cents is under the USD provider minimum");
329 + assert_pending(&h, buyer_id, &[], "a sub-minimum item writes no charge").await;
330 +
331 + let path = format!("/stripe/checkout/{at_min_item}");
332 + let resp = h.client.post_form(&path, "share_contact=false").await;
333 + assert_status(
334 + &resp,
335 + 303,
336 + "50 cents is exactly the minimum and is accepted",
337 + );
338 + assert_pending(
339 + &h,
340 + buyer_id,
341 + &[50],
342 + "the minimum-priced item is charged at 50",
343 + )
344 + .await;
345 + }
346 +
347 + /// Replay of the buyer-side submission: a double-posted checkout form (a
348 + /// refresh, a double click, a retried request) must leave exactly one open
349 + /// charge. The second post is answered with the purchase page rather than a
350 + /// second Stripe redirect, and the surviving row still points at the first
351 + /// session.
352 + #[tokio::test]
353 + async fn repeated_item_checkout_leaves_exactly_one_pending_charge() {
354 + let mut h = TestHarness::with_mocks().await;
355 + let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "dupeseller", 1234).await;
356 + let buyer_id = h
357 + .signup("dupebuyer", "dupebuyer@test.com", "pass1234")
358 + .await;
359 + let url = format!("/stripe/checkout/{item_id}");
360 +
361 + let first = h.client.post_form(&url, "share_contact=false").await;
362 + assert_status(&first, 303, "first checkout must redirect to Stripe");
363 + let first_session = pending_session_id(&h, buyer_id).await;
364 +
365 + let second = h.client.post_form(&url, "share_contact=false").await;
366 + assert_status(&second, 303, "the replay answers with a redirect");
367 + assert_redirect(
368 + &second,
369 + &format!("/purchase/{item_id}"),
370 + "the replay lands on the purchase page, not a second session",
371 + );
372 + assert_pending(&h, buyer_id, &[1234], "a replay opens no second charge").await;
373 + assert_eq!(
374 + pending_session_id(&h, buyer_id).await,
375 + first_session,
376 + "the surviving pending row must still be the first session's"
377 + );
378 + }
379 +
380 + /// Cancelling the pending checkout is what lets a buyer start over: the row is
381 + /// gone afterwards, and a fresh submission opens a new charge at the same price
382 + /// against a different session.
383 + #[tokio::test]
384 + async fn cancelling_a_pending_item_checkout_lets_the_buyer_start_a_fresh_one() {
385 + let mut h = TestHarness::with_mocks().await;
386 + let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "cancelsell", 1234).await;
387 + let buyer_id = h
388 + .signup("cancelbuy", "cancelbuy@test.com", "pass1234")
389 + .await;
390 + let url = format!("/stripe/checkout/{item_id}");
391 +
392 + let resp = h.client.post_form(&url, "share_contact=false").await;
393 + assert_status(&resp, 303, "first checkout must redirect");
394 + let first_session = pending_session_id(&h, buyer_id).await;
395 +
396 + let resp = h
397 + .client
398 + .post_form(&format!("{url}/cancel-pending"), "")
399 + .await;
400 + assert_status(&resp, 303, "cancel-pending redirects to the purchase page");
401 + assert_pending(&h, buyer_id, &[], "cancel-pending clears the open charge").await;
402 +
403 + let resp = h.client.post_form(&url, "share_contact=false").await;
404 + assert_status(
405 + &resp,
406 + 303,
407 + "a fresh checkout after cancelling must redirect",
408 + );
409 + assert_pending(&h, buyer_id, &[1234], "the fresh checkout opens one charge").await;
410 + assert_ne!(
411 + pending_session_id(&h, buyer_id).await,
412 + first_session,
413 + "the fresh checkout must be a new session, not the cancelled one"
414 + );
415 + }
416 +
417 + // routes::stripe::checkout::cart
418 +
419 + /// Two lines from one seller become one pending row each, at that line's own
420 + /// price, inside a single session. The prices are distinct and neither divides
421 + /// the other, so duplicating a line (1234 twice), averaging them (900) or
422 + /// charging the sum once all differ from the expected pair.
423 + #[tokio::test]
424 + async fn cart_checkout_creates_one_pending_row_per_line_at_that_lines_exact_price() {
425 + let mut h = TestHarness::with_mocks().await;
426 + let (seller_id, project_id, item_a) =
427 + connected_seller_with_item(&mut h, "cartlines", 1234).await;
428 + h.login("cartlines", "password123").await;
429 + let item_b = add_published_item(&mut h, &project_id, "Second+Line", 567).await;
430 + h.client.post_form("/logout", "").await;
431 +
432 + let buyer_id = h
433 + .signup("cartlinebuy", "cartlinebuy@test.com", "pass1234")
434 + .await;
435 + add_to_cart(&mut h, &item_a).await;
436 + add_to_cart(&mut h, &item_b).await;
437 +
438 + let body = format!("seller_id={seller_id}&share_contact=false");
439 + let resp = h.client.post_form("/stripe/checkout/cart", &body).await;
440 + assert_status(&resp, 303, "cart checkout must redirect to Stripe");
441 +
442 + let opened = sessions(&h);
443 + assert_sessions(&h, 1, "one seller's cart is one provider session");
444 + assert_redirect(
445 + &resp,
446 + &opened[0].url,
447 + "buyer goes to the session just opened",
448 + );
449 + assert_pending(
450 + &h,
451 + buyer_id,
452 + &[567, 1234],
453 + "each line is charged at its own price",
454 + )
455 + .await;
456 +
457 + let rows: Vec<String> = sqlx::query_scalar(
458 + "SELECT DISTINCT stripe_checkout_session_id FROM transactions \
459 + WHERE buyer_id = $1 AND status = 'pending'",
460 + )
461 + .bind(buyer_id)
462 + .fetch_all(&h.db)
463 + .await
464 + .expect("read pending sessions");
465 + assert_eq!(
466 + rows,
467 + vec![opened[0].id.clone()],
468 + "both lines must hang off the single session the buyer was sent to"
469 + );
470 + }
471 +
472 + /// A free line is claimed outright at zero and never reaches the provider; the
473 + /// paid line is charged alone. Asserting the free row is zero and the paid row
474 + /// is the full 1234 separates "claimed the free item" from "folded it into the
475 + /// charge".
476 + #[tokio::test]
477 + async fn cart_checkout_claims_a_free_line_at_zero_and_charges_only_the_paid_line() {
478 + let mut h = TestHarness::with_mocks().await;
479 + let (seller_id, project_id, paid_item) =
480 + connected_seller_with_item(&mut h, "cartfree", 1234).await;
481 + h.login("cartfree", "password123").await;
482 + let free_item = add_published_item(&mut h, &project_id, "Free+Line", 0).await;
483 + h.client.post_form("/logout", "").await;
484 +
485 + let buyer_id = h
486 + .signup("cartfreebuy", "cartfreebuy@test.com", "pass1234")
487 + .await;
488 + add_to_cart(&mut h, &paid_item).await;
489 + add_to_cart(&mut h, &free_item).await;
490 +
491 + let body = format!("seller_id={seller_id}&share_contact=false");
492 + let resp = h.client.post_form("/stripe/checkout/cart", &body).await;
493 + assert_status(&resp, 303, "mixed free/paid cart checkout must redirect");
494 + assert_pending(
495 + &h,
496 + buyer_id,
497 + &[1234],
498 + "only the paid line is charged, at its price",
499 + )
500 + .await;
Lines truncated
@@ -1,0 +1,319 @@
1 + //! Route-layer contract tests for `routes::stripe::checkout::tips`, the
2 + //! buyer-facing tip handler.
3 + //!
4 + //! A tip is the one checkout whose amount the payer types in whole dollars, so
5 + //! the conversion to integer cents is the contract: a handler that shipped the
6 + //! typed number straight through would charge a hundredth of the tip. Every
7 + //! test pins the exact cents figure on the pending tip row the handler writes,
8 + //! and ties that row to the provider by asserting its session id is the session
9 + //! the tipper was redirected to. Both halves are needed: the amount alone would
10 + //! not prove it belongs to this session, the session alone would not prove the
11 + //! amount.
12 + //!
13 + //! Also pinned: both ends of the one-to-ten-thousand-dollar range, inclusive;
14 + //! the 280-character message truncation; the project-ownership check; refusal
15 + //! of a disabled recipient and of a self-tip; and that each submission records
16 + //! its own pending tip, because a tip is a fresh intent to give rather than an
17 + //! idempotent purchase, which is the opposite of the item and cart contract.
18 + //!
19 + //! The item and cart handlers are `stripe_checkout_routes`.
20 + //!
21 + //! Delete this file and the dollars-to-cents conversion stops being checked
22 + //! anywhere.
23 +
24 + use crate::harness::TestHarness;
25 + use crate::harness::client::TestResponse;
26 + use crate::harness::stripe::MockCheckout;
27 + use makenotwork::db;
28 +
29 + // Helpers
30 +
31 + /// Assert an exact status, reporting the body when it does not hold.
32 + fn assert_status(resp: &TestResponse, expected: u16, what: &str) {
33 + assert_eq!(
34 + resp.status, expected,
35 + "{what}: got {} with body {}",
36 + resp.status, resp.text
37 + );
38 + }
39 +
40 + /// Assert the exact redirect target, reporting the body when it does not hold.
41 + fn assert_redirect(resp: &TestResponse, target: &str, what: &str) {
42 + assert_eq!(
43 + resp.header("location"),
44 + Some(target),
45 + "{what}, body: {}",
46 + resp.text
47 + );
48 + }
49 +
50 + /// Sessions the mock provider was asked to open, in call order.
51 + fn sessions(h: &TestHarness) -> Vec<MockCheckout> {
52 + h.mock_stripe
53 + .as_ref()
54 + .expect("mock stripe configured")
55 + .checkouts()
56 + }
57 +
58 + /// Assert how many provider sessions have been opened so far.
59 + fn assert_sessions(h: &TestHarness, expected: usize, what: &str) {
60 + let opened = sessions(h);
61 + assert_eq!(opened.len(), expected, "{what}, got {opened:?}");
62 + }
63 +
64 + /// Create a Stripe-connected creator with one published paid item, then log
65 + /// out. Returns `(seller_id, project_id, item_id)`.
66 + async fn connected_seller_with_item(
67 + h: &mut TestHarness,
68 + username: &str,
69 + price_cents: i64,
70 + ) -> (db::UserId, String, String) {
71 + let setup = h
72 + .create_creator_with_item(username, "audio", price_cents)
73 + .await;
74 + h.connect_stripe(setup.user_id, &format!("acct_test_{username}"))
75 + .await;
76 + h.publish_project_and_item(&setup.project_id, &setup.item_id)
77 + .await;
78 + h.client.post_form("/logout", "").await;
79 + (setup.user_id, setup.project_id, setup.item_id)
80 + }
81 +
82 + // routes::stripe::checkout::item
83 +
84 + /// The pending charge is the item's price in cents, unrounded and unscaled, and
85 + /// it belongs to the session the buyer was redirected to. 1234 is chosen so a
86 + /// dollars/cents mix-up (12 or 123400) and a round to the nearest dollar (1200)
87 + /// Create a Stripe-connected creator who accepts tips, then log out. Returns
88 + /// `(recipient_id, project_id)`.
89 + async fn tip_recipient(h: &mut TestHarness, username: &str) -> (db::UserId, String) {
90 + let setup = h.create_creator_with_item(username, "audio", 1234).await;
91 + h.connect_stripe(setup.user_id, &format!("acct_test_{username}"))
92 + .await;
93 + sqlx::query("UPDATE users SET tips_enabled = true WHERE id = $1")
94 + .bind(setup.user_id)
95 + .execute(&h.db)
96 + .await
97 + .expect("enable tips");
98 + h.client.post_form("/logout", "").await;
99 + (setup.user_id, setup.project_id)
100 + }
101 +
102 + /// How many pending tips a tipper has.
103 + async fn tip_count(h: &TestHarness, tipper_id: db::UserId) -> i64 {
104 + sqlx::query_scalar("SELECT COUNT(*) FROM tips WHERE tipper_id = $1")
105 + .bind(tipper_id)
106 + .fetch_one(&h.db)
107 + .await
108 + .expect("count tips")
109 + }
110 +
111 + /// Tips are submitted in whole dollars and stored in cents. Seven dollars is
112 + /// chosen because 700 differs from every plausible wrong operator: 7 (no
113 + /// conversion), 107 (addition), 0 (integer division), 100 (a constant).
114 + #[tokio::test]
115 + async fn tip_checkout_converts_whole_dollars_to_cents() {
116 + let mut h = TestHarness::with_mocks().await;
117 + let (recipient_id, _project) = tip_recipient(&mut h, "tipgetter").await;
118 + let tipper_id = h.signup("tipper", "tipper@test.com", "pass1234").await;
119 +
120 + let path = format!("/stripe/checkout/tip/{recipient_id}");
121 + let resp = h.client.post_form(&path, "amount_dollars=7").await;
122 + assert_status(&resp, 303, "tip checkout must redirect to Stripe");
123 + let opened = sessions(&h);
124 + assert_sessions(&h, 1, "one tip submission opens one provider session");
125 + assert_redirect(&resp, &opened[0].url, "tipper goes to the session opened");
126 +
127 + let (amount, session_id): (i32, String) = sqlx::query_as(
128 + "SELECT amount_cents, stripe_checkout_session_id FROM tips \
129 + WHERE tipper_id = $1 AND recipient_id = $2",
130 + )
131 + .bind(tipper_id)
132 + .bind(recipient_id)
133 + .fetch_one(&h.db)
134 + .await
135 + .expect("pending tip row");
136 + assert_eq!(amount, 700, "a $7 tip is 700 cents");
137 + assert_eq!(
138 + session_id, opened[0].id,
139 + "the tip row must reference the session the tipper was sent to"
140 + );
141 + }
142 +
143 + /// Both sides of both ends of the accepted range: $0 and $10,001 are refused,
144 + /// $1 and $10,000 go through at 100 and 1,000,000 cents.
145 + #[tokio::test]
146 + async fn tip_checkout_refuses_amounts_outside_the_one_to_ten_thousand_dollar_range() {
147 + let mut h = TestHarness::with_mocks().await;
148 + let (recipient_id, _project) = tip_recipient(&mut h, "boundsgetter").await;
149 + let tipper_id = h
150 + .signup("boundstipper", "boundstipper@test.com", "pass1234")
151 + .await;
152 + let url = format!("/stripe/checkout/tip/{recipient_id}");
153 +
154 + let resp = h.client.post_form(&url, "amount_dollars=0").await;
155 + assert_status(&resp, 400, "a $0 tip is under the $1 minimum");
156 + let resp = h.client.post_form(&url, "amount_dollars=10001").await;
157 + assert_status(&resp, 400, "a $10,001 tip is over the $10,000 maximum");
158 + let resp = h.client.post_form(&url, "amount_dollars=1").await;
159 + assert_status(&resp, 303, "the $1 minimum itself is accepted");
160 + let resp = h.client.post_form(&url, "amount_dollars=10000").await;
161 + assert_status(&resp, 303, "the $10,000 maximum itself is accepted");
162 +
163 + let amounts: Vec<i32> = sqlx::query_scalar(
164 + "SELECT amount_cents FROM tips WHERE tipper_id = $1 ORDER BY amount_cents",
165 + )
166 + .bind(tipper_id)
167 + .fetch_all(&h.db)
168 + .await
169 + .expect("read tips");
170 + assert_eq!(
171 + amounts,
172 + vec![100, 1_000_000],
173 + "only the two in-range tips may exist, each converted to cents"
174 + );
175 + }
176 +
177 + /// A tip may name a project only when that project belongs to the recipient:
178 + /// the project decides which revenue splits the webhook credits, so an
179 + /// unrelated project would pay strangers out of this creator's tip.
180 + #[tokio::test]
181 + async fn tip_checkout_refuses_a_project_that_belongs_to_another_creator() {
182 + let mut h = TestHarness::with_mocks().await;
183 + let (recipient_id, own_project) = tip_recipient(&mut h, "projgetter").await;
184 + let (_other, other_project, _item) =
185 + connected_seller_with_item(&mut h, "otherowner", 1234).await;
186 + let tipper_id = h
187 + .signup("projtipper", "projtipper@test.com", "pass1234")
188 + .await;
189 + let url = format!("/stripe/checkout/tip/{recipient_id}");
190 +
191 + let form = format!("amount_dollars=7&project_id={other_project}");
192 + let resp = h.client.post_form(&url, &form).await;
193 + assert_status(&resp, 400, "a project owned by someone else is refused");
194 + assert_eq!(
195 + tip_count(&h, tipper_id).await,
196 + 0,
197 + "a refused tip must not leave a pending row behind"
198 + );
199 +
200 + let form = format!("amount_dollars=7&project_id={own_project}");
201 + let resp = h.client.post_form(&url, &form).await;
202 + assert_status(&resp, 303, "the recipient's own project is accepted");
203 + let (amount, project): (i32, String) =
204 + sqlx::query_as("SELECT amount_cents, project_id::text FROM tips WHERE tipper_id = $1")
205 + .bind(tipper_id)
206 + .fetch_one(&h.db)
207 + .await
208 + .expect("pending tip row");
209 + assert_eq!(amount, 700, "the accepted tip is still 700 cents");
210 + assert_eq!(
211 + project, own_project,
212 + "the tip must be attributed to the project that was submitted"
213 + );
214 + }
215 +
216 + /// Tip messages are truncated at 280 characters rather than rejected, and the
217 + /// kept part is the leading 280, not an arbitrary slice.
218 + #[tokio::test]
219 + async fn tip_checkout_truncates_a_message_at_two_hundred_eighty_characters() {
220 + let mut h = TestHarness::with_mocks().await;
221 + let (recipient_id, _project) = tip_recipient(&mut h, "msggetter").await;
222 + let tipper_id = h
223 + .signup("msgtipper", "msgtipper@test.com", "pass1234")
224 + .await;
225 +
226 + // 285 characters, distinguishable by position: the 281st onward is the part
227 + // that must be dropped.
228 + let sent: String = std::iter::repeat_n('a', 280)
229 + .chain(std::iter::repeat_n('z', 5))
230 + .collect();
231 + let path = format!("/stripe/checkout/tip/{recipient_id}");
232 + let resp = h
233 + .client
234 + .post_form(&path, &format!("amount_dollars=7&message={sent}"))
235 + .await;
236 + assert_status(&resp, 303, "an over-long message is truncated, not refused");
237 +
238 + let stored: String = sqlx::query_scalar("SELECT message FROM tips WHERE tipper_id = $1")
239 + .bind(tipper_id)
240 + .fetch_one(&h.db)
241 + .await
242 + .expect("pending tip row");
243 + assert_eq!(
244 + stored.chars().count(),
245 + 280,
246 + "the stored message must be exactly 280 characters, got {stored:?}"
247 + );
248 + assert_eq!(
249 + stored,
250 + sent.chars().take(280).collect::<String>(),
251 + "truncation must keep the leading 280 characters"
252 + );
253 + }
254 +
255 + /// Tips are not deduplicated: each submission is a separate intent to give, so
256 + /// two identical posts leave two pending tips against two distinct sessions.
257 + /// Pinned because the opposite reading (collapsing them) would silently drop a
258 + /// tip the giver meant to send.
259 + #[tokio::test]
260 + async fn each_tip_submission_records_its_own_pending_tip() {
261 + let mut h = TestHarness::with_mocks().await;
262 + let (recipient_id, _project) = tip_recipient(&mut h, "twicegetter").await;
263 + let tipper_id = h
264 + .signup("twicetipper", "twicetipper@test.com", "pass1234")
265 + .await;
266 + let url = format!("/stripe/checkout/tip/{recipient_id}");
267 +
268 + for attempt in 1..=2 {
269 + let resp = h.client.post_form(&url, "amount_dollars=7").await;
270 + assert_status(&resp, 303, &format!("tip submission {attempt} redirects"));
271 + }
272 +
273 + let rows: Vec<(i32, String)> = sqlx::query_as(
274 + "SELECT amount_cents, stripe_checkout_session_id FROM tips WHERE tipper_id = $1",
275 + )
276 + .bind(tipper_id)
277 + .fetch_all(&h.db)
278 + .await
279 + .expect("read tips");
280 + assert_eq!(rows.len(), 2, "each submission records its own tip");
281 + assert!(
282 + rows.iter().all(|(amount, _)| *amount == 700),
283 + "both tips are 700 cents, not one doubled row: {rows:?}"
284 + );
285 + assert_ne!(
286 + rows[0].1, rows[1].1,
287 + "the two tips must hang off distinct provider sessions: {rows:?}"
288 + );
289 + }
290 +
291 + /// A creator who has not turned tips on cannot be tipped, and nobody can tip
292 + /// themselves. Both refusals must happen before any row is written.
293 + #[tokio::test]
294 + async fn tip_checkout_refuses_a_disabled_recipient_and_a_self_tip() {
295 + let mut h = TestHarness::with_mocks().await;
296 + let (recipient_id, _project) = tip_recipient(&mut h, "offgetter").await;
297 + sqlx::query("UPDATE users SET tips_enabled = false WHERE id = $1")
298 + .bind(recipient_id)
299 + .execute(&h.db)
300 + .await
301 + .expect("disable tips");
302 + let tipper_id = h
303 + .signup("offtipper", "offtipper@test.com", "pass1234")
304 + .await;
305 +
306 + let path = format!("/stripe/checkout/tip/{recipient_id}");
307 + let resp = h.client.post_form(&path, "amount_dollars=7").await;
308 + assert_status(&resp, 400, "a creator not accepting tips is refused");
309 +
310 + let path = format!("/stripe/checkout/tip/{tipper_id}");
311 + let resp = h.client.post_form(&path, "amount_dollars=7").await;
312 + assert_status(&resp, 400, "a self-tip is refused");
313 + assert_eq!(
314 + tip_count(&h, tipper_id).await,
315 + 0,
316 + "neither refusal may leave a pending tip behind"
317 + );
318 + assert_sessions(&h, 0, "no provider session may be opened for a refused tip");
319 + }