Skip to main content

max / makenotwork

13.6 KB · 372 lines History Blame Raw
1 //! DB-layer contract tests for `db::synckit_billing`, the SyncKit v2
2 //! developer-billing writes.
3 //!
4 //! Audit Run 18 (Testing) flagged this module's guarded UPDATEs as asserted
5 //! only indirectly through the HTTP/webhook billing flow. These call the
6 //! `db::synckit_billing` functions directly against real Postgres so the
7 //! invariants the billing safety leans on are pinned at the layer they live in:
8 //!
9 //! - `apply_billing_update` persists status + period in one guarded statement,
10 //! - the terminal-`canceled` guard refuses to revive a canceled app (a stray
11 //! `invoice.paid` after a `deleted` can't resurrect it or refresh its period),
12 //! - the epoch-period guard drops a non-positive / inverted Stripe window
13 //! rather than stamping a 1970 period onto a live app,
14 //! - `activate_billing` is only valid from `draft` (replay / TOCTOU conflict),
15 //! - `claim_key` enforces `key_cap` under the `FOR UPDATE` lock, a claim that
16 //! would exceed the cap is refused atomically, never over-allocated,
17 //! - `claim_key` / `release_key` are idempotent.
18
19 use crate::harness::db::TestDb;
20 use crate::harness::seed_user;
21 use chrono::{DateTime, Utc};
22 use makenotwork::db::synckit_billing;
23 use makenotwork::db::{SyncAppId, SyncBillingStatus, SyncEnforcementMode, UserId};
24
25 /// Seed a non-internal draft sync app plus its live-usage row (the row the
26 /// storage layer normally inserts on app create, and that `claim_key` locks
27 /// `FOR UPDATE`).
28 async fn seed_billable_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
29 let app: SyncAppId = sqlx::query_scalar::<_, SyncAppId>(
30 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status)
31 VALUES ($1, $2, $3, $4, FALSE, 'draft') RETURNING id",
32 )
33 .bind(user)
34 .bind(name)
35 .bind(format!("hash_{name}"))
36 .bind(&name[..name.len().min(8)])
37 .fetch_one(pool)
38 .await
39 .expect("seed sync app");
40
41 sqlx::query("INSERT INTO sync_app_usage_current (app_id) VALUES ($1)")
42 .bind(app)
43 .execute(pool)
44 .await
45 .expect("seed usage row");
46
47 app
48 }
49
50 /// Read the current `keys_claimed` counter off `sync_app_usage_current`.
51 async fn keys_claimed(pool: &sqlx::PgPool, app: SyncAppId) -> i32 {
52 sqlx::query_scalar::<_, i32>(
53 "SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1",
54 )
55 .bind(app)
56 .fetch_one(pool)
57 .await
58 .expect("read keys_claimed")
59 }
60
61 /// Count of currently-active (un-released) key rows for the app.
62 async fn active_key_rows(pool: &sqlx::PgPool, app: SyncAppId) -> i64 {
63 sqlx::query_scalar::<_, i64>(
64 "SELECT COUNT(*) FROM sync_app_keys WHERE app_id = $1 AND released_at IS NULL",
65 )
66 .bind(app)
67 .fetch_one(pool)
68 .await
69 .expect("count active keys")
70 }
71
72 fn ts(secs: i64) -> DateTime<Utc> {
73 DateTime::<Utc>::from_timestamp(secs, 0).expect("valid timestamp")
74 }
75
76 /// Activate billing on a draft app in `bulk` mode with a 100 GB cap. Leaves the
77 /// app `active` with the shape constraint satisfied (storage_gb_cap set, per-key
78 /// knobs NULL).
79 async fn activate_bulk(pool: &sqlx::PgPool, app: SyncAppId, sub_id: &str, start: i64, end: i64) {
80 synckit_billing::activate_billing(
81 pool,
82 app,
83 SyncEnforcementMode::Bulk,
84 Some(100),
85 None,
86 None,
87 sub_id,
88 ts(start),
89 ts(end),
90 )
91 .await
92 .expect("activate billing");
93 }
94
95 // ── apply_billing_update: persistence ────────────────────────────────────────
96
97 #[tokio::test]
98 async fn apply_billing_update_persists_status_and_period() {
99 let db = TestDb::new().await;
100 let user = seed_user(&db.pool, "sbb_persist").await;
101 let app = seed_billable_app(&db.pool, user, "sbbpersist").await;
102 activate_bulk(&db.pool, app, "sub_persist", 1_700_000_000, 1_700_100_000).await;
103
104 // A Stripe-driven update flips status and refreshes the period atomically.
105 let updated = synckit_billing::apply_billing_update(
106 &db.pool,
107 app,
108 Some("suspended_unpaid"),
109 Some((1_700_200_000, 1_700_300_000)),
110 )
111 .await
112 .expect("apply update");
113 assert!(updated, "a live app row is updated");
114
115 let billing = synckit_billing::get_app_with_billing(&db.pool, app)
116 .await
117 .expect("load billing")
118 .expect("app exists");
119 assert_eq!(billing.billing_status, SyncBillingStatus::SuspendedUnpaid);
120 assert_eq!(billing.current_period_start, Some(ts(1_700_200_000)));
121 assert_eq!(billing.current_period_end, Some(ts(1_700_300_000)));
122 }
123
124 // ── apply_billing_update: terminal-canceled guard ────────────────────────────
125
126 #[tokio::test]
127 async fn apply_billing_update_cannot_revive_a_canceled_app() {
128 let db = TestDb::new().await;
129 let user = seed_user(&db.pool, "sbb_revive").await;
130 let app = seed_billable_app(&db.pool, user, "sbbrevive").await;
131 activate_bulk(&db.pool, app, "sub_revive", 1_700_000_000, 1_700_100_000).await;
132
133 // Cancel is terminal. (A `customer.subscription.deleted` webhook.)
134 let canceled = synckit_billing::apply_billing_update(&db.pool, app, Some("canceled"), None)
135 .await
136 .expect("cancel");
137 assert!(canceled, "the cancel transition itself matches a live row");
138
139 // A stray `invoice.paid` afterward tries to move canceled -> active AND
140 // refresh the period. The guard must refuse both in one statement.
141 let revived = synckit_billing::apply_billing_update(
142 &db.pool,
143 app,
144 Some("active"),
145 Some((1_700_900_000, 1_701_000_000)),
146 )
147 .await
148 .expect("apply update");
149 assert!(!revived, "no row updated: canceled is terminal");
150
151 let billing = synckit_billing::get_app_with_billing(&db.pool, app)
152 .await
153 .expect("load billing")
154 .expect("app exists");
155 assert_eq!(
156 billing.billing_status,
157 SyncBillingStatus::Canceled,
158 "status stays canceled"
159 );
160 // The period must not have been refreshed by the refused update.
161 assert_eq!(
162 billing.current_period_end,
163 Some(ts(1_700_100_000)),
164 "period is not refreshed on a canceled app"
165 );
166 }
167
168 // ── apply_billing_update: epoch-period guard ─────────────────────────────────
169
170 #[tokio::test]
171 async fn apply_billing_update_ignores_a_nonpositive_period() {
172 let db = TestDb::new().await;
173 let user = seed_user(&db.pool, "sbb_epoch").await;
174 let app = seed_billable_app(&db.pool, user, "sbbepoch").await;
175 activate_bulk(&db.pool, app, "sub_epoch", 1_700_000_000, 1_700_100_000).await;
176
177 // A thin/zero webhook: end <= 0. The row still matches (status unchanged),
178 // but the COALESCE must keep the existing period rather than stamp 1970.
179 let matched = synckit_billing::apply_billing_update(&db.pool, app, None, Some((0, 0)))
180 .await
181 .expect("apply update");
182 assert!(
183 matched,
184 "the live row still matches even with no writable fields"
185 );
186
187 let billing = synckit_billing::get_app_with_billing(&db.pool, app)
188 .await
189 .expect("load billing")
190 .expect("app exists");
191 assert_eq!(
192 billing.current_period_start,
193 Some(ts(1_700_000_000)),
194 "existing period start is preserved"
195 );
196 assert_eq!(
197 billing.current_period_end,
198 Some(ts(1_700_100_000)),
199 "a non-positive window never stamps a 1970 period"
200 );
201 }
202
203 #[tokio::test]
204 async fn apply_billing_update_ignores_an_inverted_period() {
205 let db = TestDb::new().await;
206 let user = seed_user(&db.pool, "sbb_inverted").await;
207 let app = seed_billable_app(&db.pool, user, "sbbinvert").await;
208 activate_bulk(&db.pool, app, "sub_invert", 1_700_000_000, 1_700_100_000).await;
209
210 // end > 0 but start > end: an inverted range writes nothing.
211 let matched = synckit_billing::apply_billing_update(
212 &db.pool,
213 app,
214 None,
215 Some((1_800_000_000, 1_700_000_000)),
216 )
217 .await
218 .expect("apply update");
219 assert!(matched);
220
221 let billing = synckit_billing::get_app_with_billing(&db.pool, app)
222 .await
223 .expect("load billing")
224 .expect("app exists");
225 assert_eq!(billing.current_period_start, Some(ts(1_700_000_000)));
226 assert_eq!(billing.current_period_end, Some(ts(1_700_100_000)));
227 }
228
229 // ── activate_billing: draft-only guard ───────────────────────────────────────
230
231 #[tokio::test]
232 async fn activate_billing_is_only_valid_from_draft() {
233 let db = TestDb::new().await;
234 let user = seed_user(&db.pool, "sbb_activate").await;
235 let app = seed_billable_app(&db.pool, user, "sbbactiv").await;
236
237 activate_bulk(&db.pool, app, "sub_first", 1_700_000_000, 1_700_100_000).await;
238
239 let billing = synckit_billing::get_app_with_billing(&db.pool, app)
240 .await
241 .expect("load billing")
242 .expect("app exists");
243 assert_eq!(billing.billing_status, SyncBillingStatus::Active);
244 assert_eq!(billing.stripe_subscription_id.as_deref(), Some("sub_first"));
245 assert_eq!(billing.storage_gb_cap, Some(100));
246
247 // A replay / TOCTOU race that reaches activation again against a now-active
248 // app must conflict, not silently orphan the live subscription.
249 let err = synckit_billing::activate_billing(
250 &db.pool,
251 app,
252 SyncEnforcementMode::Bulk,
253 Some(250),
254 None,
255 None,
256 "sub_second",
257 ts(1_700_500_000),
258 ts(1_700_600_000),
259 )
260 .await
261 .expect_err("re-activation of a non-draft app must fail");
262 assert!(
263 matches!(err, makenotwork::error::AppError::Conflict(_)),
264 "expected a Conflict, got {err:?}"
265 );
266
267 // The first subscription id is untouched.
268 let after = synckit_billing::get_app_with_billing(&db.pool, app)
269 .await
270 .expect("load billing")
271 .expect("app exists");
272 assert_eq!(after.stripe_subscription_id.as_deref(), Some("sub_first"));
273 assert_eq!(
274 after.storage_gb_cap,
275 Some(100),
276 "knobs are not overwritten by the refused activation"
277 );
278 }
279
280 // ── claim_key: cap enforcement under the FOR UPDATE lock ──────────────────────
281
282 #[tokio::test]
283 async fn claim_key_enforces_cap_atomically() {
284 let db = TestDb::new().await;
285 let user = seed_user(&db.pool, "sbb_cap").await;
286 let app = seed_billable_app(&db.pool, user, "sbbcap").await;
287
288 // key_cap = 2. Two distinct claims fill it; the third is refused.
289 let first = synckit_billing::claim_key(&db.pool, app, "k1", Some(2))
290 .await
291 .expect("claim k1");
292 assert!(first.newly_claimed);
293 assert!(!first.cap_reached);
294 assert_eq!(first.total_claimed, 1);
295
296 let second = synckit_billing::claim_key(&db.pool, app, "k2", Some(2))
297 .await
298 .expect("claim k2");
299 assert!(second.newly_claimed);
300 assert_eq!(second.total_claimed, 2);
301
302 let third = synckit_billing::claim_key(&db.pool, app, "k3", Some(2))
303 .await
304 .expect("claim k3");
305 assert!(!third.newly_claimed, "a new slot over the cap is refused");
306 assert!(third.cap_reached);
307 assert_eq!(third.total_claimed, 2, "the cap is not overshot");
308
309 // The refused claim inserted no row and left the counter at the cap.
310 assert_eq!(keys_claimed(&db.pool, app).await, 2);
311 assert_eq!(active_key_rows(&db.pool, app).await, 2);
312 }
313
314 #[tokio::test]
315 async fn claim_key_is_idempotent_and_admits_reclaims_at_cap() {
316 let db = TestDb::new().await;
317 let user = seed_user(&db.pool, "sbb_reclaim").await;
318 let app = seed_billable_app(&db.pool, user, "sbbreclaim").await;
319
320 // key_cap = 1, filled by k1.
321 let first = synckit_billing::claim_key(&db.pool, app, "k1", Some(1))
322 .await
323 .expect("claim k1");
324 assert!(first.newly_claimed);
325 assert_eq!(first.total_claimed, 1);
326
327 // Re-claiming the already-active key consumes no new slot: not newly
328 // claimed, and NOT reported as cap_reached (it's admitted).
329 let reclaim = synckit_billing::claim_key(&db.pool, app, "k1", Some(1))
330 .await
331 .expect("re-claim k1");
332 assert!(!reclaim.newly_claimed, "re-claim inserts nothing");
333 assert!(
334 !reclaim.cap_reached,
335 "an admitted re-claim is not a cap refusal"
336 );
337 assert_eq!(reclaim.total_claimed, 1);
338
339 // Exactly one active row and counter of 1, no double count.
340 assert_eq!(keys_claimed(&db.pool, app).await, 1);
341 assert_eq!(active_key_rows(&db.pool, app).await, 1);
342 }
343
344 // ── release_key: idempotency ─────────────────────────────────────────────────
345
346 #[tokio::test]
347 async fn release_key_is_idempotent() {
348 let db = TestDb::new().await;
349 let user = seed_user(&db.pool, "sbb_release").await;
350 let app = seed_billable_app(&db.pool, user, "sbbrelease").await;
351
352 synckit_billing::claim_key(&db.pool, app, "k1", Some(5))
353 .await
354 .expect("claim k1");
355
356 let first = synckit_billing::release_key(&db.pool, app, "k1")
357 .await
358 .expect("release k1");
359 assert!(first.newly_released);
360 assert_eq!(first.total_claimed, 0);
361
362 // Releasing an already-released key is a no-op, and the counter never goes
363 // negative.
364 let second = synckit_billing::release_key(&db.pool, app, "k1")
365 .await
366 .expect("re-release k1");
367 assert!(!second.newly_released, "no active row to release");
368 assert_eq!(second.total_claimed, 0);
369 assert_eq!(keys_claimed(&db.pool, app).await, 0);
370 assert_eq!(active_key_rows(&db.pool, app).await, 0);
371 }
372