Skip to main content

max / makenotwork

22.7 KB · 730 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 /// Keys-endpoint secret seeded alongside the api_key. A distinct value on
37 /// purpose: the api_key ships inside client binaries and must not authenticate
38 /// `/api/sync/keys/*`.
39 const APP_SECRET: &str = "test-app-secret-billing-integration";
40
41 /// Insert a draft (non-internal) sync app and return its id + plaintext api_key.
42 async fn create_draft_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) {
43 let api_key = "test-api-key-billing-integration";
44 let key_hash = crate::harness::hash_api_key(api_key);
45 let key_prefix = &api_key[..8];
46
47 let app_id: SyncAppId = sqlx::query_scalar(
48 r"
49 INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, keys_secret_hash, keys_secret_prefix, is_internal, billing_status)
50 VALUES ($1, 'BillingTest', $2, $3, $4, $5, FALSE, 'draft')
51 RETURNING id
52 ",
53 )
54 .bind(user_id)
55 .bind(&key_hash)
56 .bind(key_prefix)
57 .bind(crate::harness::hash_api_key(APP_SECRET))
58 .bind(&APP_SECRET[..8])
59 .fetch_one(pool)
60 .await
61 .expect("Failed to create draft sync app");
62
63 sqlx::query("INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT DO NOTHING")
64 .bind(app_id)
65 .execute(pool)
66 .await
67 .expect("Failed to seed usage row");
68
69 (app_id, api_key.to_string())
70 }
71
72 #[tokio::test]
73 async fn setup_creates_customer_and_returns_portal_url() {
74 let mut h = TestHarness::with_mocks().await;
75 let user_id = h.signup("dev1", "dev1@example.com", "Password1!").await;
76 let (app_id, _) = create_draft_app(&h.db, user_id).await;
77
78 let resp = h
79 .client
80 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
81 .await;
82 assert_eq!(resp.status, 200, "setup failed: {}", resp.text);
83
84 let body: BillingSetupResp = resp.json();
85 assert!(
86 !body.stripe_customer_id.is_empty(),
87 "expected a customer id"
88 );
89 assert!(
90 body.billing_portal_url.contains("billing.stripe"),
91 "got {}",
92 body.billing_portal_url
93 );
94
95 // The setup call should have persisted the customer id.
96 let persisted: Option<String> =
97 sqlx::query_scalar("SELECT stripe_customer_id FROM sync_apps WHERE id = $1")
98 .bind(app_id)
99 .fetch_one(&h.db)
100 .await
101 .unwrap();
102 assert!(persisted.is_some());
103 }
104
105 #[tokio::test]
106 async fn activate_then_get_reports_active_status_and_price() {
107 let mut h = TestHarness::with_mocks().await;
108 let user_id = h.signup("dev2", "dev2@example.com", "Password1!").await;
109 let (app_id, _) = create_draft_app(&h.db, user_id).await;
110
111 // setup
112 let resp = h
113 .client
114 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
115 .await;
116 assert_eq!(resp.status, 200);
117
118 // Activate in bulk mode at 100 GB. Price = 100 × $0.03 = $3.00.
119 let resp = h
120 .client
121 .post_json(
122 &format!("/api/sync/apps/{app_id}/billing/activate"),
123 &json!({
124 "enforcement_mode": "bulk",
125 "storage_gb_cap": 100
126 })
127 .to_string(),
128 )
129 .await;
130 assert_eq!(resp.status, 200, "activate failed: {}", resp.text);
131
132 let body: BillingUpdatedResp = resp.json();
133 assert_eq!(body.billing_status, "active");
134 assert_eq!(body.monthly_price_cents, 300, "100 GB bulk should be $3.00");
135 assert!(body.stripe_subscription_id.is_some());
136
137 // GET should agree
138 let resp = h
139 .client
140 .get(&format!("/api/sync/apps/{app_id}/billing"))
141 .await;
142 assert_eq!(resp.status, 200);
143 let status: BillingStatusResp = resp.json();
144 assert_eq!(status.billing_status, "active");
145 assert!(!status.is_internal);
146 assert_eq!(status.enforcement_mode, "bulk");
147 assert_eq!(status.storage_gb_cap, Some(100));
148 assert_eq!(status.key_cap, None);
149 assert_eq!(status.gb_per_key, None);
150 assert_eq!(status.monthly_price_cents, Some(300));
151 }
152
153 #[tokio::test]
154 async fn activate_per_key_mode() {
155 let mut h = TestHarness::with_mocks().await;
156 let user_id = h.signup("dev2pk", "dev2pk@example.com", "Password1!").await;
157 let (app_id, _) = create_draft_app(&h.db, user_id).await;
158 h.client
159 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
160 .await;
161
162 // 50 keys × 2 GB = 100 GB equivalent → $3.00.
163 let resp = h
164 .client
165 .post_json(
166 &format!("/api/sync/apps/{app_id}/billing/activate"),
167 &json!({
168 "enforcement_mode": "per_key",
169 "key_cap": 50,
170 "gb_per_key": 2
171 })
172 .to_string(),
173 )
174 .await;
175 assert_eq!(resp.status, 200, "activate failed: {}", resp.text);
176 let body: BillingUpdatedResp = resp.json();
177 assert_eq!(body.monthly_price_cents, 300);
178
179 let resp = h
180 .client
181 .get(&format!("/api/sync/apps/{app_id}/billing"))
182 .await;
183 let status: BillingStatusResp = resp.json();
184 assert_eq!(status.enforcement_mode, "per_key");
185 assert_eq!(status.storage_gb_cap, None);
186 assert_eq!(status.key_cap, Some(50));
187 assert_eq!(status.gb_per_key, Some(2));
188 }
189
190 #[tokio::test]
191 async fn patch_reprices_subscription() {
192 let mut h = TestHarness::with_mocks().await;
193 let user_id = h.signup("dev3", "dev3@example.com", "Password1!").await;
194 let (app_id, _) = create_draft_app(&h.db, user_id).await;
195
196 h.client
197 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
198 .await;
199 h.client
200 .post_json(
201 &format!("/api/sync/apps/{app_id}/billing/activate"),
202 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
203 )
204 .await;
205
206 // PATCH up to 1000 GB → $30.00.
207 let resp = h
208 .client
209 .patch_json(
210 &format!("/api/sync/apps/{app_id}/billing"),
211 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 1000 }).to_string(),
212 )
213 .await;
214 assert_eq!(resp.status, 200, "patch failed: {}", resp.text);
215 let body: BillingUpdatedResp = resp.json();
216 assert_eq!(body.monthly_price_cents, 3000);
217 }
218
219 #[tokio::test]
220 async fn cancel_returns_no_content_and_marks_canceled() {
221 let mut h = TestHarness::with_mocks().await;
222 let user_id = h.signup("dev4", "dev4@example.com", "Password1!").await;
223 let (app_id, _) = create_draft_app(&h.db, user_id).await;
224
225 h.client
226 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
227 .await;
228 h.client
229 .post_json(
230 &format!("/api/sync/apps/{app_id}/billing/activate"),
231 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
232 )
233 .await;
234
235 let resp = h
236 .client
237 .delete(&format!("/api/sync/apps/{app_id}/billing"))
238 .await;
239 assert_eq!(resp.status, 204, "cancel failed: {}", resp.text);
240
241 let status: String = sqlx::query_scalar("SELECT billing_status FROM sync_apps WHERE id = $1")
242 .bind(app_id)
243 .fetch_one(&h.db)
244 .await
245 .unwrap();
246 assert_eq!(status, "canceled");
247 }
248
249 #[tokio::test]
250 async fn activate_rejects_invalid_knobs() {
251 let mut h = TestHarness::with_mocks().await;
252 let user_id = h.signup("dev5", "dev5@example.com", "Password1!").await;
253 let (app_id, _) = create_draft_app(&h.db, user_id).await;
254 h.client
255 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
256 .await;
257
258 // per_key without key_cap → 400.
259 let resp = h
260 .client
261 .post_json(
262 &format!("/api/sync/apps/{app_id}/billing/activate"),
263 &json!({ "enforcement_mode": "per_key", "gb_per_key": 1 }).to_string(),
264 )
265 .await;
266 assert_eq!(
267 resp.status, 400,
268 "expected 400 for missing key_cap: {}",
269 resp.text
270 );
271
272 // per_key without gb_per_key → 400.
273 let resp = h
274 .client
275 .post_json(
276 &format!("/api/sync/apps/{app_id}/billing/activate"),
277 &json!({ "enforcement_mode": "per_key", "key_cap": 10 }).to_string(),
278 )
279 .await;
280 assert_eq!(
281 resp.status, 400,
282 "expected 400 for missing gb_per_key: {}",
283 resp.text
284 );
285
286 // bulk with storage_gb_cap = 0 → 400.
287 let resp = h
288 .client
289 .post_json(
290 &format!("/api/sync/apps/{app_id}/billing/activate"),
291 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 0 }).to_string(),
292 )
293 .await;
294 assert_eq!(
295 resp.status, 400,
296 "expected 400 for zero storage_gb_cap: {}",
297 resp.text
298 );
299
300 // bulk with extra knobs → 400.
301 let resp = h
302 .client
303 .post_json(
304 &format!("/api/sync/apps/{app_id}/billing/activate"),
305 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10, "key_cap": 5 }).to_string(),
306 )
307 .await;
308 assert_eq!(
309 resp.status, 400,
310 "expected 400 for mixing modes: {}",
311 resp.text
312 );
313 }
314
315 #[tokio::test]
316 async fn claim_key_blocked_when_billing_inactive() {
317 let mut h = TestHarness::with_mocks().await;
318 let user_id = h.signup("dev6", "dev6@example.com", "Password1!").await;
319 let (_, _api_key) = create_draft_app(&h.db, user_id).await;
320
321 // App is non-internal + draft → claim must return 402 billing_inactive.
322 let resp = h
323 .client
324 .post_json(
325 "/api/sync/keys/claim",
326 &json!({ "app_secret": APP_SECRET, "key": "dev-key-1" }).to_string(),
327 )
328 .await;
329 assert_eq!(
330 resp.status, 402,
331 "expected 402, got {}: {}",
332 resp.status, resp.text
333 );
334 assert!(
335 resp.text.contains("billing_inactive"),
336 "got body: {}",
337 resp.text
338 );
339 }
340
341 #[tokio::test]
342 async fn claim_key_blocked_at_cap_in_per_key_mode() {
343 let mut h = TestHarness::with_mocks().await;
344 let user_id = h.signup("dev7", "dev7@example.com", "Password1!").await;
345 let (app_id, _api_key) = create_draft_app(&h.db, user_id).await;
346
347 // Activate in per_key mode with a tiny cap so we can exhaust it cheaply.
348 h.client
349 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
350 .await;
351 let resp = h
352 .client
353 .post_json(
354 &format!("/api/sync/apps/{app_id}/billing/activate"),
355 &json!({
356 "enforcement_mode": "per_key",
357 "key_cap": 2,
358 "gb_per_key": 1
359 })
360 .to_string(),
361 )
362 .await;
363 assert_eq!(resp.status, 200, "activate failed: {}", resp.text);
364
365 // First two claims succeed.
366 for k in &["k1", "k2"] {
367 let resp = h
368 .client
369 .post_json(
370 "/api/sync/keys/claim",
371 &json!({ "app_secret": APP_SECRET, "key": k }).to_string(),
372 )
373 .await;
374 assert_eq!(resp.status, 200, "claim {} failed: {}", k, resp.text);
375 }
376
377 // Third claim hits the cap → 402.
378 let resp = h
379 .client
380 .post_json(
381 "/api/sync/keys/claim",
382 &json!({ "app_secret": APP_SECRET, "key": "k3" }).to_string(),
383 )
384 .await;
385 assert_eq!(
386 resp.status, 402,
387 "expected 402, got {}: {}",
388 resp.status, resp.text
389 );
390 assert!(
391 resp.text.contains("key_limit_reached"),
392 "got body: {}",
393 resp.text
394 );
395
396 // Re-claiming an already-active key is idempotent (does not consume a slot).
397 let resp = h
398 .client
399 .post_json(
400 "/api/sync/keys/claim",
401 &json!({ "app_secret": APP_SECRET, "key": "k1" }).to_string(),
402 )
403 .await;
404 assert_eq!(resp.status, 200, "re-claim should succeed: {}", resp.text);
405 }
406
407 // ── Edge cases (test-fuzz) ──
408
409 #[tokio::test]
410 async fn cannot_activate_after_cancel() {
411 let mut h = TestHarness::with_mocks().await;
412 let user_id = h
413 .signup("cancel1", "cancel1@example.com", "Password1!")
414 .await;
415 let (app_id, _) = create_draft_app(&h.db, user_id).await;
416
417 h.client
418 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
419 .await;
420 h.client
421 .post_json(
422 &format!("/api/sync/apps/{app_id}/billing/activate"),
423 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
424 )
425 .await;
426
427 let resp = h
428 .client
429 .delete(&format!("/api/sync/apps/{app_id}/billing"))
430 .await;
431 assert_eq!(resp.status, 204);
432
433 // Activate now requires draft status, canceled apps cannot be reactivated.
434 let resp = h
435 .client
436 .post_json(
437 &format!("/api/sync/apps/{app_id}/billing/activate"),
438 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
439 )
440 .await;
441 assert_eq!(
442 resp.status, 409,
443 "expected 409 conflict on re-activate after cancel: {}",
444 resp.text
445 );
446 }
447
448 #[tokio::test]
449 async fn cancel_is_idempotent() {
450 let mut h = TestHarness::with_mocks().await;
451 let user_id = h.signup("can2", "can2@example.com", "Password1!").await;
452 let (app_id, _) = create_draft_app(&h.db, user_id).await;
453 h.client
454 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
455 .await;
456 h.client
457 .post_json(
458 &format!("/api/sync/apps/{app_id}/billing/activate"),
459 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10 }).to_string(),
460 )
461 .await;
462
463 let r1 = h
464 .client
465 .delete(&format!("/api/sync/apps/{app_id}/billing"))
466 .await;
467 assert_eq!(r1.status, 204);
468 let r2 = h
469 .client
470 .delete(&format!("/api/sync/apps/{app_id}/billing"))
471 .await;
472 assert_eq!(
473 r2.status, 204,
474 "second cancel must also be 204, got {}",
475 r2.status
476 );
477 }
478
479 #[tokio::test]
480 async fn patch_switches_mode_bulk_to_per_key() {
481 // Activating in bulk and then PATCHing to per_key must succeed and update
482 // the columns coherently (bulk knob cleared, per_key knobs set).
483 let mut h = TestHarness::with_mocks().await;
484 let user_id = h.signup("mode1", "mode1@example.com", "Password1!").await;
485 let (app_id, _) = create_draft_app(&h.db, user_id).await;
486 h.client
487 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
488 .await;
489 h.client
490 .post_json(
491 &format!("/api/sync/apps/{app_id}/billing/activate"),
492 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 100 }).to_string(),
493 )
494 .await;
495
496 let resp = h
497 .client
498 .patch_json(
499 &format!("/api/sync/apps/{app_id}/billing"),
500 &json!({ "enforcement_mode": "per_key", "key_cap": 5, "gb_per_key": 20 }).to_string(),
501 )
502 .await;
503 assert_eq!(resp.status, 200, "mode switch failed: {}", resp.text);
504 let body: BillingUpdatedResp = resp.json();
505 // 5 × 20 = 100 GB equivalent → 300 cents, same as the bulk price before.
506 assert_eq!(body.monthly_price_cents, 300);
507
508 // Verify the row reflects the switch: bulk knob cleared, per_key knobs set.
509 let resp = h
510 .client
511 .get(&format!("/api/sync/apps/{app_id}/billing"))
512 .await;
513 let status: BillingStatusResp = resp.json();
514 assert_eq!(status.enforcement_mode, "per_key");
515 assert_eq!(
516 status.storage_gb_cap, None,
517 "bulk knob should be cleared on mode switch"
518 );
519 assert_eq!(status.key_cap, Some(5));
520 assert_eq!(status.gb_per_key, Some(20));
521 }
522
523 #[tokio::test]
524 async fn setup_rejects_non_draft_app() {
525 // Once activated, calling setup again must 409 (not silently re-mint
526 // a customer).
527 let mut h = TestHarness::with_mocks().await;
528 let user_id = h.signup("setup2", "setup2@example.com", "Password1!").await;
529 let (app_id, _) = create_draft_app(&h.db, user_id).await;
530 h.client
531 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
532 .await;
533 h.client
534 .post_json(
535 &format!("/api/sync/apps/{app_id}/billing/activate"),
536 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": 10 }).to_string(),
537 )
538 .await;
539
540 let resp = h
541 .client
542 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
543 .await;
544 assert_eq!(
545 resp.status, 409,
546 "second setup on active app must 409, got {}: {}",
547 resp.status, resp.text
548 );
549 }
550
551 #[tokio::test]
552 async fn other_users_app_billing_rejected() {
553 // Cross-tenant: dev A cannot inspect or mutate dev B's app billing.
554 let mut h = TestHarness::with_mocks().await;
555 let owner = h.signup("owner1", "owner1@example.com", "Password1!").await;
556 let (app_id, _) = create_draft_app(&h.db, owner).await;
557
558 // Switch session to a second user.
559 let _other = h.signup("other1", "other1@example.com", "Password1!").await;
560 // signup auto-logs-in the new user; the previous session is replaced.
561
562 let r = h
563 .client
564 .get(&format!("/api/sync/apps/{app_id}/billing"))
565 .await;
566 assert_eq!(
567 r.status, 403,
568 "expected 403 forbidden cross-tenant: {}",
569 r.text
570 );
571
572 let r = h
573 .client
574 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
575 .await;
576 assert_eq!(r.status, 403);
577
578 let r = h
579 .client
580 .delete(&format!("/api/sync/apps/{app_id}/billing"))
581 .await;
582 assert_eq!(r.status, 403);
583 }
584
585 /// A canceled SyncKit app must not be reactivated by an out-of-order
586 /// `customer.subscription.updated`(active). The terminal guard lives in
587 /// `set_billing_status`.
588 #[tokio::test]
589 async fn webhook_subscription_updated_cannot_revive_canceled_synckit_billing() {
590 let mut h = TestHarness::with_mocks().await;
591 let user_id = h
592 .signup("sktrevive", "sktrevive@test.com", "password123")
593 .await;
594 let (app_id, _) = create_draft_app(&h.db, user_id).await;
595
596 let stripe_sub_id = "sub_skt_revive_1";
597 sqlx::query("UPDATE sync_apps SET stripe_subscription_id = $1, billing_status = 'canceled' WHERE id = $2")
598 .bind(stripe_sub_id)
599 .bind(app_id)
600 .execute(&h.db)
601 .await
602 .unwrap();
603
604 let sub = json!({
605 "id": stripe_sub_id,
606 "object": "subscription",
607 "status": "active",
608 "cancel_at_period_end": false,
609 "items": {"object": "list", "data": [{
610 "id": "si_skt_revive",
611 "current_period_start": 1_700_000_000_i64,
612 "current_period_end": 1_702_592_000_i64,
613 }]},
614 });
615 let payload = json!({
616 "id": "evt_skt_revive",
617 "type": "customer.subscription.updated",
618 "data": {"object": sub},
619 })
620 .to_string();
621 let sig = crate::harness::stripe::sign_webhook_payload(
622 &payload,
623 crate::harness::stripe::TEST_WEBHOOK_SECRET,
624 );
625 let resp = h
626 .client
627 .request_with_headers(
628 "POST",
629 "/stripe/webhook",
630 Some(&payload),
631 &[
632 ("stripe-signature", &sig),
633 ("content-type", "application/json"),
634 ],
635 )
636 .await;
637 assert_eq!(
638 resp.status.as_u16(),
639 200,
640 "webhook must not error: {}",
641 resp.text
642 );
643
644 let status: String = sqlx::query_scalar("SELECT billing_status FROM sync_apps WHERE id = $1")
645 .bind(app_id)
646 .fetch_one(&h.db)
647 .await
648 .unwrap();
649 assert_eq!(
650 status, "canceled",
651 "a canceled SyncKit app must not be reactivated by an out-of-order update"
652 );
653 }
654
655 /// A canceled SyncKit app's PERIOD (and usage) must not be refreshed by a stray
656 /// `invoice.payment_succeeded`. The period write used to live in an unguarded
657 /// `set_period`; it now shares `apply_billing_update`'s terminal-canceled guard,
658 /// and the usage reset is gated on that write succeeding.
659 #[tokio::test]
660 async fn webhook_invoice_paid_cannot_refresh_period_on_canceled_synckit_app() {
661 let mut h = TestHarness::with_mocks().await;
662 let user_id = h
663 .signup("sktperiod", "sktperiod@test.com", "password123")
664 .await;
665 let (app_id, _) = create_draft_app(&h.db, user_id).await;
666
667 let stripe_sub_id = "sub_skt_period_1";
668 sqlx::query(
669 "UPDATE sync_apps SET stripe_subscription_id = $1, billing_status = 'canceled', \
670 current_period_end = to_timestamp(1_000_000_000) WHERE id = $2",
671 )
672 .bind(stripe_sub_id)
673 .bind(app_id)
674 .execute(&h.db)
675 .await
676 .unwrap();
677
678 let invoice = json!({
679 "id": "in_skt_period",
680 "object": "invoice",
681 "subscription": stripe_sub_id,
682 "billing_reason": "subscription_cycle",
683 "period_start": 1_700_000_000_i64,
684 "period_end": 1_702_592_000_i64,
685 "currency": "usd",
686 "livemode": false,
687 });
688 let payload = json!({
689 "id": "evt_skt_period",
690 "type": "invoice.payment_succeeded",
691 "data": {"object": invoice},
692 })
693 .to_string();
694 let sig = crate::harness::stripe::sign_webhook_payload(
695 &payload,
696 crate::harness::stripe::TEST_WEBHOOK_SECRET,
697 );
698 let resp = h
699 .client
700 .request_with_headers(
701 "POST",
702 "/stripe/webhook",
703 Some(&payload),
704 &[
705 ("stripe-signature", &sig),
706 ("content-type", "application/json"),
707 ],
708 )
709 .await;
710 assert_eq!(
711 resp.status.as_u16(),
712 200,
713 "webhook must not error: {}",
714 resp.text
715 );
716
717 let (status, period_end): (String, chrono::DateTime<chrono::Utc>) =
718 sqlx::query_as("SELECT billing_status, current_period_end FROM sync_apps WHERE id = $1")
719 .bind(app_id)
720 .fetch_one(&h.db)
721 .await
722 .unwrap();
723 assert_eq!(status, "canceled", "canceled app must stay canceled");
724 assert_eq!(
725 period_end.timestamp(),
726 1_000_000_000,
727 "period must stay frozen on a canceled app, not jump to the invoice's period_end"
728 );
729 }
730