Skip to main content

max / makenotwork

20.4 KB · 527 lines History Blame Raw
1 //! Integration tests for SyncKit v2 developer billing.
2 //!
3 //! Exercises the full lifecycle against a real Postgres test DB and a mock
4 //! Stripe provider: setup → activate → patch → cancel, plus the
5 //! key-claim cap enforcement on the server-to-server endpoint.
6
7 use crate::harness::TestHarness;
8 use makenotwork::db::{SyncAppId, UserId};
9 use serde::Deserialize;
10 use serde_json::json;
11 use sqlx::PgPool;
12
13 #[derive(Deserialize)]
14 struct BillingSetupResp {
15 stripe_customer_id: String,
16 billing_portal_url: String,
17 }
18
19 #[derive(Deserialize)]
20 struct BillingUpdatedResp {
21 monthly_price_cents: i64,
22 billing_status: String,
23 stripe_subscription_id: Option<String>,
24 }
25
26 #[derive(Deserialize)]
27 struct BillingStatusResp {
28 billing_status: String,
29 is_internal: bool,
30 enforcement_mode: String,
31 storage_gb_cap: Option<u32>,
32 key_cap: Option<u32>,
33 gb_per_key: Option<u32>,
34 monthly_price_cents: Option<i64>,
35 }
36
37 /// Insert a draft (non-internal) sync app and return its id + plaintext api_key.
38 async fn create_draft_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) {
39 let api_key = "test-api-key-billing-integration";
40 let key_hash = crate::harness::hash_api_key(api_key);
41 let key_prefix = &api_key[..8];
42
43 let app_id: SyncAppId = sqlx::query_scalar(
44 r#"
45 INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status)
46 VALUES ($1, 'BillingTest', $2, $3, FALSE, 'draft')
47 RETURNING id
48 "#,
49 )
50 .bind(user_id)
51 .bind(&key_hash)
52 .bind(key_prefix)
53 .fetch_one(pool)
54 .await
55 .expect("Failed to create draft sync app");
56
57 sqlx::query("INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT DO NOTHING")
58 .bind(app_id)
59 .execute(pool)
60 .await
61 .expect("Failed to seed usage row");
62
63 (app_id, api_key.to_string())
64 }
65
66 #[tokio::test]
67 async fn setup_creates_customer_and_returns_portal_url() {
68 let mut h = TestHarness::with_mocks().await;
69 let user_id = h.signup("dev1", "dev1@example.com", "Password1!").await;
70 let (app_id, _) = create_draft_app(&h.db, user_id).await;
71
72 let resp = h.client.post_json(
73 &format!("/api/sync/apps/{}/billing/setup", app_id),
74 "",
75 ).await;
76 assert_eq!(resp.status, 200, "setup failed: {}", resp.text);
77
78 let body: BillingSetupResp = resp.json();
79 assert!(!body.stripe_customer_id.is_empty(), "expected a customer id");
80 assert!(body.billing_portal_url.contains("billing.stripe"), "got {}", body.billing_portal_url);
81
82 // The setup call should have persisted the customer id.
83 let persisted: Option<String> = sqlx::query_scalar(
84 "SELECT stripe_customer_id FROM sync_apps WHERE id = $1",
85 )
86 .bind(app_id)
87 .fetch_one(&h.db)
88 .await
89 .unwrap();
90 assert!(persisted.is_some());
91 }
92
93 #[tokio::test]
94 async fn activate_then_get_reports_active_status_and_price() {
95 let mut h = TestHarness::with_mocks().await;
96 let user_id = h.signup("dev2", "dev2@example.com", "Password1!").await;
97 let (app_id, _) = create_draft_app(&h.db, user_id).await;
98
99 // setup
100 let resp = h.client.post_json(
101 &format!("/api/sync/apps/{}/billing/setup", app_id),
102 "",
103 ).await;
104 assert_eq!(resp.status, 200);
105
106 // Activate in bulk mode at 100 GB. Price = 100 × $0.03 = $3.00.
107 let resp = h.client.post_json(
108 &format!("/api/sync/apps/{}/billing/activate", app_id),
109 &json!({
110 "enforcement_mode": "bulk",
111 "storage_gb_cap": 100
112 }).to_string(),
113 ).await;
114 assert_eq!(resp.status, 200, "activate failed: {}", resp.text);
115
116 let body: BillingUpdatedResp = resp.json();
117 assert_eq!(body.billing_status, "active");
118 assert_eq!(body.monthly_price_cents, 300, "100 GB bulk should be $3.00");
119 assert!(body.stripe_subscription_id.is_some());
120
121 // GET should agree
122 let resp = h.client.get(&format!("/api/sync/apps/{}/billing", app_id)).await;
123 assert_eq!(resp.status, 200);
124 let status: BillingStatusResp = resp.json();
125 assert_eq!(status.billing_status, "active");
126 assert!(!status.is_internal);
127 assert_eq!(status.enforcement_mode, "bulk");
128 assert_eq!(status.storage_gb_cap, Some(100));
129 assert_eq!(status.key_cap, None);
130 assert_eq!(status.gb_per_key, None);
131 assert_eq!(status.monthly_price_cents, Some(300));
132 }
133
134 #[tokio::test]
135 async fn activate_per_key_mode() {
136 let mut h = TestHarness::with_mocks().await;
137 let user_id = h.signup("dev2pk", "dev2pk@example.com", "Password1!").await;
138 let (app_id, _) = create_draft_app(&h.db, user_id).await;
139 h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
140
141 // 50 keys × 2 GB = 100 GB equivalent → $3.00.
142 let resp = h.client.post_json(
143 &format!("/api/sync/apps/{}/billing/activate", app_id),
144 &json!({
145 "enforcement_mode": "per_key",
146 "key_cap": 50,
147 "gb_per_key": 2
148 }).to_string(),
149 ).await;
150 assert_eq!(resp.status, 200, "activate failed: {}", resp.text);
151 let body: BillingUpdatedResp = resp.json();
152 assert_eq!(body.monthly_price_cents, 300);
153
154 let resp = h.client.get(&format!("/api/sync/apps/{}/billing", app_id)).await;
155 let status: BillingStatusResp = resp.json();
156 assert_eq!(status.enforcement_mode, "per_key");
157 assert_eq!(status.storage_gb_cap, None);
158 assert_eq!(status.key_cap, Some(50));
159 assert_eq!(status.gb_per_key, Some(2));
160 }
161
162 #[tokio::test]
163 async fn patch_reprices_subscription() {
164 let mut h = TestHarness::with_mocks().await;
165 let user_id = h.signup("dev3", "dev3@example.com", "Password1!").await;
166 let (app_id, _) = create_draft_app(&h.db, user_id).await;
167
168 h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
169 h.client.post_json(
170 &format!("/api/sync/apps/{}/billing/activate", app_id),
171 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
172 ).await;
173
174 // PATCH up to 1000 GB → $30.00.
175 let resp = h.client.patch_json(
176 &format!("/api/sync/apps/{}/billing", app_id),
177 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 1000 }).to_string(),
178 ).await;
179 assert_eq!(resp.status, 200, "patch failed: {}", resp.text);
180 let body: BillingUpdatedResp = resp.json();
181 assert_eq!(body.monthly_price_cents, 3000);
182 }
183
184 #[tokio::test]
185 async fn cancel_returns_no_content_and_marks_canceled() {
186 let mut h = TestHarness::with_mocks().await;
187 let user_id = h.signup("dev4", "dev4@example.com", "Password1!").await;
188 let (app_id, _) = create_draft_app(&h.db, user_id).await;
189
190 h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
191 h.client.post_json(
192 &format!("/api/sync/apps/{}/billing/activate", app_id),
193 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
194 ).await;
195
196 let resp = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await;
197 assert_eq!(resp.status, 204, "cancel failed: {}", resp.text);
198
199 let status: String = sqlx::query_scalar(
200 "SELECT billing_status FROM sync_apps WHERE id = $1",
201 )
202 .bind(app_id)
203 .fetch_one(&h.db)
204 .await
205 .unwrap();
206 assert_eq!(status, "canceled");
207 }
208
209 #[tokio::test]
210 async fn activate_rejects_invalid_knobs() {
211 let mut h = TestHarness::with_mocks().await;
212 let user_id = h.signup("dev5", "dev5@example.com", "Password1!").await;
213 let (app_id, _) = create_draft_app(&h.db, user_id).await;
214 h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
215
216 // per_key without key_cap → 400.
217 let resp = h.client.post_json(
218 &format!("/api/sync/apps/{}/billing/activate", app_id),
219 &json!({ "enforcement_mode": "per_key", "gb_per_key": 1 }).to_string(),
220 ).await;
221 assert_eq!(resp.status, 400, "expected 400 for missing key_cap: {}", resp.text);
222
223 // per_key without gb_per_key → 400.
224 let resp = h.client.post_json(
225 &format!("/api/sync/apps/{}/billing/activate", app_id),
226 &json!({ "enforcement_mode": "per_key", "key_cap": 10 }).to_string(),
227 ).await;
228 assert_eq!(resp.status, 400, "expected 400 for missing gb_per_key: {}", resp.text);
229
230 // bulk with storage_gb_cap = 0 → 400.
231 let resp = h.client.post_json(
232 &format!("/api/sync/apps/{}/billing/activate", app_id),
233 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 0 }).to_string(),
234 ).await;
235 assert_eq!(resp.status, 400, "expected 400 for zero storage_gb_cap: {}", resp.text);
236
237 // bulk with extra knobs → 400.
238 let resp = h.client.post_json(
239 &format!("/api/sync/apps/{}/billing/activate", app_id),
240 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10, "key_cap": 5 }).to_string(),
241 ).await;
242 assert_eq!(resp.status, 400, "expected 400 for mixing modes: {}", resp.text);
243 }
244
245 #[tokio::test]
246 async fn claim_key_blocked_when_billing_inactive() {
247 let mut h = TestHarness::with_mocks().await;
248 let user_id = h.signup("dev6", "dev6@example.com", "Password1!").await;
249 let (_, api_key) = create_draft_app(&h.db, user_id).await;
250
251 // App is non-internal + draft → claim must return 402 billing_inactive.
252 let resp = h.client.post_json(
253 "/api/sync/keys/claim",
254 &json!({ "api_key": api_key, "key": "dev-key-1" }).to_string(),
255 ).await;
256 assert_eq!(resp.status, 402, "expected 402, got {}: {}", resp.status, resp.text);
257 assert!(resp.text.contains("billing_inactive"), "got body: {}", resp.text);
258 }
259
260 #[tokio::test]
261 async fn claim_key_blocked_at_cap_in_per_key_mode() {
262 let mut h = TestHarness::with_mocks().await;
263 let user_id = h.signup("dev7", "dev7@example.com", "Password1!").await;
264 let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
265
266 // Activate in per_key mode with a tiny cap so we can exhaust it cheaply.
267 h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
268 let resp = h.client.post_json(
269 &format!("/api/sync/apps/{}/billing/activate", app_id),
270 &json!({
271 "enforcement_mode": "per_key",
272 "key_cap": 2,
273 "gb_per_key": 1
274 }).to_string(),
275 ).await;
276 assert_eq!(resp.status, 200, "activate failed: {}", resp.text);
277
278 // First two claims succeed.
279 for k in &["k1", "k2"] {
280 let resp = h.client.post_json(
281 "/api/sync/keys/claim",
282 &json!({ "api_key": api_key, "key": k }).to_string(),
283 ).await;
284 assert_eq!(resp.status, 200, "claim {} failed: {}", k, resp.text);
285 }
286
287 // Third claim hits the cap → 402.
288 let resp = h.client.post_json(
289 "/api/sync/keys/claim",
290 &json!({ "api_key": api_key, "key": "k3" }).to_string(),
291 ).await;
292 assert_eq!(resp.status, 402, "expected 402, got {}: {}", resp.status, resp.text);
293 assert!(resp.text.contains("key_limit_reached"), "got body: {}", resp.text);
294
295 // Re-claiming an already-active key is idempotent (does not consume a slot).
296 let resp = h.client.post_json(
297 "/api/sync/keys/claim",
298 &json!({ "api_key": api_key, "key": "k1" }).to_string(),
299 ).await;
300 assert_eq!(resp.status, 200, "re-claim should succeed: {}", resp.text);
301 }
302
303 // ── Edge cases (test-fuzz) ──
304
305 #[tokio::test]
306 async fn cannot_activate_after_cancel() {
307 let mut h = TestHarness::with_mocks().await;
308 let user_id = h.signup("cancel1", "cancel1@example.com", "Password1!").await;
309 let (app_id, _) = create_draft_app(&h.db, user_id).await;
310
311 h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
312 h.client.post_json(
313 &format!("/api/sync/apps/{}/billing/activate", app_id),
314 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
315 ).await;
316
317 let resp = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await;
318 assert_eq!(resp.status, 204);
319
320 // Activate now requires draft status — canceled apps cannot be reactivated.
321 let resp = h.client.post_json(
322 &format!("/api/sync/apps/{}/billing/activate", app_id),
323 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
324 ).await;
325 assert_eq!(resp.status, 409, "expected 409 conflict on re-activate after cancel: {}", resp.text);
326 }
327
328 #[tokio::test]
329 async fn cancel_is_idempotent() {
330 let mut h = TestHarness::with_mocks().await;
331 let user_id = h.signup("can2", "can2@example.com", "Password1!").await;
332 let (app_id, _) = create_draft_app(&h.db, user_id).await;
333 h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
334 h.client.post_json(
335 &format!("/api/sync/apps/{}/billing/activate", app_id),
336 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10 }).to_string(),
337 ).await;
338
339 let r1 = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await;
340 assert_eq!(r1.status, 204);
341 let r2 = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await;
342 assert_eq!(r2.status, 204, "second cancel must also be 204, got {}", r2.status);
343 }
344
345 #[tokio::test]
346 async fn patch_switches_mode_bulk_to_per_key() {
347 // Activating in bulk and then PATCHing to per_key must succeed and update
348 // the columns coherently (bulk knob cleared, per_key knobs set).
349 let mut h = TestHarness::with_mocks().await;
350 let user_id = h.signup("mode1", "mode1@example.com", "Password1!").await;
351 let (app_id, _) = create_draft_app(&h.db, user_id).await;
352 h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
353 h.client.post_json(
354 &format!("/api/sync/apps/{}/billing/activate", app_id),
355 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
356 ).await;
357
358 let resp = h.client.patch_json(
359 &format!("/api/sync/apps/{}/billing", app_id),
360 &json!({ "enforcement_mode": "per_key", "key_cap": 5, "gb_per_key": 20 }).to_string(),
361 ).await;
362 assert_eq!(resp.status, 200, "mode switch failed: {}", resp.text);
363 let body: BillingUpdatedResp = resp.json();
364 // 5 × 20 = 100 GB equivalent → 300 cents, same as the bulk price before.
365 assert_eq!(body.monthly_price_cents, 300);
366
367 // Verify the row reflects the switch: bulk knob cleared, per_key knobs set.
368 let resp = h.client.get(&format!("/api/sync/apps/{}/billing", app_id)).await;
369 let status: BillingStatusResp = resp.json();
370 assert_eq!(status.enforcement_mode, "per_key");
371 assert_eq!(status.storage_gb_cap, None, "bulk knob should be cleared on mode switch");
372 assert_eq!(status.key_cap, Some(5));
373 assert_eq!(status.gb_per_key, Some(20));
374 }
375
376 #[tokio::test]
377 async fn setup_rejects_non_draft_app() {
378 // Once activated, calling setup again must 409 (not silently re-mint
379 // a customer).
380 let mut h = TestHarness::with_mocks().await;
381 let user_id = h.signup("setup2", "setup2@example.com", "Password1!").await;
382 let (app_id, _) = create_draft_app(&h.db, user_id).await;
383 h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
384 h.client.post_json(
385 &format!("/api/sync/apps/{}/billing/activate", app_id),
386 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10 }).to_string(),
387 ).await;
388
389 let resp = h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
390 assert_eq!(resp.status, 409, "second setup on active app must 409, got {}: {}", resp.status, resp.text);
391 }
392
393 #[tokio::test]
394 async fn other_users_app_billing_rejected() {
395 // Cross-tenant: dev A cannot inspect or mutate dev B's app billing.
396 let mut h = TestHarness::with_mocks().await;
397 let owner = h.signup("owner1", "owner1@example.com", "Password1!").await;
398 let (app_id, _) = create_draft_app(&h.db, owner).await;
399
400 // Switch session to a second user.
401 let _other = h.signup("other1", "other1@example.com", "Password1!").await;
402 // signup auto-logs-in the new user; the previous session is replaced.
403
404 let r = h.client.get(&format!("/api/sync/apps/{}/billing", app_id)).await;
405 assert_eq!(r.status, 403, "expected 403 forbidden cross-tenant: {}", r.text);
406
407 let r = h.client.post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "").await;
408 assert_eq!(r.status, 403);
409
410 let r = h.client.delete(&format!("/api/sync/apps/{}/billing", app_id)).await;
411 assert_eq!(r.status, 403);
412 }
413
414 /// A canceled SyncKit app must not be reactivated by an out-of-order
415 /// `customer.subscription.updated`(active) — the second Run #12 SERIOUS revival
416 /// path. The terminal guard now lives in `set_billing_status`.
417 #[tokio::test]
418 async fn webhook_subscription_updated_cannot_revive_canceled_synckit_billing() {
419 let mut h = TestHarness::with_mocks().await;
420 let user_id = h.signup("sktrevive", "sktrevive@test.com", "password123").await;
421 let (app_id, _) = create_draft_app(&h.db, user_id).await;
422
423 let stripe_sub_id = "sub_skt_revive_1";
424 sqlx::query("UPDATE sync_apps SET stripe_subscription_id = $1, billing_status = 'canceled' WHERE id = $2")
425 .bind(stripe_sub_id)
426 .bind(app_id)
427 .execute(&h.db)
428 .await
429 .unwrap();
430
431 let sub = json!({
432 "id": stripe_sub_id,
433 "object": "subscription",
434 "status": "active",
435 "cancel_at_period_end": false,
436 "items": {"object": "list", "data": [{
437 "id": "si_skt_revive",
438 "current_period_start": 1700000000_i64,
439 "current_period_end": 1702592000_i64,
440 }]},
441 });
442 let payload = json!({
443 "id": "evt_skt_revive",
444 "type": "customer.subscription.updated",
445 "data": {"object": sub},
446 })
447 .to_string();
448 let sig = crate::harness::stripe::sign_webhook_payload(&payload, crate::harness::stripe::TEST_WEBHOOK_SECRET);
449 let resp = h
450 .client
451 .request_with_headers(
452 "POST",
453 "/stripe/webhook",
454 Some(&payload),
455 &[("stripe-signature", &sig), ("content-type", "application/json")],
456 )
457 .await;
458 assert_eq!(resp.status.as_u16(), 200, "webhook must not error: {}", resp.text);
459
460 let status: String = sqlx::query_scalar("SELECT billing_status FROM sync_apps WHERE id = $1")
461 .bind(app_id)
462 .fetch_one(&h.db)
463 .await
464 .unwrap();
465 assert_eq!(status, "canceled", "a canceled SyncKit app must not be reactivated by an out-of-order update");
466 }
467
468 /// A canceled SyncKit app's PERIOD (and usage) must not be refreshed by a stray
469 /// `invoice.payment_succeeded`. The period write used to live in an unguarded
470 /// `set_period`; it now shares `apply_billing_update`'s terminal-canceled guard,
471 /// and the usage reset is gated on that write succeeding. (Run #13 chronic fix.)
472 #[tokio::test]
473 async fn webhook_invoice_paid_cannot_refresh_period_on_canceled_synckit_app() {
474 let mut h = TestHarness::with_mocks().await;
475 let user_id = h.signup("sktperiod", "sktperiod@test.com", "password123").await;
476 let (app_id, _) = create_draft_app(&h.db, user_id).await;
477
478 let stripe_sub_id = "sub_skt_period_1";
479 sqlx::query(
480 "UPDATE sync_apps SET stripe_subscription_id = $1, billing_status = 'canceled', \
481 current_period_end = to_timestamp(1000000000) WHERE id = $2",
482 )
483 .bind(stripe_sub_id)
484 .bind(app_id)
485 .execute(&h.db)
486 .await
487 .unwrap();
488
489 let invoice = json!({
490 "id": "in_skt_period",
491 "object": "invoice",
492 "subscription": stripe_sub_id,
493 "billing_reason": "subscription_cycle",
494 "period_start": 1700000000_i64,
495 "period_end": 1702592000_i64,
496 "currency": "usd",
497 "livemode": false,
498 });
499 let payload = json!({
500 "id": "evt_skt_period",
501 "type": "invoice.payment_succeeded",
502 "data": {"object": invoice},
503 })
504 .to_string();
505 let sig = crate::harness::stripe::sign_webhook_payload(&payload, crate::harness::stripe::TEST_WEBHOOK_SECRET);
506 let resp = h
507 .client
508 .request_with_headers(
509 "POST",
510 "/stripe/webhook",
511 Some(&payload),
512 &[("stripe-signature", &sig), ("content-type", "application/json")],
513 )
514 .await;
515 assert_eq!(resp.status.as_u16(), 200, "webhook must not error: {}", resp.text);
516
517 let (status, period_end): (String, chrono::DateTime<chrono::Utc>) = sqlx::query_as(
518 "SELECT billing_status, current_period_end FROM sync_apps WHERE id = $1",
519 )
520 .bind(app_id)
521 .fetch_one(&h.db)
522 .await
523 .unwrap();
524 assert_eq!(status, "canceled", "canceled app must stay canceled");
525 assert_eq!(period_end.timestamp(), 1000000000, "period must stay frozen on a canceled app, not jump to the invoice's period_end");
526 }
527