Skip to main content

max / makenotwork

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