max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+831 insertions,
-15 deletions
| @@ -5010,13 +5010,9 @@ | |||
| 5010 | 5010 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5011 | 5011 | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" | |
| 5012 | 5012 | ||
| 5013 | - | [[patch.unused]] | |
| 5014 | - | name = "supernote-push" | |
| 5015 | - | version = "0.1.0" | |
| 5016 | - | ||
| 5017 | 5013 | [[patch.unused]] | |
| 5018 | 5014 | name = "synckit-client" | |
| 5019 | - | version = "0.6.0" | |
| 5015 | + | version = "0.7.0" | |
| 5020 | 5016 | ||
| 5021 | 5017 | [[patch.unused]] | |
| 5022 | 5018 | name = "synckit-config" |
| @@ -35,3 +35,10 @@ | |||
| 35 | 35 | # SMTP_USER= | |
| 36 | 36 | # SMTP_PASS= | |
| 37 | 37 | # FROM_EMAIL=noreply@makenot.work | |
| 38 | + | ||
| 39 | + | # Optional: the multithreaded browser-harness phase of `--seed-examples`. Set | |
| 40 | + | # both on a testnot box to have the reseed recreate the login-capable harness | |
| 41 | + | # accounts and the mt OAuth client; leave unset anywhere else and the seed | |
| 42 | + | # builds the catalog alone. See multithreaded/deploy/README.md. | |
| 43 | + | # MT_HARNESS_PASSWORD= | |
| 44 | + | # MT_HARNESS_REDIRECT_URI=https://<mt-host>/auth/callback |
| @@ -100,3 +100,68 @@ | |||
| 100 | 100 | ||
| 101 | 101 | Rollback is the MNW server's rollback: the previous bundle still holds the | |
| 102 | 102 | previous mt binary. | |
| 103 | + | ||
| 104 | + | ## astra: the write-enabled harness instance | |
| 105 | + | ||
| 106 | + | `deploy/deploy.sh` installs mt on astra. That instance is not a second staging | |
| 107 | + | box in general; it exists so an audit run's browser lens can **write**. | |
| 108 | + | ||
| 109 | + | The public demo (testnot) is deliberately read-only and no-login, which leaves | |
| 110 | + | every mutation path browser-unverified, and mutation paths are where | |
| 111 | + | rendered-behaviour bugs live: the CSRF token round-tripping through a real form | |
| 112 | + | submission, HX swap targets, optimistic UI, the compiled islands once | |
| 113 | + | `static/dist` is loaded. Verifying those needs a session, and a session needs a | |
| 114 | + | real OAuth login. So the harness instance is reachable over the tailnet only, | |
| 115 | + | logs in against testnot's MNW, and is wiped between runs. | |
| 116 | + | ||
| 117 | + | Reached at `https://astra.tailc6b3e1.ts.net:3443`, published by `tailscale serve` | |
| 118 | + | (`tailscale serve --bg --https=3443 http://127.0.0.1:3400`) and reachable from | |
| 119 | + | the tailnet only. mt itself binds `127.0.0.1`, so the app port is on no other | |
| 120 | + | interface and the tailnet proxy is the only way in. | |
| 121 | + | ||
| 122 | + | TLS is not optional here, and finding that out is worth two minutes of someone | |
| 123 | + | else's time: `config.rs` refuses to boot a non-loopback deployment on plain | |
| 124 | + | `http`, because that would carry OAuth codes and session cookies in the clear. | |
| 125 | + | `tailscale serve` is what supplies the certificate — astra's own Caddy already | |
| 126 | + | holds `:443` for something else, hence the `:3443`. Set `COOKIE_SECURE=true` | |
| 127 | + | to match. | |
| 128 | + | ||
| 129 | + | ### What it depends on, on the MNW side | |
| 130 | + | ||
| 131 | + | Login is the whole point, so mt is only half the setup. The other half is | |
| 132 | + | testnot's example seed, whose harness phase creates the accounts and the OAuth | |
| 133 | + | client (`server/src/seed/harness.rs`): | |
| 134 | + | ||
| 135 | + | | Piece | Value | | |
| 136 | + | |-------|-------| | |
| 137 | + | | `OAUTH_CLIENT_ID` | `mt-astra-harness` (a `sync_apps` row; PKCE, no secret) | | |
| 138 | + | | Registered redirect | `https://astra.tailc6b3e1.ts.net:3443/auth/callback` | | |
| 139 | + | | Accounts | `harness_fan` (Fan+), `harness_creator` (creator), `harness_owner` | | |
| 140 | + | | Password | shared, from `MT_HARNESS_PASSWORD` on the testnot box | | |
| 141 | + | ||
| 142 | + | That phase runs only when `MT_HARNESS_PASSWORD` and `MT_HARNESS_REDIRECT_URI` | |
| 143 | + | are both set in testnot's `EnvironmentFile` (`/etc/mnw/makenotwork.env`), which | |
| 144 | + | `mnw-testnot-seed.sh` already passes through. Neither value is in the repo: one | |
| 145 | + | is a credential, the other names a tailnet host. | |
| 146 | + | ||
| 147 | + | The three accounts carry fixed MNW ids, and mt's own seed (`src/seed.rs`) | |
| 148 | + | assigns their community roles by `mnw_account_id` — Owner and Moderator for | |
| 149 | + | `harness_owner` — because login upserts identity and perks but never | |
| 150 | + | membership. The two id lists have to be edited together; the MNW side has a test | |
| 151 | + | pinning the literals. | |
| 152 | + | ||
| 153 | + | ### Resetting it | |
| 154 | + | ||
| 155 | + | ``` | |
| 156 | + | deploy/reset-astra.sh | |
| 157 | + | ``` | |
| 158 | + | ||
| 159 | + | Stops the service, drops every schema, re-migrates, re-seeds, and then asserts | |
| 160 | + | the harness preconditions (three accounts, an Owner membership, threads | |
| 161 | + | present). A run that logs in and finds no membership fails at the first write | |
| 162 | + | with a 403, which reads like a permissions bug rather than an unfinished seed, | |
| 163 | + | so the reset verifies rather than assuming. | |
| 164 | + | ||
| 165 | + | This resets mt only. The identities live on testnot and come back from its own | |
| 166 | + | reseed; the two are independent on purpose, because mt's state is disposable and | |
| 167 | + | the accounts behind it are not. |
| @@ -35,16 +35,27 @@ | |||
| 35 | 35 | --exclude frontend/node_modules/ \ | |
| 36 | 36 | ./ $SERVER:~/$SRC_DIR/ | |
| 37 | 37 | ||
| 38 | - | # Sync shared dependencies (Cargo.toml references ../shared/ from ~/src/multithreaded/) | |
| 39 | - | local shared_dir | |
| 40 | - | shared_dir="$(cd ../shared && pwd)" | |
| 41 | - | for dep in docengine tagtree s3-storage livechat; do | |
| 42 | - | local dep_dir="$shared_dir/$dep" | |
| 43 | - | if [ -d "$dep_dir" ]; then | |
| 44 | - | echo "[rsync] Syncing shared dep: $dep" | |
| 45 | - | ssh $SERVER "mkdir -p ~/src/shared/$dep" | |
| 38 | + | # Sync the sibling crates the manifests point at (`../shared/...` resolves | |
| 39 | + | # the same way from ~/src/multithreaded on the build host). | |
| 40 | + | # | |
| 41 | + | # Read the list out of the manifests rather than hardcoding it. The hardcoded | |
| 42 | + | # version listed docengine and livechat, which mt no longer takes by path, | |
| 43 | + | # and omitted pom-contract, which it does: the build failed on the build host | |
| 44 | + | # for a dependency the deploy script had never heard of. A list derived from | |
| 45 | + | # the manifests cannot drift from them. | |
| 46 | + | local dep_paths dep_path dep | |
| 47 | + | dep_paths="$(grep -ho 'path = "\.\./[^"]*"' Cargo.toml crates/*/Cargo.toml \ | |
| 48 | + | | sed 's/path = "//; s/"$//' | sort -u)" | |
| 49 | + | for dep_path in $dep_paths; do | |
| 50 | + | dep="${dep_path#../}" | |
| 51 | + | if [ -d "$dep_path" ]; then | |
| 52 | + | echo "[rsync] Syncing sibling crate: $dep" | |
| 53 | + | ssh $SERVER "mkdir -p ~/src/$dep" | |
| 46 | 54 | rsync -az --delete --exclude target --exclude .git \ | |
| 47 | - | "$dep_dir/" "$SERVER:~/src/shared/$dep/" | |
| 55 | + | "$dep_path/" "$SERVER:~/src/$dep/" | |
| 56 | + | else | |
| 57 | + | echo "[rsync] ERROR: manifest points at $dep_path, which does not exist" >&2 | |
| 58 | + | exit 1 | |
| 48 | 59 | fi | |
| 49 | 60 | done | |
| 50 | 61 | echo "[rsync] Done" |
| @@ -18,6 +18,7 @@ | |||
| 18 | 18 | // --- users | |
| 19 | 19 | ||
| 20 | 20 | let users = seed_users(pool).await; | |
| 21 | + | let harness = seed_harness_users(pool).await; | |
| 21 | 22 | ||
| 22 | 23 | // --- communities | |
| 23 | 24 | ||
| @@ -146,6 +147,17 @@ | |||
| 146 | 147 | seed_membership_upsert(pool, users[1].id, rust_id, CommunityRole::Member).await; | |
| 147 | 148 | seed_membership_upsert(pool, users[1].id, music_id, CommunityRole::Moderator).await; | |
| 148 | 149 | ||
| 150 | + | // The harness accounts get the roles the browser axis needs to write: | |
| 151 | + | // ordinary membership for thread/reply/flag, and Owner + Moderator on two | |
| 152 | + | // different communities so a moderation action has somewhere to land. The | |
| 153 | + | // roles are seeded rather than granted at login because login only ever | |
| 154 | + | // upserts identity and MNW perks, never membership. | |
| 155 | + | for account in &harness { | |
| 156 | + | seed_membership_upsert(pool, account.id, rust_id, account.rust_role).await; | |
| 157 | + | seed_membership_upsert(pool, account.id, music_id, account.music_role).await; | |
| 158 | + | seed_membership_upsert(pool, account.id, selfhosted_id, CommunityRole::Member).await; | |
| 159 | + | } | |
| 160 | + | ||
| 149 | 161 | // --- Rust community: a few threads | |
| 150 | 162 | ||
| 151 | 163 | let welcome_id = seed_thread( | |
| @@ -225,6 +237,88 @@ | |||
| 225 | 237 | id: Uuid, | |
| 226 | 238 | } | |
| 227 | 239 | ||
| 240 | + | /// A harness account: the local row plus the roles it carries per community. | |
| 241 | + | struct HarnessUser { | |
| 242 | + | id: Uuid, | |
| 243 | + | rust_role: CommunityRole, | |
| 244 | + | music_role: CommunityRole, | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | /// The three accounts the browser harness logs in as. | |
| 248 | + | /// | |
| 249 | + | /// `mnw_account_id` is the join to MNW and these values are fixed on both | |
| 250 | + | /// sides: the MNW example seed creates the same three ids in | |
| 251 | + | /// `server/src/seed/harness.rs`, which is what lets roles be assigned here | |
| 252 | + | /// before anyone has logged in. The two lists have to be edited together, and | |
| 253 | + | /// the MNW side has a test pinning the literals to make that hard to forget. | |
| 254 | + | /// | |
| 255 | + | /// `is_fan_plus`/`is_creator` mirror the perks MNW will report at login. They | |
| 256 | + | /// are denormalised onto the row for post rendering, and login overwrites them | |
| 257 | + | /// from userinfo, so seeding them wrong is cosmetic rather than a privilege | |
| 258 | + | /// question. Seed them right anyway: a pre-login render of a seeded post is one | |
| 259 | + | /// of the things the harness looks at. | |
| 260 | + | async fn seed_harness_users(pool: &PgPool) -> Vec<HarnessUser> { | |
| 261 | + | let harness_data = [ | |
| 262 | + | ( | |
| 263 | + | "00000000-0000-0000-0000-00000000f001", | |
| 264 | + | "harness_fan", | |
| 265 | + | "Harness Fan", | |
| 266 | + | true, | |
| 267 | + | false, | |
| 268 | + | CommunityRole::Member, | |
| 269 | + | CommunityRole::Member, | |
| 270 | + | ), | |
| 271 | + | ( | |
| 272 | + | "00000000-0000-0000-0000-00000000f002", | |
| 273 | + | "harness_creator", | |
| 274 | + | "Harness Creator", | |
| 275 | + | false, | |
| 276 | + | true, | |
| 277 | + | CommunityRole::Member, | |
| 278 | + | CommunityRole::Member, | |
| 279 | + | ), | |
| 280 | + | ( | |
| 281 | + | "00000000-0000-0000-0000-00000000f003", | |
| 282 | + | "harness_owner", | |
| 283 | + | "Harness Owner", | |
| 284 | + | false, | |
| 285 | + | false, | |
| 286 | + | CommunityRole::Owner, | |
| 287 | + | CommunityRole::Moderator, | |
| 288 | + | ), | |
| 289 | + | ]; | |
| 290 | + | ||
| 291 | + | let mut users = Vec::new(); | |
| 292 | + | for (uuid_str, username, display_name, is_fan_plus, is_creator, rust_role, music_role) in | |
| 293 | + | harness_data | |
| 294 | + | { | |
| 295 | + | let id = Uuid::parse_str(uuid_str).unwrap(); | |
| 296 | + | sqlx::query( | |
| 297 | + | "INSERT INTO users (mnw_account_id, username, display_name, is_fan_plus, is_creator) | |
| 298 | + | VALUES ($1, $2, $3, $4, $5) | |
| 299 | + | ON CONFLICT (mnw_account_id) DO UPDATE | |
| 300 | + | SET username = EXCLUDED.username, | |
| 301 | + | display_name = EXCLUDED.display_name, | |
| 302 | + | is_fan_plus = EXCLUDED.is_fan_plus, | |
| 303 | + | is_creator = EXCLUDED.is_creator", | |
| 304 | + | ) | |
| 305 | + | .bind(id) | |
| 306 | + | .bind(username) | |
| 307 | + | .bind(display_name) | |
| 308 | + | .bind(is_fan_plus) | |
| 309 | + | .bind(is_creator) | |
| 310 | + | .execute(pool) | |
| 311 | + | .await | |
| 312 | + | .expect("failed to seed harness user"); | |
| 313 | + | users.push(HarnessUser { | |
| 314 | + | id, | |
| 315 | + | rust_role, | |
| 316 | + | music_role, | |
| 317 | + | }); | |
| 318 | + | } | |
| 319 | + | users | |
| 320 | + | } | |
| 321 | + | ||
| 228 | 322 | async fn seed_users(pool: &PgPool) -> Vec<SeedUser> { | |
| 229 | 323 | let user_data = [ | |
| 230 | 324 | ("00000000-0000-0000-0000-000000000001", "admin", "Admin"), |
| @@ -22,6 +22,13 @@ | |||
| 22 | 22 | # configured in the env file (S3_*). Until MinIO is stood up on testnot, items | |
| 23 | 23 | # seed hidden (scan_status stays pending) and the catalog shows creators, | |
| 24 | 24 | # projects, blog posts, and follow counts without media. | |
| 25 | + | # | |
| 26 | + | # The seed's harness phase (login-capable accounts plus the OAuth client for the | |
| 27 | + | # write-enabled mt on astra) runs only when MT_HARNESS_PASSWORD and | |
| 28 | + | # MT_HARNESS_REDIRECT_URI are both set. They are read from $ENV_FILE, which the | |
| 29 | + | # systemd-run below already passes through, so nothing here needs to know them. | |
| 30 | + | # A box without them reseeds the catalog alone and the mt browser harness loses | |
| 31 | + | # its login — see multithreaded/deploy/README.md. | |
| 25 | 32 | set -euo pipefail | |
| 26 | 33 | ||
| 27 | 34 | SSH_TARGET="${TESTNOT_SSH:-root@testnot}" |
| @@ -32,6 +32,7 @@ | |||
| 32 | 32 | ||
| 33 | 33 | pub mod blog; | |
| 34 | 34 | pub mod creators; | |
| 35 | + | pub mod harness; | |
| 35 | 36 | pub mod items; | |
| 36 | 37 | pub mod media; | |
| 37 | 38 | pub mod projects; | |
| @@ -112,14 +113,24 @@ | |||
| 112 | 113 | pub allow_example_seed: bool, | |
| 113 | 114 | /// The configured `HOST_URL` (from [`crate::config::Config`]). | |
| 114 | 115 | pub host_url: String, | |
| 116 | + | /// Credentials for the mt browser-harness phase, when the box carries them. | |
| 117 | + | /// `None` skips that phase and seeds the catalog alone. | |
| 118 | + | /// | |
| 119 | + | /// Carried here rather than read inside the phase so a test names what it | |
| 120 | + | /// wants explicitly: the harness accounts change the account counts every | |
| 121 | + | /// other seed test asserts, and a stray env var in a dev shell should not be | |
| 122 | + | /// able to fail them. | |
| 123 | + | pub harness: Option<harness::HarnessOptions>, | |
| 115 | 124 | } | |
| 116 | 125 | ||
| 117 | 126 | impl SeedOptions { | |
| 118 | - | /// Read the opt-in switch from the environment; take `host_url` from config. | |
| 127 | + | /// Read the opt-in switch and the harness credentials from the environment; | |
| 128 | + | /// take `host_url` from config. | |
| 119 | 129 | pub fn from_env(host_url: &str) -> Self { | |
| 120 | 130 | Self { | |
| 121 | 131 | allow_example_seed: std::env::var("ALLOW_EXAMPLE_SEED").ok().as_deref() == Some("1"), | |
| 122 | 132 | host_url: host_url.to_string(), | |
| 133 | + | harness: harness::HarnessOptions::from_env(), | |
| 123 | 134 | } | |
| 124 | 135 | } | |
| 125 | 136 | } | |
| @@ -167,6 +178,19 @@ | |||
| 167 | 178 | social::seed_social(pool, &projects).await?; | |
| 168 | 179 | tracing::warn!("example seed: forum (Phase 5) deferred; refresh-flow swap is Phase 6"); | |
| 169 | 180 | ||
| 181 | + | // Harness phase: login-capable accounts and the mt OAuth client, for the | |
| 182 | + | // browser axis of an audit run. Opt-in per box (see `harness`), because the | |
| 183 | + | // password and the mt callback URL are environment, not repo content. A box | |
| 184 | + | // without them seeds exactly the catalog it seeded before. | |
| 185 | + | match opts.harness.as_ref() { | |
| 186 | + | Some(harness_opts) => harness::seed_harness(pool, harness_opts).await?, | |
| 187 | + | None => tracing::info!( | |
| 188 | + | "example seed: harness phase skipped ({} and {} must both be set)", | |
| 189 | + | harness::PASSWORD_ENV, | |
| 190 | + | harness::REDIRECT_URI_ENV, | |
| 191 | + | ), | |
| 192 | + | } | |
| 193 | + | ||
| 170 | 194 | Ok(()) | |
| 171 | 195 | } | |
| 172 | 196 |
| @@ -33,6 +33,7 @@ | |||
| 33 | 33 | SeedOptions { | |
| 34 | 34 | allow_example_seed: true, | |
| 35 | 35 | host_url: "https://testnot.work".to_string(), | |
| 36 | + | harness: None, | |
| 36 | 37 | } | |
| 37 | 38 | } | |
| 38 | 39 | ||
| @@ -289,6 +290,7 @@ | |||
| 289 | 290 | &SeedOptions { | |
| 290 | 291 | allow_example_seed: true, | |
| 291 | 292 | host_url: "https://makenot.work".to_string(), | |
| 293 | + | harness: None, | |
| 292 | 294 | }, | |
| 293 | 295 | &SeedMedia::none(), | |
| 294 | 296 | ) | |
| @@ -449,3 +451,182 @@ | |||
| 449 | 451 | SEEDED_ITEMS | |
| 450 | 452 | ); | |
| 451 | 453 | } | |
| 454 | + | ||
| 455 | + | // ── Harness phase ── | |
| 456 | + | // | |
| 457 | + | // The accounts and OAuth client the mt browser axis logs in with. Their whole | |
| 458 | + | // value is that a testnot reseed reproduces them exactly: an audit run that | |
| 459 | + | // cannot log in stops at reads, which is the coverage gap the phase exists to | |
| 460 | + | // close. These tests pin the three things a login actually depends on, the | |
| 461 | + | // password verifying, the perks MNW will report, and the redirect URI being | |
| 462 | + | // registered, plus survival across a reseed. | |
| 463 | + | ||
| 464 | + | use makenotwork::seed::harness::{ | |
| 465 | + | self, CREATOR_ACCOUNT_ID, FAN_ACCOUNT_ID, HarnessOptions, OWNER_ACCOUNT_ID, | |
| 466 | + | }; | |
| 467 | + | ||
| 468 | + | const HARNESS_REDIRECT_URI: &str = "http://mt.example.test:3400/auth/callback"; | |
| 469 | + | ||
| 470 | + | fn harness_opts() -> HarnessOptions { | |
| 471 | + | HarnessOptions { | |
| 472 | + | password: "harness-test-password".to_string(), | |
| 473 | + | redirect_uri: HARNESS_REDIRECT_URI.to_string(), | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | /// Options that run the catalog *and* the harness phase, as a testnot box with | |
| 478 | + | /// both env vars set does. | |
| 479 | + | fn testnot_opts_with_harness() -> SeedOptions { | |
| 480 | + | SeedOptions { | |
| 481 | + | harness: Some(harness_opts()), | |
| 482 | + | ..testnot_opts() | |
| 483 | + | } | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | async fn password_hash_of(pool: &sqlx::PgPool, id: uuid::Uuid) -> String { | |
| 487 | + | sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1") | |
| 488 | + | .bind(id) | |
| 489 | + | .fetch_one(pool) | |
| 490 | + | .await | |
| 491 | + | .expect("harness account should exist") | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | #[tokio::test] | |
| 495 | + | async fn harness_accounts_can_log_in_and_carry_their_perks() { | |
| 496 | + | let db = TestDb::new().await; | |
| 497 | + | seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none()) | |
| 498 | + | .await | |
| 499 | + | .expect("seed with harness phase"); | |
| 500 | + | ||
| 501 | + | // The password verifies: this is the login the browser run performs. | |
| 502 | + | for id in [FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID] { | |
| 503 | + | let hash = password_hash_of(&db.pool, id).await; | |
| 504 | + | assert!( | |
| 505 | + | makenotwork::auth::verify_password_async(harness_opts().password, hash) | |
| 506 | + | .await | |
| 507 | + | .expect("verify"), | |
| 508 | + | "seeded password should verify for {id}" | |
| 509 | + | ); | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | // Perks, as `/oauth/userinfo` computes them: fan_plus from an active | |
| 513 | + | // subscription row, is_creator from a non-null creator_tier. The pair is | |
| 514 | + | // what mt's `UserPerks::effective_plus` gate reads, and the two accounts | |
| 515 | + | // exercise its two halves separately. | |
| 516 | + | let fan_plus_active: bool = sqlx::query_scalar( | |
| 517 | + | "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions \ | |
| 518 | + | WHERE user_id = $1 AND status = 'active')", | |
| 519 | + | ) | |
| 520 | + | .bind(FAN_ACCOUNT_ID) | |
| 521 | + | .fetch_one(&db.pool) | |
| 522 | + | .await | |
| 523 | + | .expect("fan plus lookup"); | |
| 524 | + | assert!(fan_plus_active, "fan account should hold active Fan+"); | |
| 525 | + | ||
| 526 | + | let tiers: Vec<Option<String>> = | |
| 527 | + | sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = ANY($1) ORDER BY username") | |
| 528 | + | .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID]) | |
| 529 | + | .fetch_all(&db.pool) | |
| 530 | + | .await | |
| 531 | + | .expect("creator tiers"); | |
| 532 | + | // Ordered by username: harness_creator, harness_fan, harness_owner. | |
| 533 | + | assert_eq!( | |
| 534 | + | tiers, | |
| 535 | + | vec![Some("everything".to_string()), None, None], | |
| 536 | + | "only the creator account should report is_creator" | |
| 537 | + | ); | |
| 538 | + | ||
| 539 | + | // Creator powers follow the tier. Three accounts that all hold them would be | |
| 540 | + | // one role tested three times. | |
| 541 | + | let can_create: Vec<bool> = sqlx::query_scalar( | |
| 542 | + | "SELECT can_create_projects FROM users WHERE id = ANY($1) ORDER BY username", | |
| 543 | + | ) | |
| 544 | + | .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID]) | |
| 545 | + | .fetch_all(&db.pool) | |
| 546 | + | .await | |
| 547 | + | .expect("project rights"); | |
| 548 | + | assert_eq!(can_create, vec![true, false, false]); | |
| 549 | + | ||
| 550 | + | // Not locked out, not suspended: a prior run's failed logins must not | |
| 551 | + | // survive the reset. | |
| 552 | + | let clean: i64 = sqlx::query_scalar( | |
| 553 | + | "SELECT COUNT(*) FROM users WHERE id = ANY($1) \ | |
| 554 | + | AND failed_login_attempts = 0 AND locked_until IS NULL \ | |
| 555 | + | AND suspended_at IS NULL AND deactivated_at IS NULL", | |
| 556 | + | ) | |
| 557 | + | .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID]) | |
| 558 | + | .fetch_one(&db.pool) | |
| 559 | + | .await | |
| 560 | + | .expect("account state"); | |
| 561 | + | assert_eq!(clean, 3); | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | #[tokio::test] | |
| 565 | + | async fn harness_client_registers_its_redirect_uri() { | |
| 566 | + | let db = TestDb::new().await; | |
| 567 | + | seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none()) | |
| 568 | + | .await | |
| 569 | + | .expect("seed with harness phase"); | |
| 570 | + | ||
| 571 | + | // `validate_redirect_uri` waves through localhost only; the harness instance | |
| 572 | + | // is reached over the tailnet, so the URI has to be registered or the | |
| 573 | + | // authorize call fails with "redirect_uri is not allowed". | |
| 574 | + | let row: (uuid::Uuid, Vec<String>, bool) = sqlx::query_as( | |
| 575 | + | "SELECT creator_id, redirect_uris, is_active FROM sync_apps WHERE api_key_hash = $1", | |
| 576 | + | ) | |
| 577 | + | .bind(makenotwork::db::synckit::hash_api_key(harness::CLIENT_ID)) | |
| 578 | + | .fetch_one(&db.pool) | |
| 579 | + | .await | |
| 580 | + | .expect("harness OAuth client should be registered"); | |
| 581 | + | ||
| 582 | + | assert_eq!(row.0, CREATOR_ACCOUNT_ID, "client is owned by the creator"); | |
| 583 | + | assert_eq!(row.1, vec![HARNESS_REDIRECT_URI.to_string()]); | |
| 584 | + | assert!(row.2, "client must be active"); | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | #[tokio::test] | |
| 588 | + | async fn harness_survives_a_reseed_with_stable_ids() { | |
| 589 | + | let db = TestDb::new().await; | |
| 590 | + | seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none()) | |
| 591 | + | .await | |
| 592 | + | .expect("first seed"); | |
| 593 | + | ||
| 594 | + | // A reseed wipes every @example.test account first, harness accounts | |
| 595 | + | // included, then rebuilds them. Stable ids are the property mt's seed | |
| 596 | + | // depends on: it pre-assigns community roles by mnw_account_id, so an id | |
| 597 | + | // that moved would leave the harness a plain member with no way to moderate. | |
| 598 | + | seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none()) | |
| 599 | + | .await | |
| 600 | + | .expect("reseed"); | |
| 601 | + | ||
| 602 | + | let ids: Vec<uuid::Uuid> = sqlx::query_scalar( | |
| 603 | + | "SELECT id FROM users WHERE username LIKE 'harness\\_%' ORDER BY username", | |
| 604 | + | ) | |
| 605 | + | .fetch_all(&db.pool) | |
| 606 | + | .await | |
| 607 | + | .expect("harness ids"); | |
| 608 | + | assert_eq!( | |
| 609 | + | ids, | |
| 610 | + | vec![CREATOR_ACCOUNT_ID, FAN_ACCOUNT_ID, OWNER_ACCOUNT_ID], | |
| 611 | + | "harness ids must be identical after a reseed" | |
| 612 | + | ); | |
| 613 | + | ||
| 614 | + | // And the catalog is unchanged by the phase running twice. | |
| 615 | + | assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS + 3); | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | #[tokio::test] | |
| 619 | + | async fn without_harness_options_the_phase_does_not_run() { | |
| 620 | + | let db = TestDb::new().await; | |
| 621 | + | seed::run(&db.pool, &testnot_opts(), &SeedMedia::none()) | |
| 622 | + | .await | |
| 623 | + | .expect("seed without harness"); | |
| 624 | + | ||
| 625 | + | let harness_accounts: i64 = | |
| 626 | + | sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username LIKE 'harness\\_%'") | |
| 627 | + | .fetch_one(&db.pool) | |
| 628 | + | .await | |
| 629 | + | .expect("count"); | |
| 630 | + | assert_eq!(harness_accounts, 0, "harness phase must be opt-in"); | |
| 631 | + | assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS); | |
| 632 | + | } |
| @@ -1,0 +1,111 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # Reset the astra mt instance to its seeded state. | |
| 3 | + | # | |
| 4 | + | # astra runs the write-enabled mt: reachable over the tailnet only, logged into | |
| 5 | + | # with real MNW accounts, and driven by the browser axis of an audit run through | |
| 6 | + | # thread create, reply, flag and moderation. That is a surface that accumulates | |
| 7 | + | # junk by design, so it needs a way back to a known state, and this is it. | |
| 8 | + | # | |
| 9 | + | # Hard reset, not incremental: drop every schema, let the binary re-migrate and | |
| 10 | + | # re-seed. Nobody is looking at this instance, so there is nothing to preserve | |
| 11 | + | # and no reason to make the reset clever. Two audit runs a week apart should see | |
| 12 | + | # the same forum. | |
| 13 | + | # | |
| 14 | + | # What it does NOT reset is the MNW side. The accounts the harness logs in as | |
| 15 | + | # live on testnot and are recreated by testnot's own reseed | |
| 16 | + | # (`sando/deploy/mnw-testnot-seed.sh`, harness phase). The two are deliberately | |
| 17 | + | # independent: mt's state is disposable, the identities behind it are not. | |
| 18 | + | # | |
| 19 | + | # Usage: | |
| 20 | + | # deploy/reset-astra.sh # stop, wipe, migrate, seed, start, verify | |
| 21 | + | # | |
| 22 | + | # Env: | |
| 23 | + | # MT_ASTRA_SSH ssh target (default: astra) | |
| 24 | + | # MT_ASTRA_DB database name (default: multithreaded) | |
| 25 | + | set -euo pipefail | |
| 26 | + | ||
| 27 | + | SSH_TARGET="${MT_ASTRA_SSH:-astra}" | |
| 28 | + | DB="${MT_ASTRA_DB:-multithreaded}" | |
| 29 | + | SERVICE="multithreaded.service" | |
| 30 | + | APP_DIR="/opt/multithreaded" | |
| 31 | + | BIN="$APP_DIR/multithreaded" | |
| 32 | + | ENV_FILE="$APP_DIR/.env" | |
| 33 | + | APP_USER="multithreaded" | |
| 34 | + | PORT="${MT_ASTRA_PORT:-3400}" | |
| 35 | + | ||
| 36 | + | log() { echo "[$(date -u +%H:%M:%S)] $*"; } | |
| 37 | + | on_astra() { ssh "$SSH_TARGET" "$@"; } | |
| 38 | + | ||
| 39 | + | log "stopping $SERVICE on $SSH_TARGET" | |
| 40 | + | on_astra "sudo systemctl stop $SERVICE" | |
| 41 | + | ||
| 42 | + | # Drop every non-system schema rather than just `public`: tower-sessions creates | |
| 43 | + | # its own, and a surviving session table would carry logins across a reset that | |
| 44 | + | # is supposed to have removed the users they point at. Recreate `public` OWNED BY | |
| 45 | + | # the app role, because on PG15+ a postgres-owned public grants the app no | |
| 46 | + | # CREATE and the boot migrations fail with "no schema has been selected". | |
| 47 | + | log "resetting schema in $DB" | |
| 48 | + | on_astra "sudo -u postgres psql -v ON_ERROR_STOP=1 -d $DB" <<SQL | |
| 49 | + | DO \$\$ | |
| 50 | + | DECLARE s text; | |
| 51 | + | BEGIN | |
| 52 | + | FOR s IN | |
| 53 | + | SELECT nspname FROM pg_namespace | |
| 54 | + | WHERE nspname NOT LIKE 'pg_%' AND nspname <> 'information_schema' | |
| 55 | + | LOOP | |
| 56 | + | EXECUTE format('DROP SCHEMA IF EXISTS %I CASCADE', s); | |
| 57 | + | END LOOP; | |
| 58 | + | EXECUTE 'CREATE SCHEMA public AUTHORIZATION $APP_USER'; | |
| 59 | + | END \$\$; | |
| 60 | + | SQL | |
| 61 | + | ||
| 62 | + | # `--seed` migrates the empty schema, seeds, and exits before binding a port, so | |
| 63 | + | # it is safe with the service stopped. systemd-run with the service's own | |
| 64 | + | # EnvironmentFile rather than sourcing it in a shell: systemd parses KEY=value | |
| 65 | + | # literally, so a secret containing shell metacharacters survives. | |
| 66 | + | log "migrating + seeding" | |
| 67 | + | on_astra "sudo systemd-run --pipe --wait --collect --service-type=exec \ | |
| 68 | + | -p EnvironmentFile=$ENV_FILE -p WorkingDirectory=$APP_DIR -p User=$APP_USER \ | |
| 69 | + | $BIN --seed" | |
| 70 | + | ||
| 71 | + | log "starting $SERVICE" | |
| 72 | + | on_astra "sudo systemctl start $SERVICE" | |
| 73 | + | ||
| 74 | + | healthy=0 | |
| 75 | + | for _ in $(seq 1 20); do | |
| 76 | + | code=$(on_astra "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$PORT/api/health" || echo 000) | |
| 77 | + | [ "$code" = "200" ] && { log "health OK"; healthy=1; break; } | |
| 78 | + | sleep 3 | |
| 79 | + | done | |
| 80 | + | if [ "$healthy" -ne 1 ]; then | |
| 81 | + | echo "mt did not return healthy after the reset" >&2 | |
| 82 | + | exit 1 | |
| 83 | + | fi | |
| 84 | + | ||
| 85 | + | # Healthy is not the same as usable. A browser run that logs in and finds no | |
| 86 | + | # membership fails at the first write with a 403, which reads like a permissions | |
| 87 | + | # bug in mt rather than a seed that did not finish. Assert the harness | |
| 88 | + | # preconditions here so the reset and its verification cannot drift apart: | |
| 89 | + | # the three accounts exist, and the owner account really is Owner somewhere. | |
| 90 | + | log "verifying harness preconditions" | |
| 91 | + | counts=$(on_astra "sudo -u postgres psql -v ON_ERROR_STOP=1 -At -d $DB" <<'SQL' | |
| 92 | + | SELECT count(*) FROM users | |
| 93 | + | WHERE mnw_account_id IN ( | |
| 94 | + | '00000000-0000-0000-0000-00000000f001', | |
| 95 | + | '00000000-0000-0000-0000-00000000f002', | |
| 96 | + | '00000000-0000-0000-0000-00000000f003'); | |
| 97 | + | SELECT count(*) FROM memberships | |
| 98 | + | WHERE user_id = '00000000-0000-0000-0000-00000000f003' AND role = 'owner'; | |
| 99 | + | SELECT count(*) FROM threads; | |
| 100 | + | SQL | |
| 101 | + | ) | |
| 102 | + | accounts=$(sed -n 1p <<<"$counts") | |
| 103 | + | owner_rows=$(sed -n 2p <<<"$counts") | |
| 104 | + | threads=$(sed -n 3p <<<"$counts") | |
| 105 | + | ||
| 106 | + | [ "$accounts" = "3" ] || { echo "expected 3 harness accounts, found ${accounts:-none}" >&2; exit 1; } | |
| 107 | + | [ "${owner_rows:-0}" -ge 1 ] || { echo "harness owner holds no owner membership" >&2; exit 1; } | |
| 108 | + | [ "${threads:-0}" -gt 0 ] || { echo "no threads seeded" >&2; exit 1; } | |
| 109 | + | log "harness OK: 3 accounts, owner membership present, $threads threads" | |
| 110 | + | ||
| 111 | + | log "done" |
| @@ -1,0 +1,320 @@ | |||
| 1 | + | //! Login-capable accounts and the OAuth client the Multithreaded browser | |
| 2 | + | //! harness needs, seeded into the example catalog on testnot. | |
| 3 | + | //! | |
| 4 | + | //! Everything else in [`crate::seed`] builds a catalog for a human to look at: | |
| 5 | + | //! the accounts it creates hash a random password nobody holds, because nothing | |
| 6 | + | //! is meant to log in as them. The browser axis of an audit run needs the | |
| 7 | + | //! opposite. It drives a real browser through mt's write paths (thread create, | |
| 8 | + | //! reply, flag, one moderation action), every one of which requires an mt | |
| 9 | + | //! session, and mt is an OAuth relying party against this server. So it needs | |
| 10 | + | //! accounts whose password is known and an OAuth client whose `redirect_uri` | |
| 11 | + | //! points at the mt instance doing the driving. | |
| 12 | + | //! | |
| 13 | + | //! # Why this lives in the seed rather than beside the instance | |
| 14 | + | //! | |
| 15 | + | //! `mnw-testnot-seed.sh` drops every schema before it reseeds. Anything created | |
| 16 | + | //! by hand — a `sync_apps` row, three accounts — is gone the next time testnot | |
| 17 | + | //! is reset, and the harness starts failing at login with nothing in the diff to | |
| 18 | + | //! explain it. Seeding them here makes the reset cycle reproduce them exactly, | |
| 19 | + | //! which is the property the harness actually depends on: the same accounts, | |
| 20 | + | //! with the same ids, after every reseed. | |
| 21 | + | //! | |
| 22 | + | //! The account ids are fixed constants rather than generated, because mt's own | |
| 23 | + | //! seed pre-assigns community roles by `mnw_account_id`. See | |
| 24 | + | //! `multithreaded/src/seed.rs`, which mirrors [`FAN_ACCOUNT_ID`], | |
| 25 | + | //! [`CREATOR_ACCOUNT_ID`] and [`OWNER_ACCOUNT_ID`] verbatim; the pair has to | |
| 26 | + | //! move together. | |
| 27 | + | //! | |
| 28 | + | //! # Why it is opt-in even inside the seed | |
| 29 | + | //! | |
| 30 | + | //! The phase runs only when both [`PASSWORD_ENV`] and [`REDIRECT_URI_ENV`] are | |
| 31 | + | //! set, and skips with a warning otherwise. Neither value is in this repo: the | |
| 32 | + | //! password is a credential, and the redirect URI names a tailnet host that a | |
| 33 | + | //! public repo has no reason to carry. A testnot box that has them set (in its | |
| 34 | + | //! `EnvironmentFile`, which the reseed passes through) gets the harness | |
| 35 | + | //! accounts; anywhere else the seed produces exactly what it produced before. | |
| 36 | + | //! | |
| 37 | + | //! The prod guards in [`crate::seed::run`] still apply, and they run first: this | |
| 38 | + | //! phase is unreachable without `ALLOW_EXAMPLE_SEED=1` on an approved host with | |
| 39 | + | //! no real accounts present. | |
| 40 | + | ||
| 41 | + | use uuid::Uuid; | |
| 42 | + | ||
| 43 | + | use super::{EXAMPLE_EMAIL_DOMAIN, SeedError}; | |
| 44 | + | use crate::auth; | |
| 45 | + | use crate::db::{self, UserId}; | |
| 46 | + | ||
| 47 | + | /// Env var holding the shared password for the three harness accounts. | |
| 48 | + | pub const PASSWORD_ENV: &str = "MT_HARNESS_PASSWORD"; | |
| 49 | + | ||
| 50 | + | /// Env var holding the OAuth `redirect_uri` to register for the harness client, | |
| 51 | + | /// i.e. `{mt base url}/auth/callback` for the write-enabled mt instance. | |
| 52 | + | pub const REDIRECT_URI_ENV: &str = "MT_HARNESS_REDIRECT_URI"; | |
| 53 | + | ||
| 54 | + | /// The harness client's `client_id` (= `sync_apps.api_key`, which is stored | |
| 55 | + | /// hashed). Fixed rather than generated so mt's `OAUTH_CLIENT_ID` is a constant | |
| 56 | + | /// its env file can carry across reseeds. Not a secret: this is a public PKCE | |
| 57 | + | /// client, holding the id alone grants nothing. | |
| 58 | + | pub const CLIENT_ID: &str = "mt-astra-harness"; | |
| 59 | + | ||
| 60 | + | /// Display name of the registered app, shown on the OAuth consent screen. | |
| 61 | + | const CLIENT_NAME: &str = "Multithreaded (astra harness)"; | |
| 62 | + | ||
| 63 | + | /// Fan+ subscriber, not a creator. Exercises the `fan_plus` half of | |
| 64 | + | /// `UserPerks::effective_plus` on its own. | |
| 65 | + | pub const FAN_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_f001); | |
| 66 | + | /// Creator (top tier). Owns the harness OAuth client, and exercises the | |
| 67 | + | /// `is_creator` auto-grant half of `effective_plus`. | |
| 68 | + | pub const CREATOR_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_f002); | |
| 69 | + | /// Plain account with no MNW perks at all. Its privileges are mt-side only | |
| 70 | + | /// (community Owner + Moderator), which is what makes it the moderation actor. | |
| 71 | + | pub const OWNER_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_f003); | |
| 72 | + | ||
| 73 | + | /// One harness account: fixed id, handle, and what perks it carries on MNW. | |
| 74 | + | struct AccountSpec { | |
| 75 | + | id: Uuid, | |
| 76 | + | handle: &'static str, | |
| 77 | + | display_name: &'static str, | |
| 78 | + | /// `users.creator_tier`, which is what `/oauth/userinfo` reports as | |
| 79 | + | /// `perks.is_creator`. `None` leaves the account a plain fan. | |
| 80 | + | creator_tier: Option<&'static str>, | |
| 81 | + | /// Whether to seed an active `fan_plus_subscriptions` row. | |
| 82 | + | fan_plus: bool, | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | const ACCOUNTS: &[AccountSpec] = &[ | |
| 86 | + | AccountSpec { | |
| 87 | + | id: FAN_ACCOUNT_ID, | |
| 88 | + | handle: "harness_fan", | |
| 89 | + | display_name: "Harness Fan", | |
| 90 | + | creator_tier: None, | |
| 91 | + | fan_plus: true, | |
| 92 | + | }, | |
| 93 | + | AccountSpec { | |
| 94 | + | id: CREATOR_ACCOUNT_ID, | |
| 95 | + | handle: "harness_creator", | |
| 96 | + | display_name: "Harness Creator", | |
| 97 | + | creator_tier: Some("everything"), | |
| 98 | + | fan_plus: false, | |
| 99 | + | }, | |
| 100 | + | AccountSpec { | |
| 101 | + | id: OWNER_ACCOUNT_ID, | |
| 102 | + | handle: "harness_owner", | |
| 103 | + | display_name: "Harness Owner", | |
| 104 | + | creator_tier: None, | |
| 105 | + | fan_plus: false, | |
| 106 | + | }, | |
| 107 | + | ]; | |
| 108 | + | ||
| 109 | + | /// The two values the phase needs from the environment. Absent either one, the | |
| 110 | + | /// phase does not run. | |
| 111 | + | #[derive(Clone)] | |
| 112 | + | pub struct HarnessOptions { | |
| 113 | + | /// Shared password for every harness account. | |
| 114 | + | pub password: String, | |
| 115 | + | /// The mt callback URL to register on the harness OAuth client. | |
| 116 | + | pub redirect_uri: String, | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Hand-written so the password cannot reach a log through | |
| 120 | + | /// [`crate::seed::SeedOptions`]'s derived `Debug`. | |
| 121 | + | impl std::fmt::Debug for HarnessOptions { | |
| 122 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 123 | + | f.debug_struct("HarnessOptions") | |
| 124 | + | .field("password", &"<redacted>") | |
| 125 | + | .field("redirect_uri", &self.redirect_uri) | |
| 126 | + | .finish() | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | impl HarnessOptions { | |
| 131 | + | /// Read both values from the environment; `None` when either is unset or | |
| 132 | + | /// empty, which is the signal to skip the phase. | |
| 133 | + | pub fn from_env() -> Option<Self> { | |
| 134 | + | let password = std::env::var(PASSWORD_ENV).ok().filter(|s| !s.is_empty())?; | |
| 135 | + | let redirect_uri = std::env::var(REDIRECT_URI_ENV) | |
| 136 | + | .ok() | |
| 137 | + | .filter(|s| !s.is_empty())?; | |
| 138 | + | Some(Self { | |
| 139 | + | password, | |
| 140 | + | redirect_uri, | |
| 141 | + | }) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Seed the three harness accounts and the harness OAuth client. | |
| 146 | + | /// | |
| 147 | + | /// Called from [`super::run`] after the catalog phases, so the accounts land in | |
| 148 | + | /// a database the guards have already cleared. Re-runnable: every write is an | |
| 149 | + | /// upsert keyed on the fixed ids, so a reseed that did not drop the schema lands | |
| 150 | + | /// in the same state as one that did. | |
| 151 | + | pub async fn seed_harness(pool: &sqlx::PgPool, opts: &HarnessOptions) -> Result<(), SeedError> { | |
| 152 | + | // One hash for all three: the password is shared, and Argon2 is the | |
| 153 | + | // expensive part of this phase. | |
| 154 | + | let password_hash = auth::hash_password_async(opts.password.clone()).await?; | |
| 155 | + | ||
| 156 | + | for spec in ACCOUNTS { | |
| 157 | + | seed_account(pool, spec, &password_hash).await?; | |
| 158 | + | if spec.fan_plus { | |
| 159 | + | seed_fan_plus(pool, spec.id).await?; | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | seed_client(pool, &opts.redirect_uri).await?; | |
| 164 | + | ||
| 165 | + | tracing::warn!( | |
| 166 | + | accounts = ACCOUNTS.len(), | |
| 167 | + | client_id = CLIENT_ID, | |
| 168 | + | redirect_uri = %opts.redirect_uri, | |
| 169 | + | "example seed: harness accounts and OAuth client seeded (login-capable)" | |
| 170 | + | ); | |
| 171 | + | Ok(()) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// Insert (or reset) one harness account at its fixed id. | |
| 175 | + | /// | |
| 176 | + | /// `email_verified` mirrors [`db::users::create_example_creator`], and | |
| 177 | + | /// `is_sandbox` is left FALSE for the reason it is there: a sandbox account is | |
| 178 | + | /// refused by `SessionUser::check_not_sandbox` on routes the harness may well | |
| 179 | + | /// walk. `can_create_projects` follows the creator tier instead of being TRUE | |
| 180 | + | /// for everyone, so the fan account is actually a fan — three accounts that all | |
| 181 | + | /// hold creator powers would test one role three times. | |
| 182 | + | async fn seed_account( | |
| 183 | + | pool: &sqlx::PgPool, | |
| 184 | + | spec: &AccountSpec, | |
| 185 | + | password_hash: &str, | |
| 186 | + | ) -> Result<(), SeedError> { | |
| 187 | + | let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", spec.handle.replace('_', "-")); | |
| 188 | + | sqlx::query( | |
| 189 | + | r" | |
| 190 | + | INSERT INTO users ( | |
| 191 | + | id, username, email, password_hash, display_name, | |
| 192 | + | can_create_projects, email_verified, creator_tier | |
| 193 | + | ) | |
| 194 | + | VALUES ($1, $2, $3, $4, $5, $6 IS NOT NULL, TRUE, $6) | |
| 195 | + | ON CONFLICT (id) DO UPDATE SET | |
| 196 | + | username = EXCLUDED.username, | |
| 197 | + | email = EXCLUDED.email, | |
| 198 | + | password_hash = EXCLUDED.password_hash, | |
| 199 | + | display_name = EXCLUDED.display_name, | |
| 200 | + | creator_tier = EXCLUDED.creator_tier, | |
| 201 | + | can_create_projects = EXCLUDED.can_create_projects, | |
| 202 | + | -- A harness run that tripped the lockout must not survive the | |
| 203 | + | -- reseed: the whole point of the reset is a known state. | |
| 204 | + | failed_login_attempts = 0, | |
| 205 | + | locked_until = NULL, | |
| 206 | + | suspended_at = NULL, | |
| 207 | + | deactivated_at = NULL | |
| 208 | + | ", | |
| 209 | + | ) | |
| 210 | + | .bind(spec.id) | |
| 211 | + | .bind(spec.handle) | |
| 212 | + | .bind(&email) | |
| 213 | + | .bind(password_hash) | |
| 214 | + | .bind(spec.display_name) | |
| 215 | + | .bind(spec.creator_tier) | |
| 216 | + | .execute(pool) | |
| 217 | + | .await?; | |
| 218 | + | ||
| 219 | + | tracing::info!( | |
| 220 | + | handle = spec.handle, | |
| 221 | + | user_id = %spec.id, | |
| 222 | + | "example seed: harness account" | |
| 223 | + | ); | |
| 224 | + | Ok(()) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// Give an account an active Fan+ subscription without touching Stripe. | |
| 228 | + | /// | |
| 229 | + | /// `is_fan_plus_active` reads status alone, so an `active` row is the whole | |
| 230 | + | /// requirement. The Stripe ids are fabricated and deliberately marked: nothing | |
| 231 | + | /// on testnot talks to live Stripe, and a `harness_` prefix makes a stray row | |
| 232 | + | /// obvious if one is ever found somewhere it should not be. | |
| 233 | + | async fn seed_fan_plus(pool: &sqlx::PgPool, user_id: Uuid) -> Result<(), SeedError> { | |
| 234 | + | sqlx::query( | |
| 235 | + | r" | |
| 236 | + | INSERT INTO fan_plus_subscriptions ( | |
| 237 | + | user_id, stripe_subscription_id, stripe_customer_id, status | |
| 238 | + | ) | |
| 239 | + | VALUES ($1, $2, $3, 'active') | |
| 240 | + | ON CONFLICT (user_id) DO UPDATE SET | |
| 241 | + | status = 'active', | |
| 242 | + | canceled_at = NULL | |
| 243 | + | ", | |
| 244 | + | ) | |
| 245 | + | .bind(user_id) | |
| 246 | + | .bind(format!("sub_harness_{user_id}")) | |
| 247 | + | .bind(format!("cus_harness_{user_id}")) | |
| 248 | + | .execute(pool) | |
| 249 | + | .await?; | |
| 250 | + | Ok(()) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// Register the harness OAuth client, owned by the creator account. | |
| 254 | + | /// | |
| 255 | + | /// The `redirect_uri` must be registered explicitly: `validate_redirect_uri` | |
| 256 | + | /// waves through localhost only, and the harness instance is reached over the | |
| 257 | + | /// tailnet by name, which is not localhost. | |
| 258 | + | async fn seed_client(pool: &sqlx::PgPool, redirect_uri: &str) -> Result<(), SeedError> { | |
| 259 | + | let existing = db::synckit::get_sync_app_by_api_key(pool, CLIENT_ID).await?; | |
| 260 | + | let app = match existing { | |
| 261 | + | Some(app) => app, | |
| 262 | + | None => { | |
| 263 | + | db::synckit::create_sync_app( | |
| 264 | + | pool, | |
| 265 | + | UserId::from(CREATOR_ACCOUNT_ID), | |
| 266 | + | CLIENT_NAME, | |
| 267 | + | CLIENT_ID, | |
| 268 | + | None, | |
| 269 | + | None, | |
| 270 | + | ) | |
| 271 | + | .await? | |
| 272 | + | } | |
| 273 | + | }; | |
| 274 | + | ||
| 275 | + | sqlx::query("UPDATE sync_apps SET redirect_uris = $2, is_active = TRUE WHERE id = $1") | |
| 276 | + | .bind(app.id) | |
| 277 | + | .bind(vec![redirect_uri.to_string()]) | |
| 278 | + | .execute(pool) | |
| 279 | + | .await?; | |
| 280 | + | ||
| 281 | + | Ok(()) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | #[cfg(test)] | |
| 285 | + | mod tests { | |
| 286 | + | use super::*; | |
| 287 | + | ||
| 288 | + | #[test] | |
| 289 | + | fn account_ids_are_the_ones_mt_seeds() { | |
| 290 | + | // These three constants are copied into multithreaded/src/seed.rs, which | |
| 291 | + | // pre-assigns community roles by mnw_account_id. A change here that is | |
| 292 | + | // not mirrored there silently drops the harness's moderator rights, and | |
| 293 | + | // the failure shows up as a 403 in a browser run rather than as a build | |
| 294 | + | // error. Pin the literals so the edit cannot be quiet. | |
| 295 | + | assert_eq!( | |
| 296 | + | FAN_ACCOUNT_ID.to_string(), | |
| 297 | + | "00000000-0000-0000-0000-00000000f001" | |
| 298 | + | ); | |
| 299 | + | assert_eq!( | |
| 300 | + | CREATOR_ACCOUNT_ID.to_string(), | |
| 301 | + | "00000000-0000-0000-0000-00000000f002" | |
| 302 | + | ); | |
| 303 | + | assert_eq!( | |
| 304 | + | OWNER_ACCOUNT_ID.to_string(), | |
| 305 | + | "00000000-0000-0000-0000-00000000f003" | |
| 306 | + | ); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | #[test] | |
| 310 | + | fn harness_emails_stay_inside_the_reserved_domain() { | |
| 311 | + | // The seed's reset step only deletes @example.test accounts, and its | |
| 312 | + | // third guard refuses to run at all when a non-example account exists. | |
| 313 | + | // A harness handle that produced an address outside the domain would | |
| 314 | + | // therefore both survive resets and block the next seed. | |
| 315 | + | for spec in ACCOUNTS { | |
| 316 | + | let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", spec.handle.replace('_', "-")); | |
| 317 | + | assert!(email.ends_with("@example.test"), "{email}"); | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | } |