Skip to main content

max / makenotwork

15.9 KB · 456 lines History Blame Raw
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 }
456