max / makenotwork
12 files changed,
+648 insertions,
-0 deletions
| @@ -32,6 +32,41 @@ pub async fn create_user( | |||
| 32 | 32 | Ok(user) | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | + | /// Insert a durable example-marketplace creator (see [`crate::seed`]). | |
| 36 | + | /// | |
| 37 | + | /// Unlike [`create_sandbox_user`], this leaves `is_sandbox` at its `FALSE` | |
| 38 | + | /// default so the account and its projects appear on every public surface | |
| 39 | + | /// (discover/browse/search gate only on `is_sandbox = FALSE`). It grants | |
| 40 | + | /// `can_create_projects`, marks the email verified, and pins the top | |
| 41 | + | /// `creator_tier` so no capability gate blocks the item spread seeded in later | |
| 42 | + | /// phases. Only ever called by the `--seed-examples` flow, which is itself | |
| 43 | + | /// confined to testnot/localhost by [`crate::seed::run`]'s guards. | |
| 44 | + | #[tracing::instrument(skip_all)] | |
| 45 | + | pub async fn create_example_creator( | |
| 46 | + | pool: &PgPool, | |
| 47 | + | username: &Username, | |
| 48 | + | email: &Email, | |
| 49 | + | password_hash: &str, | |
| 50 | + | ) -> Result<DbUser> { | |
| 51 | + | let user = sqlx::query_as::<_, DbUser>( | |
| 52 | + | r#" | |
| 53 | + | INSERT INTO users ( | |
| 54 | + | username, email, password_hash, | |
| 55 | + | can_create_projects, email_verified, creator_tier | |
| 56 | + | ) | |
| 57 | + | VALUES ($1, $2, $3, TRUE, TRUE, 'everything') | |
| 58 | + | RETURNING * | |
| 59 | + | "#, | |
| 60 | + | ) | |
| 61 | + | .bind(username) | |
| 62 | + | .bind(email) | |
| 63 | + | .bind(password_hash) | |
| 64 | + | .fetch_one(pool) | |
| 65 | + | .await?; | |
| 66 | + | ||
| 67 | + | Ok(user) | |
| 68 | + | } | |
| 69 | + | ||
| 35 | 70 | /// Fetch a user by primary key. Returns `None` if not found. | |
| 36 | 71 | #[tracing::instrument(skip_all)] | |
| 37 | 72 | pub async fn get_user_by_id(pool: &PgPool, id: UserId) -> Result<Option<DbUser>> { |
| @@ -33,6 +33,7 @@ pub mod pricing; | |||
| 33 | 33 | pub mod pricing_comparison; | |
| 34 | 34 | pub mod synckit_billing; | |
| 35 | 35 | pub mod scheduler; | |
| 36 | + | pub mod seed; | |
| 36 | 37 | pub mod routes; | |
| 37 | 38 | pub mod rss; | |
| 38 | 39 | pub mod scanning; |
| @@ -161,6 +161,24 @@ async fn main() { | |||
| 161 | 161 | ||
| 162 | 162 | tracing::info!("Migrations complete"); | |
| 163 | 163 | ||
| 164 | + | // Example-marketplace seed for staging (testnot.work). Runs against the | |
| 165 | + | // already-migrated DB, then exits — never during normal boot. Layered guards | |
| 166 | + | // (opt-in env, host allowlist, no-real-users) make it refuse against prod. | |
| 167 | + | // See `makenotwork::seed` and `_private/docs/mnw/testnot-example-seed.md`. | |
| 168 | + | if std::env::args().any(|a| a == "--seed-examples") { | |
| 169 | + | let opts = makenotwork::seed::SeedOptions::from_env(&config.host_url); | |
| 170 | + | match makenotwork::seed::run(&db, &opts).await { | |
| 171 | + | Ok(()) => { | |
| 172 | + | tracing::info!("example seed complete"); | |
| 173 | + | std::process::exit(0); | |
| 174 | + | } | |
| 175 | + | Err(e) => { | |
| 176 | + | tracing::error!(error = %e, "example seed refused or failed"); | |
| 177 | + | std::process::exit(1); | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 164 | 182 | // Create PostgreSQL-backed session store (persists across restarts) | |
| 165 | 183 | let session_store = PostgresStore::new(db.clone()); | |
| 166 | 184 | session_store |
| @@ -0,0 +1 @@ | |||
| 1 | + | //! Seed blog posts per project (drives blog + RSS + landing). Filled in Phase 4. |
| @@ -0,0 +1,162 @@ | |||
| 1 | + | //! Seed the fabricated example creators (`@example.test`) and hold the roster | |
| 2 | + | //! that drives the rest of the seed. | |
| 3 | + | //! | |
| 4 | + | //! The roster is the single source of truth for Phase 1: each [`CreatorSpec`] | |
| 5 | + | //! carries the creator's public identity plus the one project they own (built by | |
| 6 | + | //! [`super::projects`]). Naming follows the locked 2026-07-11 convention — real | |
| 7 | + | //! apps never appear under their real names; public-domain / CC0 media accounts | |
| 8 | + | //! get thematic handles, purely-generated copy gets invented names. | |
| 9 | + | ||
| 10 | + | use uuid::Uuid; | |
| 11 | + | ||
| 12 | + | use super::{SeedError, EXAMPLE_EMAIL_DOMAIN}; | |
| 13 | + | use crate::auth; | |
| 14 | + | use crate::db::{self, DbUser, Email, Username}; | |
| 15 | + | ||
| 16 | + | /// The single project a seeded creator owns. Pricing (per-`PricingKind`) and | |
| 17 | + | /// media land on this project's items in Phases 2-3; Phase 1 seeds the shell. | |
| 18 | + | pub struct ProjectSpec { | |
| 19 | + | /// URL slug (auto-suffixed on the rare collision by `create_project`). | |
| 20 | + | pub slug: &'static str, | |
| 21 | + | /// Public project title. | |
| 22 | + | pub title: &'static str, | |
| 23 | + | /// Project description (re-skinned from `content_seed.md`, no real-app names). | |
| 24 | + | pub description: &'static str, | |
| 25 | + | /// Enabled features; `create_project` derives `project_type` from these. | |
| 26 | + | pub features: &'static [&'static str], | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// One fabricated creator plus the project they own. | |
| 30 | + | struct CreatorSpec { | |
| 31 | + | /// Login handle and local-part of `{handle}@example.test`. | |
| 32 | + | handle: &'static str, | |
| 33 | + | /// Display name shown on the profile. | |
| 34 | + | display_name: &'static str, | |
| 35 | + | /// Short bio — matter-of-fact, no pomp, no real-app names. | |
| 36 | + | bio: &'static str, | |
| 37 | + | /// The project this creator owns. | |
| 38 | + | project: ProjectSpec, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// A creator after insertion, paired with the project spec still to be seeded. | |
| 42 | + | pub struct SeededCreator { | |
| 43 | + | /// The durable, publicly-visible creator row (`is_sandbox = FALSE`). | |
| 44 | + | pub user: DbUser, | |
| 45 | + | /// The project [`super::projects::seed_projects`] will create for this user. | |
| 46 | + | pub project: &'static ProjectSpec, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// The five Phase-1 creators. Together their projects span all four | |
| 50 | + | /// `PricingKind`s (realized via items in Phase 2). See the sprint doc | |
| 51 | + | /// `_private/docs/mnw/testnot-example-seed.md`. | |
| 52 | + | const ROSTER: &[CreatorSpec] = &[ | |
| 53 | + | CreatorSpec { | |
| 54 | + | handle: "openreels", | |
| 55 | + | display_name: "Open Reels", | |
| 56 | + | bio: "A small label restoring and reissuing public-domain recordings. \ | |
| 57 | + | Every release is free to share; pay what it's worth if it moves you.", | |
| 58 | + | project: ProjectSpec { | |
| 59 | + | slug: "restored-reels-vol-1", | |
| 60 | + | title: "Restored Reels, Vol. 1", | |
| 61 | + | description: "A first volume of public-domain recordings, cleaned up \ | |
| 62 | + | and remastered for easy listening. Free to stream and \ | |
| 63 | + | share; name your price if you'd like to support the \ | |
| 64 | + | restoration work.", | |
| 65 | + | features: &["audio"], | |
| 66 | + | }, | |
| 67 | + | }, | |
| 68 | + | CreatorSpec { | |
| 69 | + | handle: "deskriver", | |
| 70 | + | display_name: "Deskriver Tools", | |
| 71 | + | bio: "Source-available desktop tools that stay out of your way. No \ | |
| 72 | + | accounts required to run them, no engagement tricks, no cloud \ | |
| 73 | + | lock-in.", | |
| 74 | + | project: ProjectSpec { | |
| 75 | + | slug: "deskriver-suite", | |
| 76 | + | title: "Deskriver Suite", | |
| 77 | + | description: "A bundle of small, source-available desktop utilities. \ | |
| 78 | + | Runs offline with no account required, stores \ | |
| 79 | + | everything locally, and stays out of your way. One \ | |
| 80 | + | download, buy it once.", | |
| 81 | + | features: &["downloads", "license_keys"], | |
| 82 | + | }, | |
| 83 | + | }, | |
| 84 | + | CreatorSpec { | |
| 85 | + | handle: "stillfield", | |
| 86 | + | display_name: "Stillfield", | |
| 87 | + | bio: "Quiet landscape and still-life photography, released to the public \ | |
| 88 | + | domain. Download, print, remix — no permission needed.", | |
| 89 | + | project: ProjectSpec { | |
| 90 | + | slug: "cc0-field-library", | |
| 91 | + | title: "CC0 Field Library", | |
| 92 | + | description: "A growing library of landscape and still-life \ | |
| 93 | + | photography released to the public domain. Download the \ | |
| 94 | + | full-resolution files and use them however you like, no \ | |
| 95 | + | attribution required.", | |
| 96 | + | features: &["downloads"], | |
| 97 | + | }, | |
| 98 | + | }, | |
| 99 | + | CreatorSpec { | |
| 100 | + | handle: "marginalia", | |
| 101 | + | display_name: "Marginalia Press", | |
| 102 | + | bio: "A one-person press typesetting public-domain literature into clean, \ | |
| 103 | + | readable editions. New volumes for subscribers each month.", | |
| 104 | + | project: ProjectSpec { | |
| 105 | + | slug: "the-marginalia-reader", | |
| 106 | + | title: "The Marginalia Reader", | |
| 107 | + | description: "Public-domain literature, re-typeset into clean and \ | |
| 108 | + | readable editions. Subscribers get a new volume every \ | |
| 109 | + | month, delivered as EPUB and PDF.", | |
| 110 | + | features: &["text", "blog", "subscriptions"], | |
| 111 | + | }, | |
| 112 | + | }, | |
| 113 | + | CreatorSpec { | |
| 114 | + | handle: "commonshare", | |
| 115 | + | display_name: "Commonshare", | |
| 116 | + | bio: "A benefit account: everything here is free, and any support routed \ | |
| 117 | + | through it passes straight to community projects. Value to the \ | |
| 118 | + | commons, not to us.", | |
| 119 | + | project: ProjectSpec { | |
| 120 | + | slug: "commons-sampler", | |
| 121 | + | title: "Commons Sampler", | |
| 122 | + | description: "A free sampler of work shared through the Commonshare \ | |
| 123 | + | benefit account. Everything here is free to download; \ | |
| 124 | + | anything routed through the account passes straight to \ | |
| 125 | + | community projects.", | |
| 126 | + | features: &["downloads"], | |
| 127 | + | }, | |
| 128 | + | }, | |
| 129 | + | ]; | |
| 130 | + | ||
| 131 | + | /// Create every roster creator: a durable `@example.test` account with a profile. | |
| 132 | + | /// | |
| 133 | + | /// Returns the created accounts paired with their project specs so | |
| 134 | + | /// [`super::projects::seed_projects`] can build one project per creator. Called | |
| 135 | + | /// only from [`super::run`], after its prod-safety guards and the example-data | |
| 136 | + | /// reset. Idempotency comes from that reset (prior example accounts are wiped | |
| 137 | + | /// first), so this always inserts fresh rows. | |
| 138 | + | pub async fn seed_creators(pool: &sqlx::PgPool) -> Result<Vec<SeededCreator>, SeedError> { | |
| 139 | + | let mut seeded = Vec::with_capacity(ROSTER.len()); | |
| 140 | + | for spec in ROSTER { | |
| 141 | + | // Never-login account: hash a random string (the sandbox pattern). The | |
| 142 | + | // stored hash is a valid Argon2id hash no one holds the input to. | |
| 143 | + | let password_hash = | |
| 144 | + | auth::hash_password_async(format!("example-seed_{}", Uuid::new_v4())).await?; | |
| 145 | + | let username = Username::from_trusted(spec.handle.to_string()); | |
| 146 | + | let email = Email::from_trusted(format!("{}@{EXAMPLE_EMAIL_DOMAIN}", spec.handle)); | |
| 147 | + | ||
| 148 | + | let user = | |
| 149 | + | db::users::create_example_creator(pool, &username, &email, &password_hash).await?; | |
| 150 | + | let user = | |
| 151 | + | db::users::update_user_profile(pool, user.id, Some(spec.display_name), Some(spec.bio)) | |
| 152 | + | .await?; | |
| 153 | + | ||
| 154 | + | tracing::info!( | |
| 155 | + | handle = spec.handle, | |
| 156 | + | user_id = %user.id, | |
| 157 | + | "example seed: created creator" | |
| 158 | + | ); | |
| 159 | + | seeded.push(SeededCreator { user, project: &spec.project }); | |
| 160 | + | } | |
| 161 | + | Ok(seeded) | |
| 162 | + | } |
| @@ -0,0 +1,2 @@ | |||
| 1 | + | //! Seed at least one item per `ItemType`, across pricing kinds and tags. | |
| 2 | + | //! Filled in Phase 2. |
| @@ -0,0 +1,2 @@ | |||
| 1 | + | //! Fetch and upload public-domain / CC0 media (audio, images, docs) through the | |
| 2 | + | //! storage layer, per `media-manifest.toml`. Filled in Phase 3. |
| @@ -0,0 +1,243 @@ | |||
| 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 creators; | |
| 35 | + | pub mod items; | |
| 36 | + | pub mod media; | |
| 37 | + | pub mod projects; | |
| 38 | + | pub mod social; | |
| 39 | + | ||
| 40 | + | use sqlx::PgPool; | |
| 41 | + | ||
| 42 | + | /// Reserved email domain for every seeded example account. It is the marker the | |
| 43 | + | /// prod-safety guard uses to tell fabricated seed data from real users: the seed | |
| 44 | + | /// refuses to run when any account with a different domain exists, and the reset | |
| 45 | + | /// step only ever deletes accounts in this domain. (The `003_seed_demo` | |
| 46 | + | /// migration's `elena@example.com` is not a concern — migration | |
| 47 | + | /// `080_remove_demo_data` deletes it, so a freshly-migrated DB holds no | |
| 48 | + | /// non-example accounts.) | |
| 49 | + | pub const EXAMPLE_EMAIL_DOMAIN: &str = "example.test"; | |
| 50 | + | ||
| 51 | + | /// Why the example seed refused to run, or how it failed partway through. | |
| 52 | + | #[derive(Debug, thiserror::Error)] | |
| 53 | + | pub enum SeedError { | |
| 54 | + | /// `ALLOW_EXAMPLE_SEED` was not set to `1`. | |
| 55 | + | #[error("refusing to seed: ALLOW_EXAMPLE_SEED is not set to 1")] | |
| 56 | + | NotAllowed, | |
| 57 | + | /// `HOST_URL` did not resolve to an approved example host. | |
| 58 | + | #[error( | |
| 59 | + | "refusing to seed: HOST_URL {0:?} is not an approved example host \ | |
| 60 | + | (testnot.work or localhost only)" | |
| 61 | + | )] | |
| 62 | + | ProdHost(String), | |
| 63 | + | /// The database already holds non-example accounts. | |
| 64 | + | #[error( | |
| 65 | + | "refusing to seed: database holds {0} non-example account(s); the example \ | |
| 66 | + | seed only runs on a database that contains example data or nothing" | |
| 67 | + | )] | |
| 68 | + | RealUsersPresent(i64), | |
| 69 | + | /// A database error while checking guards, resetting, or seeding. | |
| 70 | + | #[error(transparent)] | |
| 71 | + | Db(#[from] sqlx::Error), | |
| 72 | + | /// An application error from a `db::*` helper or the password hasher while | |
| 73 | + | /// seeding content (the `db` layer returns [`crate::error::AppError`], not a | |
| 74 | + | /// raw `sqlx::Error`). | |
| 75 | + | #[error(transparent)] | |
| 76 | + | App(#[from] crate::error::AppError), | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Inputs for a seed run, resolved from the process environment and config. | |
| 80 | + | #[derive(Debug, Clone)] | |
| 81 | + | pub struct SeedOptions { | |
| 82 | + | /// `ALLOW_EXAMPLE_SEED=1` was present. | |
| 83 | + | pub allow_example_seed: bool, | |
| 84 | + | /// The configured `HOST_URL` (from [`crate::config::Config`]). | |
| 85 | + | pub host_url: String, | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | impl SeedOptions { | |
| 89 | + | /// Read the opt-in switch from the environment; take `host_url` from config. | |
| 90 | + | pub fn from_env(host_url: &str) -> Self { | |
| 91 | + | Self { | |
| 92 | + | allow_example_seed: std::env::var("ALLOW_EXAMPLE_SEED").ok().as_deref() == Some("1"), | |
| 93 | + | host_url: host_url.to_string(), | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Run the example seed: guards, reset, then content. | |
| 99 | + | /// | |
| 100 | + | /// Refuses (returns `Err`) unless every prod-safety guard passes. On success the | |
| 101 | + | /// database holds a fresh fabricated catalog. Callers should treat this as a | |
| 102 | + | /// one-shot: run it, then exit the process. | |
| 103 | + | pub async fn run(pool: &PgPool, opts: &SeedOptions) -> Result<(), SeedError> { | |
| 104 | + | // Guards 1 + 2: opt-in switch and approved host. Pure, no DB. | |
| 105 | + | check_static_guards(opts.allow_example_seed, &opts.host_url)?; | |
| 106 | + | ||
| 107 | + | // Guard 3: no real accounts present. | |
| 108 | + | let real = count_real_users(pool).await?; | |
| 109 | + | if real > 0 { | |
| 110 | + | return Err(SeedError::RealUsersPresent(real)); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | tracing::warn!( | |
| 114 | + | host = %opts.host_url, | |
| 115 | + | "example seed: all guards passed; wiping prior example data, then seeding" | |
| 116 | + | ); | |
| 117 | + | ||
| 118 | + | // Reset semantics: wipe-example-data-first (see module docs). | |
| 119 | + | let wiped = reset_example_data(pool).await?; | |
| 120 | + | tracing::info!(wiped_users = wiped, "example seed: cleared prior example accounts"); | |
| 121 | + | ||
| 122 | + | // Phase 1: durable creators, then one project each. Later phases layer items | |
| 123 | + | // (2), media (3), and blog/social (4) on top of these rows. | |
| 124 | + | let creators = creators::seed_creators(pool).await?; | |
| 125 | + | projects::seed_projects(pool, &creators).await?; | |
| 126 | + | tracing::warn!("example seed: items/media/blog/social pending (Phases 2-4)"); | |
| 127 | + | ||
| 128 | + | Ok(()) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// The env + host guards, computed from explicit inputs so they can be unit-tested | |
| 132 | + | /// without a process environment or a database. | |
| 133 | + | pub fn check_static_guards(allow_example_seed: bool, host_url: &str) -> Result<(), SeedError> { | |
| 134 | + | if !allow_example_seed { | |
| 135 | + | return Err(SeedError::NotAllowed); | |
| 136 | + | } | |
| 137 | + | if !is_approved_example_host(host_url) { | |
| 138 | + | return Err(SeedError::ProdHost(host_url.to_string())); | |
| 139 | + | } | |
| 140 | + | Ok(()) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// Whether `host_url`'s host is on the example-seed allowlist. Allowlist, not | |
| 144 | + | /// denylist: only `testnot.work` (and subdomains) and localhost pass, so the prod | |
| 145 | + | /// apex and any unknown host are refused by default. | |
| 146 | + | fn is_approved_example_host(host_url: &str) -> bool { | |
| 147 | + | let host = url::Url::parse(host_url) | |
| 148 | + | .ok() | |
| 149 | + | .and_then(|u| u.host_str().map(str::to_ascii_lowercase)); | |
| 150 | + | match host { | |
| 151 | + | Some(h) => { | |
| 152 | + | h == "testnot.work" | |
| 153 | + | || h.ends_with(".testnot.work") | |
| 154 | + | || h == "localhost" | |
| 155 | + | || h == "127.0.0.1" | |
| 156 | + | || h == "::1" | |
| 157 | + | } | |
| 158 | + | // Not a parseable URL with a host — refuse rather than guess. | |
| 159 | + | None => false, | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// Count accounts that are NOT example accounts (email domain outside | |
| 164 | + | /// [`EXAMPLE_EMAIL_DOMAIN`]). A non-zero result means real data is present and the | |
| 165 | + | /// seed must refuse. | |
| 166 | + | async fn count_real_users(pool: &PgPool) -> Result<i64, sqlx::Error> { | |
| 167 | + | sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE lower(email) NOT LIKE $1") | |
| 168 | + | .bind(format!("%@{EXAMPLE_EMAIL_DOMAIN}")) | |
| 169 | + | .fetch_one(pool) | |
| 170 | + | .await | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /// Delete every example account, cascading to its projects, items, and related | |
| 174 | + | /// rows. Returns the number of accounts removed. Safe only after the guards have | |
| 175 | + | /// confirmed no real data is present. | |
| 176 | + | async fn reset_example_data(pool: &PgPool) -> Result<u64, sqlx::Error> { | |
| 177 | + | let res = sqlx::query("DELETE FROM users WHERE lower(email) LIKE $1") | |
| 178 | + | .bind(format!("%@{EXAMPLE_EMAIL_DOMAIN}")) | |
| 179 | + | .execute(pool) | |
| 180 | + | .await?; | |
| 181 | + | Ok(res.rows_affected()) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | #[cfg(test)] | |
| 185 | + | mod tests { | |
| 186 | + | use super::*; | |
| 187 | + | ||
| 188 | + | #[test] | |
| 189 | + | fn refuses_without_allow_flag() { | |
| 190 | + | let err = check_static_guards(false, "https://testnot.work").unwrap_err(); | |
| 191 | + | assert!(matches!(err, SeedError::NotAllowed)); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn refuses_prod_apex_even_with_flag() { | |
| 196 | + | // The exact prod-shaped config: allowed switch on, prod host. | |
| 197 | + | let err = check_static_guards(true, "https://makenot.work").unwrap_err(); | |
| 198 | + | assert!(matches!(err, SeedError::ProdHost(_))); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[test] | |
| 202 | + | fn refuses_prod_subdomain() { | |
| 203 | + | for host in [ | |
| 204 | + | "https://www.makenot.work", | |
| 205 | + | "https://app.makenot.work", | |
| 206 | + | "https://makenot.work/creators", | |
| 207 | + | ] { | |
| 208 | + | let err = check_static_guards(true, host).unwrap_err(); | |
| 209 | + | assert!(matches!(err, SeedError::ProdHost(_)), "should refuse {host}"); | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | #[test] | |
| 214 | + | fn allows_testnot_and_localhost() { | |
| 215 | + | for host in [ | |
| 216 | + | "https://testnot.work", | |
| 217 | + | "https://testnot.work/", | |
| 218 | + | "https://sub.testnot.work", | |
| 219 | + | "http://localhost:3000", | |
| 220 | + | "http://127.0.0.1:8080", | |
| 221 | + | ] { | |
| 222 | + | assert!( | |
| 223 | + | check_static_guards(true, host).is_ok(), | |
| 224 | + | "should allow {host}" | |
| 225 | + | ); | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | #[test] | |
| 230 | + | fn refuses_lookalike_hosts() { | |
| 231 | + | // Substring tricks must not slip past the allowlist. | |
| 232 | + | for host in [ | |
| 233 | + | "https://testnot.work.evil.com", | |
| 234 | + | "https://nottestnot.work", | |
| 235 | + | "https://testnot-work.com", | |
| 236 | + | "not-a-url", | |
| 237 | + | "", | |
| 238 | + | ] { | |
| 239 | + | let err = check_static_guards(true, host).unwrap_err(); | |
| 240 | + | assert!(matches!(err, SeedError::ProdHost(_)), "should refuse {host}"); | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | } |
| @@ -0,0 +1,44 @@ | |||
| 1 | + | //! Seed one project per creator (their [`ProjectSpec`]), spread across genres. | |
| 2 | + | //! | |
| 3 | + | //! Each project is public the moment it is inserted: `create_project` leaves | |
| 4 | + | //! `is_public` at its `true` default and never sets `is_sandbox`, so the pair | |
| 5 | + | //! (durable creator + project) clears the only two public-visibility gates. The | |
| 6 | + | //! intended per-project `PricingKind` is realized via items in Phase 2; here we | |
| 7 | + | //! only establish the project shell and its `features`. | |
| 8 | + | ||
| 9 | + | use super::creators::SeededCreator; | |
| 10 | + | use super::SeedError; | |
| 11 | + | use crate::db::{self, Slug}; | |
| 12 | + | ||
| 13 | + | /// Create the one project each seeded creator owns. | |
| 14 | + | /// | |
| 15 | + | /// Called from [`super::run`] immediately after [`super::creators::seed_creators`], | |
| 16 | + | /// with the creators it returned. `create_project` auto-suffixes any slug | |
| 17 | + | /// collision and defaults the project to public, so no publish step is needed. | |
| 18 | + | pub async fn seed_projects( | |
| 19 | + | pool: &sqlx::PgPool, | |
| 20 | + | creators: &[SeededCreator], | |
| 21 | + | ) -> Result<(), SeedError> { | |
| 22 | + | for creator in creators { | |
| 23 | + | let spec = creator.project; | |
| 24 | + | let slug = Slug::from_trusted(spec.slug.to_string()); | |
| 25 | + | let features: Vec<String> = spec.features.iter().map(|f| f.to_string()).collect(); | |
| 26 | + | ||
| 27 | + | let project = db::projects::create_project( | |
| 28 | + | pool, | |
| 29 | + | creator.user.id, | |
| 30 | + | &slug, | |
| 31 | + | spec.title, | |
| 32 | + | Some(spec.description), | |
| 33 | + | &features, | |
| 34 | + | ) | |
| 35 | + | .await?; | |
| 36 | + | ||
| 37 | + | tracing::info!( | |
| 38 | + | slug = %project.slug, | |
| 39 | + | user_id = %creator.user.id, | |
| 40 | + | "example seed: created project" | |
| 41 | + | ); | |
| 42 | + | } | |
| 43 | + | Ok(()) | |
| 44 | + | } |
| @@ -0,0 +1,2 @@ | |||
| 1 | + | //! Seed follows/fans (and reviews/comments where the surface exists) so counts | |
| 2 | + | //! render. Filled in Phase 4. |
| @@ -3,6 +3,7 @@ mod auth; | |||
| 3 | 3 | mod csrf_coverage; | |
| 4 | 4 | mod custom_pages; | |
| 5 | 5 | mod sso; | |
| 6 | + | mod seed_examples; | |
| 6 | 7 | mod discover; | |
| 7 | 8 | mod embeds; | |
| 8 | 9 | mod enum_drift; |
| @@ -0,0 +1,137 @@ | |||
| 1 | + | //! DB-layer contract tests for the `--seed-examples` flow (`seed::run`, Phase 1). | |
| 2 | + | //! | |
| 3 | + | //! These exercise the seed against real Postgres to prove the invariants the | |
| 4 | + | //! example marketplace rests on: | |
| 5 | + | //! | |
| 6 | + | //! 1. It clears the no-real-users guard on a *freshly-migrated* DB (migration | |
| 7 | + | //! `080_remove_demo_data` leaves no non-example accounts) and creates the | |
| 8 | + | //! full roster. | |
| 9 | + | //! 2. Every seeded creator+project is publicly visible — `is_sandbox = FALSE` | |
| 10 | + | //! on the user and `is_public = true` on the project, the only two gates the | |
| 11 | + | //! discover feed applies. The discover predicate returning them is the proof. | |
| 12 | + | //! 3. Idempotency: a second `run()` wipes the prior example data first, so | |
| 13 | + | //! counts stay fixed instead of doubling. | |
| 14 | + | //! 4. The prod-host guard still refuses even with the opt-in flag set. | |
| 15 | + | ||
| 16 | + | use crate::harness::db::TestDb; | |
| 17 | + | use makenotwork::seed::{self, SeedOptions}; | |
| 18 | + | ||
| 19 | + | /// The Phase-1 roster size (see `seed::creators::ROSTER`). | |
| 20 | + | const SEEDED_CREATORS: i64 = 5; | |
| 21 | + | ||
| 22 | + | /// Guard-passing options for a test run: opt-in on, approved example host. | |
| 23 | + | fn testnot_opts() -> SeedOptions { | |
| 24 | + | SeedOptions { | |
| 25 | + | allow_example_seed: true, | |
| 26 | + | host_url: "https://testnot.work".to_string(), | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | async fn count_example_creators(pool: &sqlx::PgPool) -> i64 { | |
| 31 | + | sqlx::query_scalar( | |
| 32 | + | "SELECT COUNT(*) FROM users \ | |
| 33 | + | WHERE lower(split_part(email, '@', 2)) = 'example.test' AND is_sandbox = FALSE", | |
| 34 | + | ) | |
| 35 | + | .fetch_one(pool) | |
| 36 | + | .await | |
| 37 | + | .expect("count example creators") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | async fn count_public_example_projects(pool: &sqlx::PgPool) -> i64 { | |
| 41 | + | sqlx::query_scalar( | |
| 42 | + | "SELECT COUNT(*) FROM projects p \ | |
| 43 | + | JOIN users u ON u.id = p.user_id \ | |
| 44 | + | WHERE lower(split_part(u.email, '@', 2)) = 'example.test' AND p.is_public = TRUE", | |
| 45 | + | ) | |
| 46 | + | .fetch_one(pool) | |
| 47 | + | .await | |
| 48 | + | .expect("count public example projects") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Slugs of seeded projects that clear the *exact* public-discover gate | |
| 52 | + | /// (`p.is_public AND u.is_sandbox = FALSE`, per `db::discover::discover_projects`). | |
| 53 | + | /// Mirrors that predicate in SQL because the `discover` module is `pub(crate)` and | |
| 54 | + | /// so unreachable from this external test crate. | |
| 55 | + | async fn discover_visible_example_slugs(pool: &sqlx::PgPool) -> Vec<String> { | |
| 56 | + | sqlx::query_scalar::<_, String>( | |
| 57 | + | "SELECT p.slug::text FROM projects p \ | |
| 58 | + | JOIN users u ON u.id = p.user_id \ | |
| 59 | + | WHERE p.is_public = TRUE AND u.is_sandbox = FALSE \ | |
| 60 | + | AND lower(split_part(u.email, '@', 2)) = 'example.test' \ | |
| 61 | + | ORDER BY p.slug", | |
| 62 | + | ) | |
| 63 | + | .fetch_all(pool) | |
| 64 | + | .await | |
| 65 | + | .expect("discover-visible example slugs") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | #[tokio::test] | |
| 69 | + | async fn seeds_publicly_visible_creators_and_projects() { | |
| 70 | + | let db = TestDb::new().await; | |
| 71 | + | ||
| 72 | + | // Precondition the guard relies on: a freshly-migrated DB holds no real | |
| 73 | + | // accounts (080_remove_demo_data cleared the 003 demo seed). | |
| 74 | + | let real: i64 = sqlx::query_scalar( | |
| 75 | + | "SELECT COUNT(*) FROM users WHERE lower(email) NOT LIKE '%@example.test'", | |
| 76 | + | ) | |
| 77 | + | .fetch_one(&db.pool) | |
| 78 | + | .await | |
| 79 | + | .expect("count real users"); | |
| 80 | + | assert_eq!(real, 0, "a migrated DB should hold no non-example accounts"); | |
| 81 | + | ||
| 82 | + | seed::run(&db.pool, &testnot_opts()) | |
| 83 | + | .await | |
| 84 | + | .expect("seed should run on a clean migrated DB"); | |
| 85 | + | ||
| 86 | + | // All five creators exist and are non-sandbox (publicly visible), and each | |
| 87 | + | // owns a public project. | |
| 88 | + | assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS); | |
| 89 | + | assert_eq!(count_public_example_projects(&db.pool).await, SEEDED_CREATORS); | |
| 90 | + | ||
| 91 | + | // Public visibility proof: all five projects clear the discover gate. | |
| 92 | + | let slugs = discover_visible_example_slugs(&db.pool).await; | |
| 93 | + | assert_eq!(slugs.len() as i64, SEEDED_CREATORS); | |
| 94 | + | for slug in [ | |
| 95 | + | "restored-reels-vol-1", | |
| 96 | + | "deskriver-suite", | |
| 97 | + | "cc0-field-library", | |
| 98 | + | "the-marginalia-reader", | |
| 99 | + | "commons-sampler", | |
| 100 | + | ] { | |
| 101 | + | assert!( | |
| 102 | + | slugs.iter().any(|s| s == slug), | |
| 103 | + | "discover-visible set missing seeded project {slug}" | |
| 104 | + | ); | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | #[tokio::test] | |
| 109 | + | async fn seed_is_idempotent() { | |
| 110 | + | let db = TestDb::new().await; | |
| 111 | + | ||
| 112 | + | seed::run(&db.pool, &testnot_opts()).await.expect("first seed"); | |
| 113 | + | seed::run(&db.pool, &testnot_opts()).await.expect("second seed"); | |
| 114 | + | ||
| 115 | + | // Re-running wipes prior example data first, so counts stay fixed. | |
| 116 | + | assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS); | |
| 117 | + | assert_eq!(count_public_example_projects(&db.pool).await, SEEDED_CREATORS); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | #[tokio::test] | |
| 121 | + | async fn refuses_on_prod_host() { | |
| 122 | + | let db = TestDb::new().await; | |
| 123 | + | ||
| 124 | + | let err = seed::run( | |
| 125 | + | &db.pool, | |
| 126 | + | &SeedOptions { | |
| 127 | + | allow_example_seed: true, | |
| 128 | + | host_url: "https://makenot.work".to_string(), | |
| 129 | + | }, | |
| 130 | + | ) | |
| 131 | + | .await | |
| 132 | + | .expect_err("prod host must be refused"); | |
| 133 | + | assert!(matches!(err, seed::SeedError::ProdHost(_))); | |
| 134 | + | ||
| 135 | + | // Nothing was created, and the demo account is untouched. | |
| 136 | + | assert_eq!(count_example_creators(&db.pool).await, 0); | |
| 137 | + | } |