Skip to main content

max / makenotwork

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