Skip to main content

max / makenotwork

17.7 KB · 497 lines History Blame Raw
1 //! The demo buyer: one login-capable account with a purchase history, so
2 //! `/library` can be photographed.
3 //!
4 //! `/library` is 401 to anonymous and every other seeded account is a creator
5 //! with nothing bought, so a third of the pitch (buyers keep what they bought,
6 //! one-click export) could not be shown to anyone. This account exists to close
7 //! that, and nothing more.
8 //!
9 //! # Scope
10 //!
11 //! This is a capture-only credential used by the screenshot run and nobody else.
12 //! A visitor-facing temp-account path on testnot is separate work (Phase 8-9 of
13 //! `_private/docs/mnw/testnot-example-seed.md`), and the boundary matters:
14 //! **nothing on the public demo changes**. No login CTA, no temp-account
15 //! endpoint, no `ALLOW_TEMP_ACCOUNTS`, and the account is linked from nowhere.
16 //! An anonymous visitor to testnot still cannot reach `/library`. That is a
17 //! known, deliberate gap, and the temp-account path is what closes it.
18 //!
19 //! # Why it is opt-in, and why it lives in the seed
20 //!
21 //! Same two reasons as [`super::harness`]. The password is a credential and does
22 //! not belong in a public repo, so the phase runs only when [`PASSWORD_ENV`] is
23 //! set and skips with a warning otherwise. And `mnw-testnot-seed.sh` drops every
24 //! schema before it reseeds, so an account created by hand is gone on the next
25 //! reset with nothing in the diff to explain why the capture run started failing
26 //! at login.
27 //!
28 //! The prod guards in [`super::run`] run first: this is unreachable without
29 //! `ALLOW_EXAMPLE_SEED=1` on an approved host with no real accounts present.
30
31 use chrono::{DateTime, Duration, Utc};
32 use uuid::Uuid;
33
34 use super::projects::SeededProject;
35 use super::{EXAMPLE_EMAIL_DOMAIN, SeedError};
36 use crate::auth;
37 use crate::db::{self, ItemId};
38
39 /// Env var holding the demo buyer's password. Set in testnot's `EnvironmentFile`
40 /// alongside the other box secrets, never in this repo.
41 pub const PASSWORD_ENV: &str = "TESTNOT_BUYER_PASSWORD";
42
43 /// Fixed id, for the same reason the harness accounts have one: a reseed has to
44 /// reproduce the same account rather than a new one each time.
45 pub const BUYER_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_b001);
46
47 /// Login handle, and the local part of `{handle}@example.test`.
48 const HANDLE: &str = "demo_collector";
49
50 /// Shown on the profile and in the header while the capture runs.
51 const DISPLAY_NAME: &str = "Demo Collector";
52
53 /// One purchase in the demo buyer's history.
54 pub(super) struct PurchaseSpec {
55 /// Item title, matched against the seeded catalog. Titles are unique within
56 /// a project and, across this roster, unique overall.
57 pub(super) title: &'static str,
58 /// Days before the seed run to date the purchase. Spread on purpose: a
59 /// library where every row says the same timestamp reads as a fixture, and
60 /// the list is ordered by date, so the spread is what gives it a shape.
61 pub(super) days_ago: i64,
62 /// Cents paid above the pay-what-you-want minimum. Ignored for fixed-price
63 /// and free items. A buyer who always pays exactly the floor is a buyer
64 /// nobody recognises.
65 pub(super) tip_cents: i32,
66 /// Whether the buyer has already downloaded the current version. `false`
67 /// leaves the "new version" badge lit, which is worth showing on one or two
68 /// rows and noise on all of them.
69 pub(super) downloaded: bool,
70 }
71
72 /// Nine of the eleven seeded items, spanning every purchasable type.
73 ///
74 /// Not all eleven: a library holding the entire catalog reads as seeded data
75 /// rather than as somebody's shelf. "Weekly-Review Template" and "Typesetting
76 /// the Commons" are deliberately left unbought.
77 pub(super) const PURCHASES: &[PurchaseSpec] = &[
78 PurchaseSpec {
79 title: "Restoration No. 1 (Full Mix)",
80 days_ago: 2,
81 tip_cents: 400,
82 downloaded: false,
83 },
84 PurchaseSpec {
85 title: "Field Study 01 (Print)",
86 days_ago: 5,
87 tip_cents: 0,
88 downloaded: true,
89 },
90 PurchaseSpec {
91 title: "Deskriver Focus (Plugin)",
92 days_ago: 11,
93 tip_cents: 0,
94 downloaded: false,
95 },
96 PurchaseSpec {
97 title: "Stem Pack: Strings",
98 days_ago: 19,
99 tip_cents: 300,
100 downloaded: true,
101 },
102 PurchaseSpec {
103 title: "On Slow Reading",
104 days_ago: 24,
105 tip_cents: 0,
106 downloaded: true,
107 },
108 PurchaseSpec {
109 title: "Deskriver Utility (Download)",
110 days_ago: 38,
111 tip_cents: 0,
112 downloaded: true,
113 },
114 PurchaseSpec {
115 title: "Session Take (Video)",
116 days_ago: 52,
117 tip_cents: 150,
118 downloaded: true,
119 },
120 PurchaseSpec {
121 title: "Community Bundle Vol. 1",
122 days_ago: 66,
123 tip_cents: 0,
124 downloaded: true,
125 },
126 PurchaseSpec {
127 title: "Minimal Preset Pack",
128 days_ago: 91,
129 tip_cents: 0,
130 downloaded: true,
131 },
132 ];
133
134 /// The subscription the buyer holds. Marginalia is the roster's one subscription
135 /// project, and the library renders subscriptions beside purchases, so without
136 /// this the frame shows half of what the page does.
137 const SUBSCRIBED_PROJECT_SLUG: &str = "the-marginalia-reader";
138
139 /// Which tier. The middle one: the cheapest reads as a trial and the dearest as
140 /// a plant.
141 const SUBSCRIBED_TIER_NAME: &str = "Patron";
142
143 /// The one value the phase needs from the environment.
144 #[derive(Clone)]
145 pub struct BuyerOptions {
146 /// The demo buyer's password, used by the capture run to log in.
147 pub password: String,
148 }
149
150 /// Hand-written so the password cannot reach a log through
151 /// [`super::SeedOptions`]'s derived `Debug`.
152 impl std::fmt::Debug for BuyerOptions {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 f.debug_struct("BuyerOptions")
155 .field("password", &"<redacted>")
156 .finish()
157 }
158 }
159
160 impl BuyerOptions {
161 /// Read the password from the environment; `None` when unset or empty, which
162 /// is the signal to skip the phase.
163 pub fn from_env() -> Option<Self> {
164 let password = std::env::var(PASSWORD_ENV).ok().filter(|s| !s.is_empty())?;
165 Some(Self { password })
166 }
167 }
168
169 /// Seed the demo buyer, their purchase history, and their subscription.
170 ///
171 /// Called from [`super::run`] after the catalog phases, because every purchase
172 /// references an item those phases created. Re-runnable: the account is an
173 /// upsert on its fixed id, and the rows hanging off it are rebuilt from scratch
174 /// each run so a reseed cannot accumulate duplicates.
175 pub async fn seed_buyer(
176 pool: &sqlx::PgPool,
177 opts: &BuyerOptions,
178 projects: &[SeededProject],
179 ) -> Result<(), SeedError> {
180 let password_hash = auth::hash_password_async(opts.password.clone()).await?;
181 seed_account(pool, &password_hash).await?;
182 clear_prior_history(pool).await?;
183
184 let mut bought = 0;
185 for project in projects {
186 let items = db::items::get_items_by_project(pool, project.project.id).await?;
187 for item in &items {
188 let Some(spec) = PURCHASES.iter().find(|p| p.title == item.title) else {
189 continue;
190 };
191 let purchased_at = Utc::now() - Duration::days(spec.days_ago);
192 let amount_cents = amount_for(item, spec);
193 let transaction_id = super::sales::record_purchase(
194 pool,
195 project,
196 item,
197 BUYER_ACCOUNT_ID,
198 amount_cents,
199 purchased_at,
200 )
201 .await?;
202 if project.spec.features.contains(&"license_keys") {
203 issue_license_key(pool, item.id, transaction_id, spec.days_ago).await?;
204 }
205 if spec.downloaded {
206 record_download(pool, item.id, purchased_at).await?;
207 }
208 bought += 1;
209 }
210 }
211
212 if bought != PURCHASES.len() {
213 // A renamed item silently drops a row from the library, and the frame
214 // just looks thin. Say so instead.
215 tracing::warn!(
216 matched = bought,
217 expected = PURCHASES.len(),
218 "example seed: some demo-buyer purchases matched no item; check the titles in buyer.rs"
219 );
220 }
221
222 seed_subscription(pool, projects).await?;
223
224 tracing::warn!(
225 user_id = %BUYER_ACCOUNT_ID,
226 handle = HANDLE,
227 purchases = bought,
228 "example seed: demo buyer seeded (login-capable, for the capture run only)"
229 );
230 Ok(())
231 }
232
233 /// What the buyer paid: the fixed price, or the pay-what-you-want floor plus the
234 /// spec's tip, or nothing for a free item.
235 ///
236 /// Reading it off the item rather than hardcoding it keeps the library's badges
237 /// honest. `get_user_purchases` derives its Free badge from `amount_cents = 0`,
238 /// so a paid item recorded at zero would badge wrong.
239 fn amount_for(item: &db::DbItem, spec: &PurchaseSpec) -> i32 {
240 if item.pwyw_enabled {
241 return item.pwyw_min_cents.unwrap_or(0) + spec.tip_cents;
242 }
243 item.price_cents
244 }
245
246 /// Insert (or reset) the buyer account at its fixed id.
247 ///
248 /// `is_sandbox` stays FALSE: a sandbox account is refused by
249 /// `SessionUser::check_not_sandbox`, and this one has to hold a real session.
250 /// `can_create_projects` stays FALSE, because the whole point is a buyer.
251 async fn seed_account(pool: &sqlx::PgPool, password_hash: &str) -> Result<(), SeedError> {
252 let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-"));
253 sqlx::query(
254 r"
255 INSERT INTO users (
256 id, username, email, password_hash, display_name,
257 can_create_projects, email_verified
258 )
259 VALUES ($1, $2, $3, $4, $5, FALSE, TRUE)
260 ON CONFLICT (id) DO UPDATE SET
261 username = EXCLUDED.username,
262 email = EXCLUDED.email,
263 password_hash = EXCLUDED.password_hash,
264 display_name = EXCLUDED.display_name,
265 -- A capture run that tripped the lockout must not survive the
266 -- reseed: the point of the reset is a known state.
267 failed_login_attempts = 0,
268 locked_until = NULL,
269 suspended_at = NULL,
270 deactivated_at = NULL
271 ",
272 )
273 .bind(BUYER_ACCOUNT_ID)
274 .bind(HANDLE)
275 .bind(&email)
276 .bind(password_hash)
277 .bind(DISPLAY_NAME)
278 .execute(pool)
279 .await?;
280 Ok(())
281 }
282
283 /// Drop everything hanging off the buyer from a previous run.
284 ///
285 /// The catalog phases get idempotency from the example-data wipe, which deletes
286 /// the creators and cascades their items. This account is not in that set (it is
287 /// keyed by id, not created per run), so its purchases would otherwise survive a
288 /// reseed pointing at items that no longer exist.
289 async fn clear_prior_history(pool: &sqlx::PgPool) -> Result<(), SeedError> {
290 // license_keys and user_downloads cascade from transactions and items
291 // respectively, but the buyer's own rows are keyed by owner/user, so clear
292 // them explicitly rather than relying on which side of the join went first.
293 for statement in [
294 "DELETE FROM user_downloads WHERE user_id = $1",
295 "DELETE FROM license_keys WHERE owner_id = $1",
296 "DELETE FROM subscriptions WHERE subscriber_id = $1",
297 "DELETE FROM transactions WHERE buyer_id = $1",
298 ] {
299 sqlx::query(statement)
300 .bind(BUYER_ACCOUNT_ID)
301 .execute(pool)
302 .await?;
303 }
304 Ok(())
305 }
306
307 /// Issue a license key for a purchase from a project that sells them.
308 ///
309 /// License keys are one of the things the library page shows and one of the
310 /// things MNW sells, so the frame is worth more with one visible. The code shape
311 /// mirrors the real generator's grouping without reusing it: this is display
312 /// data on a demo box, not a key anything validates.
313 async fn issue_license_key(
314 pool: &sqlx::PgPool,
315 item_id: ItemId,
316 transaction_id: Uuid,
317 days_ago: i64,
318 ) -> Result<(), SeedError> {
319 // Derived from the item id so a reseed of the same catalog produces the same
320 // key, and no two items collide on the UNIQUE constraint.
321 let raw = item_id.as_uuid().simple().to_string().to_uppercase();
322 let key_code = format!("DEMO-{}-{}-{}", &raw[0..4], &raw[4..8], &raw[8..12]);
323 sqlx::query(
324 r"
325 INSERT INTO license_keys (
326 item_id, owner_id, transaction_id, key_code, max_activations, created_at
327 )
328 VALUES ($1, $2, $3, $4, 3, $5)
329 ON CONFLICT (key_code) DO NOTHING
330 ",
331 )
332 .bind(item_id)
333 .bind(BUYER_ACCOUNT_ID)
334 .bind(transaction_id)
335 .bind(&key_code)
336 .bind(Utc::now() - Duration::days(days_ago))
337 .execute(pool)
338 .await?;
339 Ok(())
340 }
341
342 /// Mark every current version of an item as already downloaded.
343 ///
344 /// `get_user_purchases` lights its "new version" badge when the item has more
345 /// versions than the buyer has downloads, so this is what turns the badge off.
346 /// Leaving it on for a row or two is the point; leaving it on for all nine would
347 /// read as a broken library rather than a used one.
348 async fn record_download(
349 pool: &sqlx::PgPool,
350 item_id: ItemId,
351 downloaded_at: DateTime<Utc>,
352 ) -> Result<(), SeedError> {
353 sqlx::query(
354 r"
355 INSERT INTO user_downloads (user_id, item_id, version_id, downloaded_at)
356 SELECT $1, $2, v.id, $3
357 FROM versions v
358 WHERE v.item_id = $2 AND v.s3_key IS NOT NULL
359 ON CONFLICT DO NOTHING
360 ",
361 )
362 .bind(BUYER_ACCOUNT_ID)
363 .bind(item_id)
364 .bind(downloaded_at)
365 .execute(pool)
366 .await?;
367 Ok(())
368 }
369
370 /// Give the buyer an active subscription to the roster's subscription project.
371 ///
372 /// `get_user_subscriptions_with_details` joins the tier and the project and
373 /// filters on nothing but the subscriber, so an `active` row with a future
374 /// `current_period_end` is the whole requirement. No Stripe call: the ids are
375 /// fabricated and prefixed, as with the purchases.
376 async fn seed_subscription(
377 pool: &sqlx::PgPool,
378 projects: &[SeededProject],
379 ) -> Result<(), SeedError> {
380 let Some(project) = projects
381 .iter()
382 .find(|p| p.spec.slug == SUBSCRIBED_PROJECT_SLUG)
383 else {
384 tracing::warn!(
385 slug = SUBSCRIBED_PROJECT_SLUG,
386 "example seed: subscription project missing; demo buyer has no subscription"
387 );
388 return Ok(());
389 };
390
391 let tier_id: Option<Uuid> =
392 sqlx::query_scalar("SELECT id FROM subscription_tiers WHERE project_id = $1 AND name = $2")
393 .bind(project.project.id)
394 .bind(SUBSCRIBED_TIER_NAME)
395 .fetch_optional(pool)
396 .await?;
397 let Some(tier_id) = tier_id else {
398 tracing::warn!(
399 tier = SUBSCRIBED_TIER_NAME,
400 "example seed: subscription tier missing; demo buyer has no subscription"
401 );
402 return Ok(());
403 };
404
405 // Started three months back, renewing in a fortnight: a subscription that is
406 // established rather than brand new, and visibly current.
407 let started = Utc::now() - Duration::days(92);
408 let period_start = Utc::now() - Duration::days(16);
409 let period_end = Utc::now() + Duration::days(14);
410 sqlx::query(
411 r"
412 INSERT INTO subscriptions (
413 subscriber_id, tier_id, project_id, stripe_subscription_id,
414 stripe_customer_id, status, current_period_start, current_period_end,
415 created_at
416 )
417 VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8)
418 ",
419 )
420 .bind(BUYER_ACCOUNT_ID)
421 .bind(tier_id)
422 .bind(project.project.id)
423 .bind(format!("sub_demo_{BUYER_ACCOUNT_ID}"))
424 .bind(format!("cus_demo_{BUYER_ACCOUNT_ID}"))
425 .bind(period_start)
426 .bind(period_end)
427 .bind(started)
428 .execute(pool)
429 .await?;
430 Ok(())
431 }
432
433 #[cfg(test)]
434 mod tests {
435 use super::*;
436
437 #[test]
438 fn buyer_email_stays_inside_the_reserved_domain() {
439 // The seed's reset only deletes @example.test accounts, and its third
440 // guard refuses to run at all when a non-example account exists. A
441 // handle producing an address outside the domain would both survive
442 // resets and block the next seed.
443 let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-"));
444 assert!(email.ends_with("@example.test"), "{email}");
445 }
446
447 #[test]
448 fn the_buyer_does_not_own_the_whole_catalog() {
449 // Eleven items are seeded. A library holding all of them reads as a
450 // fixture; this is the assertion that keeps someone from "fixing" the
451 // gap by adding the last two.
452 assert!(
453 PURCHASES.len() < 11,
454 "the demo buyer should leave some of the catalog unbought"
455 );
456 }
457
458 #[test]
459 fn purchase_titles_are_unique() {
460 let mut seen = std::collections::HashSet::new();
461 for spec in PURCHASES {
462 assert!(
463 seen.insert(spec.title),
464 "duplicate purchase {:?}",
465 spec.title
466 );
467 }
468 }
469
470 #[test]
471 fn purchase_dates_are_distinct_and_ordered() {
472 // The library lists by date. Two rows sharing a day is a coin flip in
473 // the ordering, which makes a re-capture differ from the approved one.
474 let days: Vec<i64> = PURCHASES.iter().map(|p| p.days_ago).collect();
475 let mut sorted = days.clone();
476 sorted.sort_unstable();
477 sorted.dedup();
478 assert_eq!(sorted.len(), days.len(), "two purchases share a date");
479 assert!(
480 days.windows(2).all(|w| w[0] < w[1]),
481 "keep PURCHASES in date order, newest first, so the list reads like the page"
482 );
483 }
484
485 #[test]
486 fn at_least_one_row_keeps_its_new_version_badge() {
487 assert!(
488 PURCHASES.iter().any(|p| !p.downloaded),
489 "the update-available state is worth showing on at least one row"
490 );
491 assert!(
492 PURCHASES.iter().filter(|p| !p.downloaded).count() <= 3,
493 "an all-badged library reads as broken, not as used"
494 );
495 }
496 }
497