//! Login-capable accounts and the OAuth client the Multithreaded browser //! harness needs, seeded into the example catalog on testnot. //! //! Everything else in [`crate::seed`] builds a catalog for a human to look at: //! the accounts it creates hash a random password nobody holds, because nothing //! is meant to log in as them. The browser axis of an audit run needs the //! opposite. It drives a real browser through mt's write paths (thread create, //! reply, flag, one moderation action), every one of which requires an mt //! session, and mt is an OAuth relying party against this server. So it needs //! accounts whose password is known and an OAuth client whose `redirect_uri` //! points at the mt instance doing the driving. //! //! # Why this lives in the seed rather than beside the instance //! //! `mnw-testnot-seed.sh` drops every schema before it reseeds. Anything created //! by hand — a `sync_apps` row, three accounts — is gone the next time testnot //! is reset, and the harness starts failing at login with nothing in the diff to //! explain it. Seeding them here makes the reset cycle reproduce them exactly, //! which is the property the harness actually depends on: the same accounts, //! with the same ids, after every reseed. //! //! The account ids are fixed constants rather than generated, because mt's own //! seed pre-assigns community roles by `mnw_account_id`. See //! `multithreaded/src/seed.rs`, which mirrors [`FAN_ACCOUNT_ID`], //! [`CREATOR_ACCOUNT_ID`] and [`OWNER_ACCOUNT_ID`] verbatim; the pair has to //! move together. //! //! # Why it is opt-in even inside the seed //! //! The phase runs only when both [`PASSWORD_ENV`] and [`REDIRECT_URI_ENV`] are //! set, and skips with a warning otherwise. Neither value is in this repo: the //! password is a credential, and the redirect URI names a tailnet host that a //! public repo has no reason to carry. A testnot box that has them set (in its //! `EnvironmentFile`, which the reseed passes through) gets the harness //! accounts; anywhere else the seed produces exactly what it produced before. //! //! The prod guards in [`crate::seed::run`] still apply, and they run first: this //! phase is unreachable without `ALLOW_EXAMPLE_SEED=1` on an approved host with //! no real accounts present. use uuid::Uuid; use super::{EXAMPLE_EMAIL_DOMAIN, SeedError}; use crate::auth; use crate::db::{self, UserId}; /// Env var holding the shared password for the three harness accounts. pub const PASSWORD_ENV: &str = "MT_HARNESS_PASSWORD"; /// Env var holding the OAuth `redirect_uri` to register for the harness client, /// i.e. `{mt base url}/auth/callback` for the write-enabled mt instance. pub const REDIRECT_URI_ENV: &str = "MT_HARNESS_REDIRECT_URI"; /// The harness client's `client_id` (= `sync_apps.api_key`, which is stored /// hashed). Fixed rather than generated so mt's `OAUTH_CLIENT_ID` is a constant /// its env file can carry across reseeds. Not a secret: this is a public PKCE /// client, holding the id alone grants nothing. pub const CLIENT_ID: &str = "mt-astra-harness"; /// Display name of the registered app, shown on the OAuth consent screen. const CLIENT_NAME: &str = "Multithreaded (astra harness)"; /// Fan+ subscriber, not a creator. Exercises the `fan_plus` half of /// `UserPerks::effective_plus` on its own. pub const FAN_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_f001); /// Creator (top tier). Owns the harness OAuth client, and exercises the /// `is_creator` auto-grant half of `effective_plus`. pub const CREATOR_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_f002); /// Plain account with no MNW perks at all. Its privileges are mt-side only /// (community Owner + Moderator), which is what makes it the moderation actor. pub const OWNER_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_f003); /// One harness account: fixed id, handle, and what perks it carries on MNW. struct AccountSpec { id: Uuid, handle: &'static str, display_name: &'static str, /// `users.creator_tier`, which is what `/oauth/userinfo` reports as /// `perks.is_creator`. `None` leaves the account a plain fan. creator_tier: Option<&'static str>, /// Whether to seed an active `fan_plus_subscriptions` row. fan_plus: bool, } const ACCOUNTS: &[AccountSpec] = &[ AccountSpec { id: FAN_ACCOUNT_ID, handle: "harness_fan", display_name: "Harness Fan", creator_tier: None, fan_plus: true, }, AccountSpec { id: CREATOR_ACCOUNT_ID, handle: "harness_creator", display_name: "Harness Creator", creator_tier: Some("everything"), fan_plus: false, }, AccountSpec { id: OWNER_ACCOUNT_ID, handle: "harness_owner", display_name: "Harness Owner", creator_tier: None, fan_plus: false, }, ]; /// The two values the phase needs from the environment. Absent either one, the /// phase does not run. #[derive(Clone)] pub struct HarnessOptions { /// Shared password for every harness account. pub password: String, /// The mt callback URL to register on the harness OAuth client. pub redirect_uri: String, } /// Hand-written so the password cannot reach a log through /// [`crate::seed::SeedOptions`]'s derived `Debug`. impl std::fmt::Debug for HarnessOptions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("HarnessOptions") .field("password", &"") .field("redirect_uri", &self.redirect_uri) .finish() } } impl HarnessOptions { /// Read both values from the environment; `None` when either is unset or /// empty, which is the signal to skip the phase. pub fn from_env() -> Option { let password = std::env::var(PASSWORD_ENV).ok().filter(|s| !s.is_empty())?; let redirect_uri = std::env::var(REDIRECT_URI_ENV) .ok() .filter(|s| !s.is_empty())?; Some(Self { password, redirect_uri, }) } } /// Seed the three harness accounts and the harness OAuth client. /// /// Called from [`super::run`] after the catalog phases, so the accounts land in /// a database the guards have already cleared. Re-runnable: every write is an /// upsert keyed on the fixed ids, so a reseed that did not drop the schema lands /// in the same state as one that did. pub async fn seed_harness(pool: &sqlx::PgPool, opts: &HarnessOptions) -> Result<(), SeedError> { // One hash for all three: the password is shared, and Argon2 is the // expensive part of this phase. let password_hash = auth::hash_password_async(opts.password.clone()).await?; for spec in ACCOUNTS { seed_account(pool, spec, &password_hash).await?; if spec.fan_plus { seed_fan_plus(pool, spec.id).await?; } } seed_client(pool, &opts.redirect_uri).await?; tracing::warn!( accounts = ACCOUNTS.len(), client_id = CLIENT_ID, redirect_uri = %opts.redirect_uri, "example seed: harness accounts and OAuth client seeded (login-capable)" ); Ok(()) } /// Insert (or reset) one harness account at its fixed id. /// /// `email_verified` mirrors [`db::users::create_example_creator`], and /// `is_sandbox` is left FALSE for the reason it is there: a sandbox account is /// refused by `SessionUser::check_not_sandbox` on routes the harness may well /// walk. `can_create_projects` follows the creator tier instead of being TRUE /// for everyone, so the fan account is actually a fan — three accounts that all /// hold creator powers would test one role three times. async fn seed_account( pool: &sqlx::PgPool, spec: &AccountSpec, password_hash: &str, ) -> Result<(), SeedError> { let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", spec.handle.replace('_', "-")); sqlx::query( r" INSERT INTO users ( id, username, email, password_hash, display_name, can_create_projects, email_verified, creator_tier ) VALUES ($1, $2, $3, $4, $5, $6 IS NOT NULL, TRUE, $6) ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, display_name = EXCLUDED.display_name, creator_tier = EXCLUDED.creator_tier, can_create_projects = EXCLUDED.can_create_projects, -- A harness run that tripped the lockout must not survive the -- reseed: the whole point of the reset is a known state. failed_login_attempts = 0, locked_until = NULL, suspended_at = NULL, deactivated_at = NULL ", ) .bind(spec.id) .bind(spec.handle) .bind(&email) .bind(password_hash) .bind(spec.display_name) .bind(spec.creator_tier) .execute(pool) .await?; tracing::info!( handle = spec.handle, user_id = %spec.id, "example seed: harness account" ); Ok(()) } /// Give an account an active Fan+ subscription without touching Stripe. /// /// `is_fan_plus_active` reads status alone, so an `active` row is the whole /// requirement. The Stripe ids are fabricated and deliberately marked: nothing /// on testnot talks to live Stripe, and a `harness_` prefix makes a stray row /// obvious if one is ever found somewhere it should not be. async fn seed_fan_plus(pool: &sqlx::PgPool, user_id: Uuid) -> Result<(), SeedError> { sqlx::query( r" INSERT INTO fan_plus_subscriptions ( user_id, stripe_subscription_id, stripe_customer_id, status ) VALUES ($1, $2, $3, 'active') ON CONFLICT (user_id) DO UPDATE SET status = 'active', canceled_at = NULL ", ) .bind(user_id) .bind(format!("sub_harness_{user_id}")) .bind(format!("cus_harness_{user_id}")) .execute(pool) .await?; Ok(()) } /// Register the harness OAuth client, owned by the creator account. /// /// The `redirect_uri` must be registered explicitly: `validate_redirect_uri` /// waves through localhost only, and the harness instance is reached over the /// tailnet by name, which is not localhost. async fn seed_client(pool: &sqlx::PgPool, redirect_uri: &str) -> Result<(), SeedError> { let existing = db::synckit::get_sync_app_by_api_key(pool, CLIENT_ID).await?; let app = match existing { Some(app) => app, None => { db::synckit::create_sync_app( pool, UserId::from(CREATOR_ACCOUNT_ID), CLIENT_NAME, CLIENT_ID, None, None, ) .await? } }; sqlx::query("UPDATE sync_apps SET redirect_uris = $2, is_active = TRUE WHERE id = $1") .bind(app.id) .bind(vec![redirect_uri.to_string()]) .execute(pool) .await?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn account_ids_are_the_ones_mt_seeds() { // These three constants are copied into multithreaded/src/seed.rs, which // pre-assigns community roles by mnw_account_id. A change here that is // not mirrored there silently drops the harness's moderator rights, and // the failure shows up as a 403 in a browser run rather than as a build // error. Pin the literals so the edit cannot be quiet. assert_eq!( FAN_ACCOUNT_ID.to_string(), "00000000-0000-0000-0000-00000000f001" ); assert_eq!( CREATOR_ACCOUNT_ID.to_string(), "00000000-0000-0000-0000-00000000f002" ); assert_eq!( OWNER_ACCOUNT_ID.to_string(), "00000000-0000-0000-0000-00000000f003" ); } #[test] fn harness_emails_stay_inside_the_reserved_domain() { // The seed's reset step only deletes @example.test accounts, and its // third guard refuses to run at all when a non-example account exists. // A harness handle that produced an address outside the domain would // therefore both survive resets and block the next seed. for spec in ACCOUNTS { let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", spec.handle.replace('_', "-")); assert!(email.ends_with("@example.test"), "{email}"); } } }