Skip to main content

max / makenotwork

12.2 KB · 321 lines History Blame Raw
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 }
321