Skip to main content

max / makenotwork

14.7 KB · 375 lines History Blame Raw
1 //! Example-marketplace seed for the `testnot.work` staging box.
2 //!
3 //! Replaces testnot's nightly prod-data restore with a self-contained catalog of
4 //! fabricated creators and public-domain items, so staging never holds real user
5 //! data. Invoked deliberately via the `--seed-examples` flag on the server binary
6 //! (see `main.rs`), never at boot. Design + sprint plan:
7 //! `_private/docs/mnw/testnot-example-seed.md`.
8 //!
9 //! # Prod safety
10 //!
11 //! The seed writes fabricated rows; it must be impossible to run against
12 //! production. [`run`] enforces three layered guards, all required:
13 //!
14 //! 1. `ALLOW_EXAMPLE_SEED=1` must be set (opt-in switch).
15 //! 2. `HOST_URL` must resolve to an approved example host, `testnot.work` (or a
16 //! subdomain) or localhost. The prod apex `makenot.work` is not on the
17 //! allowlist, so it is refused.
18 //! 3. The `users` table must contain no *real* accounts. "Real" means any email
19 //! outside the reserved [`EXAMPLE_EMAIL_DOMAIN`]; a database with only example
20 //! accounts (or none) passes.
21 //!
22 //! # Reset semantics
23 //!
24 //! Decision (Phase 0): **wipe-example-data-first**. Before seeding, [`run`]
25 //! deletes every `@example.test` account, which cascades to their projects,
26 //! items, follows, and transactions (all `ON DELETE CASCADE` on `user_id`). This
27 //! makes `--seed-examples` idempotent and re-runnable to reset testnot without a
28 //! full schema drop. It is safe precisely because guard 3 guarantees only example
29 //! data is ever present when the seed runs. The Phase 6 `mnw-testnot-seed.sh` may
30 //! still reset the schema for a fully clean slate, but the seed does not depend on
31 //! that.
32
33 pub mod blog;
34 pub mod buyer;
35 pub mod creators;
36 pub mod harness;
37 pub mod items;
38 pub mod manifest;
39 pub mod media;
40 pub mod projects;
41 pub mod sales;
42 pub mod social;
43
44 use std::sync::Arc;
45
46 use sqlx::PgPool;
47
48 use crate::storage::StorageBackend;
49
50 /// Storage handles the media phase needs, resolved from config at the seed call
51 /// site. When `s3` is `None` (testnot's stubbed default until MinIO is stood up),
52 /// the media phase is skipped and items stay hidden. Covers additionally require
53 /// `public_s3` + `cdn_base_url` (the CDN render base).
54 pub struct SeedMedia {
55 /// Main (gated) bucket for audio/video/download files.
56 pub s3: Option<Arc<dyn StorageBackend>>,
57 /// Public/CDN bucket for cover images.
58 pub public_s3: Option<Arc<dyn StorageBackend>>,
59 /// CDN render base for `cover_image_url` (`{cdn_base}/{key}`).
60 pub cdn_base_url: Option<String>,
61 /// Curated public-domain / CC0 files, already fetched and verified. Empty
62 /// means every slot falls back to its generated placeholder, which is the
63 /// state the box was in before any curation. Populate with
64 /// [`Self::with_manifest`].
65 pub assets: manifest::ResolvedAssets,
66 }
67
68 impl SeedMedia {
69 /// No storage, the media phase is skipped and items remain hidden. Used on a
70 /// stubbed-S3 box and in tests that only exercise the DB-only phases.
71 pub fn none() -> Self {
72 Self {
73 s3: None,
74 public_s3: None,
75 cdn_base_url: None,
76 assets: manifest::ResolvedAssets::default(),
77 }
78 }
79
80 /// Load `media-manifest.toml` and fetch everything curated in it.
81 ///
82 /// Fails before the seed writes anything when a curated asset will not fetch
83 /// or does not match its pinned digest, so a broken manifest leaves the
84 /// existing catalog standing rather than half-replacing it. A manifest with
85 /// nothing curated yet succeeds and resolves to nothing.
86 pub async fn with_manifest(mut self) -> Result<Self, manifest::ManifestError> {
87 self.assets = manifest::Manifest::load()?.resolve().await?;
88 Ok(self)
89 }
90 }
91
92 /// Reserved email domain for every seeded example account. It is the marker the
93 /// prod-safety guard uses to tell fabricated seed data from real users: the seed
94 /// refuses to run when any account with a different domain exists, and the reset
95 /// step only ever deletes accounts in this domain. (The `003_seed_demo`
96 /// migration's `elena@example.com` is not a concern, migration
97 /// `080_remove_demo_data` deletes it, so a freshly-migrated DB holds no
98 /// non-example accounts.)
99 pub const EXAMPLE_EMAIL_DOMAIN: &str = "example.test";
100
101 /// Why the example seed refused to run, or how it failed partway through.
102 #[derive(Debug, thiserror::Error)]
103 pub enum SeedError {
104 /// `ALLOW_EXAMPLE_SEED` was not set to `1`.
105 #[error("refusing to seed: ALLOW_EXAMPLE_SEED is not set to 1")]
106 NotAllowed,
107 /// `HOST_URL` did not resolve to an approved example host.
108 #[error(
109 "refusing to seed: HOST_URL {0:?} is not an approved example host \
110 (testnot.work or localhost only)"
111 )]
112 ProdHost(String),
113 /// The database already holds non-example accounts.
114 #[error(
115 "refusing to seed: database holds {0} non-example account(s); the example \
116 seed only runs on a database that contains example data or nothing"
117 )]
118 RealUsersPresent(i64),
119 /// A database error while checking guards, resetting, or seeding.
120 #[error(transparent)]
121 Db(#[from] sqlx::Error),
122 /// An application error from a `db::*` helper or the password hasher while
123 /// seeding content (the `db` layer returns [`crate::error::AppError`], not a
124 /// raw `sqlx::Error`).
125 #[error(transparent)]
126 App(#[from] crate::error::AppError),
127 }
128
129 /// Inputs for a seed run, resolved from the process environment and config.
130 #[derive(Debug, Clone)]
131 pub struct SeedOptions {
132 /// `ALLOW_EXAMPLE_SEED=1` was present.
133 pub allow_example_seed: bool,
134 /// The configured `HOST_URL` (from [`crate::config::Config`]).
135 pub host_url: String,
136 /// Credentials for the mt browser-harness phase, when the box carries them.
137 /// `None` skips that phase and seeds the catalog alone.
138 ///
139 /// Carried here rather than read inside the phase so a test names what it
140 /// wants explicitly: the harness accounts change the account counts every
141 /// other seed test asserts, and a stray env var in a dev shell should not be
142 /// able to fail them.
143 pub harness: Option<harness::HarnessOptions>,
144 /// Credential for the demo buyer, when the box carries it. `None` skips that
145 /// phase, and `/library` stays empty of anything to photograph. Carried
146 /// explicitly for the same reason as `harness`: the buyer adds an account
147 /// and nine transactions, which every count-asserting test would feel.
148 pub buyer: Option<buyer::BuyerOptions>,
149 }
150
151 impl SeedOptions {
152 /// Read the opt-in switch and the harness credentials from the environment;
153 /// take `host_url` from config.
154 pub fn from_env(host_url: &str) -> Self {
155 Self {
156 allow_example_seed: std::env::var("ALLOW_EXAMPLE_SEED").ok().as_deref() == Some("1"),
157 host_url: host_url.to_string(),
158 harness: harness::HarnessOptions::from_env(),
159 buyer: buyer::BuyerOptions::from_env(),
160 }
161 }
162 }
163
164 /// Run the example seed: guards, reset, then content.
165 ///
166 /// Refuses (returns `Err`) unless every prod-safety guard passes. On success the
167 /// database holds a fresh fabricated catalog. Callers should treat this as a
168 /// one-shot: run it, then exit the process.
169 pub async fn run(pool: &PgPool, opts: &SeedOptions, media: &SeedMedia) -> Result<(), SeedError> {
170 // Guards 1 + 2: opt-in switch and approved host. Pure, no DB.
171 check_static_guards(opts.allow_example_seed, &opts.host_url)?;
172
173 // Guard 3: no real accounts present.
174 let real = count_real_users(pool).await?;
175 if real > 0 {
176 return Err(SeedError::RealUsersPresent(real));
177 }
178
179 tracing::warn!(
180 host = %opts.host_url,
181 "example seed: all guards passed; wiping prior example data, then seeding"
182 );
183
184 // Reset semantics: wipe-example-data-first (see module docs).
185 let wiped = reset_example_data(pool).await?;
186 tracing::info!(
187 wiped_users = wiped,
188 "example seed: cleared prior example accounts"
189 );
190
191 // Phases 1-2: durable creators, one project each, then items + pricing + tiers
192 // + tags. Items are seeded hidden (`scan_status='pending'`); Phase 3 attaches
193 // media and makes them visible. Phase 4 layers blog/social on top.
194 let creators = creators::seed_creators(pool).await?;
195 let projects = projects::seed_projects(pool, &creators).await?;
196 items::seed_items(pool, &projects).await?;
197
198 // Phase 3: attach placeholder media and flip items visible. Skipped (items
199 // stay hidden) when storage is unconfigured.
200 media::seed_media(pool, media, &projects).await?;
201
202 // Phase 4: blog posts (blog page + RSS) and a follow graph (renders counts).
203 blog::seed_blog(pool, &projects).await?;
204 blog::seed_media_credits(pool, &projects, &media.assets).await?;
205 social::seed_social(pool, &projects).await?;
206 tracing::warn!("example seed: forum (Phase 5) deferred; refresh-flow swap is Phase 6");
207
208 // Harness phase: login-capable accounts and the mt OAuth client, for the
209 // browser axis of an audit run. Opt-in per box (see `harness`), because the
210 // password and the mt callback URL are environment, not repo content. A box
211 // without them seeds exactly the catalog it seeded before.
212 match opts.harness.as_ref() {
213 Some(harness_opts) => harness::seed_harness(pool, harness_opts).await?,
214 None => tracing::info!(
215 "example seed: harness phase skipped ({} and {} must both be set)",
216 harness::PASSWORD_ENV,
217 harness::REDIRECT_URI_ENV,
218 ),
219 }
220
221 // Demo buyer: one login-capable account with a purchase history, so the
222 // landing carousel's third frame (`/library`) has something to photograph.
223 // Opt-in per box for the same reason as the harness, and after the catalog
224 // phases because every purchase references an item they created.
225 match opts.buyer.as_ref() {
226 Some(buyer_opts) => buyer::seed_buyer(pool, buyer_opts, &projects).await?,
227 None => tracing::info!(
228 "example seed: demo-buyer phase skipped ({} must be set)",
229 buyer::PASSWORD_ENV,
230 ),
231 }
232
233 // Sales phase: the purchase history behind every item's "Sales" figure, and
234 // the reconcile that makes `items.sales_count` agree with it. After the
235 // buyer phase so it counts those purchases too, and unconditional because it
236 // needs no credential.
237 sales::seed_sales(pool, &projects).await?;
238
239 Ok(())
240 }
241
242 /// The env + host guards, computed from explicit inputs so they can be unit-tested
243 /// without a process environment or a database.
244 pub fn check_static_guards(allow_example_seed: bool, host_url: &str) -> Result<(), SeedError> {
245 if !allow_example_seed {
246 return Err(SeedError::NotAllowed);
247 }
248 if !is_approved_example_host(host_url) {
249 return Err(SeedError::ProdHost(host_url.to_string()));
250 }
251 Ok(())
252 }
253
254 /// Whether `host_url`'s host is on the example-seed allowlist. Allowlist, not
255 /// denylist: only `testnot.work` (and subdomains) and localhost pass, so the prod
256 /// apex and any unknown host are refused by default.
257 fn is_approved_example_host(host_url: &str) -> bool {
258 let host = url::Url::parse(host_url)
259 .ok()
260 .and_then(|u| u.host_str().map(str::to_ascii_lowercase));
261 match host {
262 Some(h) => {
263 h == "testnot.work"
264 || h.ends_with(".testnot.work")
265 || h == "localhost"
266 || h == "127.0.0.1"
267 || h == "::1"
268 }
269 // Not a parseable URL with a host, refuse rather than guess.
270 None => false,
271 }
272 }
273
274 /// Count accounts that are NOT example accounts (email domain outside
275 /// [`EXAMPLE_EMAIL_DOMAIN`]). A non-zero result means real data is present and the
276 /// seed must refuse.
277 async fn count_real_users(pool: &PgPool) -> Result<i64, sqlx::Error> {
278 sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE lower(email) NOT LIKE $1")
279 .bind(format!("%@{EXAMPLE_EMAIL_DOMAIN}"))
280 .fetch_one(pool)
281 .await
282 }
283
284 /// Delete every example account and its content. Returns the number of accounts
285 /// removed. Safe only after the guards have confirmed no real data is present.
286 ///
287 /// Projects are deleted first, on purpose: that cascades their items, blog posts,
288 /// versions, tiers, and tags (all `ON DELETE CASCADE` on `project_id`). It clears
289 /// `blog_posts.author_id` in particular, whose FK to `users` is *not*
290 /// `ON DELETE CASCADE` and would otherwise block the subsequent `DELETE FROM users`
291 /// (the user-scoped `follows` rows do cascade on `follower_id`).
292 async fn reset_example_data(pool: &PgPool) -> Result<u64, sqlx::Error> {
293 let like = format!("%@{EXAMPLE_EMAIL_DOMAIN}");
294 sqlx::query(
295 "DELETE FROM projects WHERE user_id IN \
296 (SELECT id FROM users WHERE lower(email) LIKE $1)",
297 )
298 .bind(&like)
299 .execute(pool)
300 .await?;
301
302 let res = sqlx::query("DELETE FROM users WHERE lower(email) LIKE $1")
303 .bind(&like)
304 .execute(pool)
305 .await?;
306 Ok(res.rows_affected())
307 }
308
309 #[cfg(test)]
310 mod tests {
311 use super::*;
312
313 #[test]
314 fn refuses_without_allow_flag() {
315 let err = check_static_guards(false, "https://testnot.work").unwrap_err();
316 assert!(matches!(err, SeedError::NotAllowed));
317 }
318
319 #[test]
320 fn refuses_prod_apex_even_with_flag() {
321 // The exact prod-shaped config: allowed switch on, prod host.
322 let err = check_static_guards(true, "https://makenot.work").unwrap_err();
323 assert!(matches!(err, SeedError::ProdHost(_)));
324 }
325
326 #[test]
327 fn refuses_prod_subdomain() {
328 for host in [
329 "https://www.makenot.work",
330 "https://app.makenot.work",
331 "https://makenot.work/creators",
332 ] {
333 let err = check_static_guards(true, host).unwrap_err();
334 assert!(
335 matches!(err, SeedError::ProdHost(_)),
336 "should refuse {host}"
337 );
338 }
339 }
340
341 #[test]
342 fn allows_testnot_and_localhost() {
343 for host in [
344 "https://testnot.work",
345 "https://testnot.work/",
346 "https://sub.testnot.work",
347 "http://localhost:3000",
348 "http://127.0.0.1:8080",
349 ] {
350 assert!(
351 check_static_guards(true, host).is_ok(),
352 "should allow {host}"
353 );
354 }
355 }
356
357 #[test]
358 fn refuses_lookalike_hosts() {
359 // Substring tricks must not slip past the allowlist.
360 for host in [
361 "https://testnot.work.evil.com",
362 "https://nottestnot.work",
363 "https://testnot-work.com",
364 "not-a-url",
365 "",
366 ] {
367 let err = check_static_guards(true, host).unwrap_err();
368 assert!(
369 matches!(err, SeedError::ProdHost(_)),
370 "should refuse {host}"
371 );
372 }
373 }
374 }
375