//! Example-marketplace seed for the `testnot.work` staging box. //! //! Replaces testnot's nightly prod-data restore with a self-contained catalog of //! fabricated creators and public-domain items, so staging never holds real user //! data. Invoked deliberately via the `--seed-examples` flag on the server binary //! (see `main.rs`), never at boot. Design + sprint plan: //! `_private/docs/mnw/testnot-example-seed.md`. //! //! # Prod safety //! //! The seed writes fabricated rows; it must be impossible to run against //! production. [`run`] enforces three layered guards, all required: //! //! 1. `ALLOW_EXAMPLE_SEED=1` must be set (opt-in switch). //! 2. `HOST_URL` must resolve to an approved example host, `testnot.work` (or a //! subdomain) or localhost. The prod apex `makenot.work` is not on the //! allowlist, so it is refused. //! 3. The `users` table must contain no *real* accounts. "Real" means any email //! outside the reserved [`EXAMPLE_EMAIL_DOMAIN`]; a database with only example //! accounts (or none) passes. //! //! # Reset semantics //! //! Decision (Phase 0): **wipe-example-data-first**. Before seeding, [`run`] //! deletes every `@example.test` account, which cascades to their projects, //! items, follows, and transactions (all `ON DELETE CASCADE` on `user_id`). This //! makes `--seed-examples` idempotent and re-runnable to reset testnot without a //! full schema drop. It is safe precisely because guard 3 guarantees only example //! data is ever present when the seed runs. The Phase 6 `mnw-testnot-seed.sh` may //! still reset the schema for a fully clean slate, but the seed does not depend on //! that. pub mod blog; pub mod creators; pub mod items; pub mod media; pub mod projects; pub mod social; use std::sync::Arc; use sqlx::PgPool; use crate::storage::StorageBackend; /// Storage handles the media phase needs, resolved from config at the seed call /// site. When `s3` is `None` (testnot's stubbed default until MinIO is stood up), /// the media phase is skipped and items stay hidden. Covers additionally require /// `public_s3` + `cdn_base_url` (the CDN render base). pub struct SeedMedia { /// Main (gated) bucket for audio/video/download files. pub s3: Option>, /// Public/CDN bucket for cover images. pub public_s3: Option>, /// CDN render base for `cover_image_url` (`{cdn_base}/{key}`). pub cdn_base_url: Option, } impl SeedMedia { /// No storage, the media phase is skipped and items remain hidden. Used on a /// stubbed-S3 box and in tests that only exercise the DB-only phases. pub fn none() -> Self { Self { s3: None, public_s3: None, cdn_base_url: None, } } } /// Reserved email domain for every seeded example account. It is the marker the /// prod-safety guard uses to tell fabricated seed data from real users: the seed /// refuses to run when any account with a different domain exists, and the reset /// step only ever deletes accounts in this domain. (The `003_seed_demo` /// migration's `elena@example.com` is not a concern, migration /// `080_remove_demo_data` deletes it, so a freshly-migrated DB holds no /// non-example accounts.) pub const EXAMPLE_EMAIL_DOMAIN: &str = "example.test"; /// Why the example seed refused to run, or how it failed partway through. #[derive(Debug, thiserror::Error)] pub enum SeedError { /// `ALLOW_EXAMPLE_SEED` was not set to `1`. #[error("refusing to seed: ALLOW_EXAMPLE_SEED is not set to 1")] NotAllowed, /// `HOST_URL` did not resolve to an approved example host. #[error( "refusing to seed: HOST_URL {0:?} is not an approved example host \ (testnot.work or localhost only)" )] ProdHost(String), /// The database already holds non-example accounts. #[error( "refusing to seed: database holds {0} non-example account(s); the example \ seed only runs on a database that contains example data or nothing" )] RealUsersPresent(i64), /// A database error while checking guards, resetting, or seeding. #[error(transparent)] Db(#[from] sqlx::Error), /// An application error from a `db::*` helper or the password hasher while /// seeding content (the `db` layer returns [`crate::error::AppError`], not a /// raw `sqlx::Error`). #[error(transparent)] App(#[from] crate::error::AppError), } /// Inputs for a seed run, resolved from the process environment and config. #[derive(Debug, Clone)] pub struct SeedOptions { /// `ALLOW_EXAMPLE_SEED=1` was present. pub allow_example_seed: bool, /// The configured `HOST_URL` (from [`crate::config::Config`]). pub host_url: String, } impl SeedOptions { /// Read the opt-in switch from the environment; take `host_url` from config. pub fn from_env(host_url: &str) -> Self { Self { allow_example_seed: std::env::var("ALLOW_EXAMPLE_SEED").ok().as_deref() == Some("1"), host_url: host_url.to_string(), } } } /// Run the example seed: guards, reset, then content. /// /// Refuses (returns `Err`) unless every prod-safety guard passes. On success the /// database holds a fresh fabricated catalog. Callers should treat this as a /// one-shot: run it, then exit the process. pub async fn run(pool: &PgPool, opts: &SeedOptions, media: &SeedMedia) -> Result<(), SeedError> { // Guards 1 + 2: opt-in switch and approved host. Pure, no DB. check_static_guards(opts.allow_example_seed, &opts.host_url)?; // Guard 3: no real accounts present. let real = count_real_users(pool).await?; if real > 0 { return Err(SeedError::RealUsersPresent(real)); } tracing::warn!( host = %opts.host_url, "example seed: all guards passed; wiping prior example data, then seeding" ); // Reset semantics: wipe-example-data-first (see module docs). let wiped = reset_example_data(pool).await?; tracing::info!( wiped_users = wiped, "example seed: cleared prior example accounts" ); // Phases 1-2: durable creators, one project each, then items + pricing + tiers // + tags. Items are seeded hidden (`scan_status='pending'`); Phase 3 attaches // media and makes them visible. Phase 4 layers blog/social on top. let creators = creators::seed_creators(pool).await?; let projects = projects::seed_projects(pool, &creators).await?; items::seed_items(pool, &projects).await?; // Phase 3: attach placeholder media and flip items visible. Skipped (items // stay hidden) when storage is unconfigured. media::seed_media(pool, media, &projects).await?; // Phase 4: blog posts (blog page + RSS) and a follow graph (renders counts). blog::seed_blog(pool, &projects).await?; social::seed_social(pool, &projects).await?; tracing::warn!("example seed: forum (Phase 5) deferred; refresh-flow swap is Phase 6"); Ok(()) } /// The env + host guards, computed from explicit inputs so they can be unit-tested /// without a process environment or a database. pub fn check_static_guards(allow_example_seed: bool, host_url: &str) -> Result<(), SeedError> { if !allow_example_seed { return Err(SeedError::NotAllowed); } if !is_approved_example_host(host_url) { return Err(SeedError::ProdHost(host_url.to_string())); } Ok(()) } /// Whether `host_url`'s host is on the example-seed allowlist. Allowlist, not /// denylist: only `testnot.work` (and subdomains) and localhost pass, so the prod /// apex and any unknown host are refused by default. fn is_approved_example_host(host_url: &str) -> bool { let host = url::Url::parse(host_url) .ok() .and_then(|u| u.host_str().map(str::to_ascii_lowercase)); match host { Some(h) => { h == "testnot.work" || h.ends_with(".testnot.work") || h == "localhost" || h == "127.0.0.1" || h == "::1" } // Not a parseable URL with a host, refuse rather than guess. None => false, } } /// Count accounts that are NOT example accounts (email domain outside /// [`EXAMPLE_EMAIL_DOMAIN`]). A non-zero result means real data is present and the /// seed must refuse. async fn count_real_users(pool: &PgPool) -> Result { sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE lower(email) NOT LIKE $1") .bind(format!("%@{EXAMPLE_EMAIL_DOMAIN}")) .fetch_one(pool) .await } /// Delete every example account and its content. Returns the number of accounts /// removed. Safe only after the guards have confirmed no real data is present. /// /// Projects are deleted first, on purpose: that cascades their items, blog posts, /// versions, tiers, and tags (all `ON DELETE CASCADE` on `project_id`). It clears /// `blog_posts.author_id` in particular, whose FK to `users` is *not* /// `ON DELETE CASCADE` and would otherwise block the subsequent `DELETE FROM users` /// (the user-scoped `follows` rows do cascade on `follower_id`). async fn reset_example_data(pool: &PgPool) -> Result { let like = format!("%@{EXAMPLE_EMAIL_DOMAIN}"); sqlx::query( "DELETE FROM projects WHERE user_id IN \ (SELECT id FROM users WHERE lower(email) LIKE $1)", ) .bind(&like) .execute(pool) .await?; let res = sqlx::query("DELETE FROM users WHERE lower(email) LIKE $1") .bind(&like) .execute(pool) .await?; Ok(res.rows_affected()) } #[cfg(test)] mod tests { use super::*; #[test] fn refuses_without_allow_flag() { let err = check_static_guards(false, "https://testnot.work").unwrap_err(); assert!(matches!(err, SeedError::NotAllowed)); } #[test] fn refuses_prod_apex_even_with_flag() { // The exact prod-shaped config: allowed switch on, prod host. let err = check_static_guards(true, "https://makenot.work").unwrap_err(); assert!(matches!(err, SeedError::ProdHost(_))); } #[test] fn refuses_prod_subdomain() { for host in [ "https://www.makenot.work", "https://app.makenot.work", "https://makenot.work/creators", ] { let err = check_static_guards(true, host).unwrap_err(); assert!( matches!(err, SeedError::ProdHost(_)), "should refuse {host}" ); } } #[test] fn allows_testnot_and_localhost() { for host in [ "https://testnot.work", "https://testnot.work/", "https://sub.testnot.work", "http://localhost:3000", "http://127.0.0.1:8080", ] { assert!( check_static_guards(true, host).is_ok(), "should allow {host}" ); } } #[test] fn refuses_lookalike_hosts() { // Substring tricks must not slip past the allowlist. for host in [ "https://testnot.work.evil.com", "https://nottestnot.work", "https://testnot-work.com", "not-a-url", "", ] { let err = check_static_guards(true, host).unwrap_err(); assert!( matches!(err, SeedError::ProdHost(_)), "should refuse {host}" ); } } }