Skip to main content

max / makenotwork

35.2 KB · 874 lines History Blame Raw
1 //! Test harness for in-process integration tests.
2
3 pub(crate) mod client;
4 pub(crate) mod db;
5 pub(crate) mod email;
6 pub(crate) mod faults;
7 pub(crate) mod gitfixture;
8 pub(crate) mod parity;
9 pub(crate) mod seed;
10 pub(crate) mod storage;
11 pub(crate) mod stripe;
12
13 #[allow(unused_imports)]
14 pub(crate) use seed::{seed_project, seed_user};
15
16 /// CDN render base every test app uses unless `Opts::cdn_base_url` overrides it.
17 /// `Config::cdn_base_url` is required, so tests always have one.
18 pub(crate) const TEST_CDN_BASE: &str = "https://cdn.test";
19
20 /// Compute SHA-256 hash of a SyncKit API key (mirrors server's hash_api_key).
21 pub(crate) fn hash_api_key(api_key: &str) -> String {
22 use sha2::Digest;
23 hex::encode(sha2::Sha256::digest(api_key.as_bytes()))
24 }
25
26 use docengine::DocLoader;
27
28 /// The real published docs, parsed once per test binary.
29 ///
30 /// The harness used to build a loader with `sections: vec![]` against `"."`, so
31 /// under test the site had no doc pages at all and nothing could cover `/docs`
32 /// or the docs half of the sitemap. Loading the real tree costs a parse, which
33 /// is why it is shared rather than rebuilt per `TestHarness::new`, and it goes
34 /// through `site_docs::build_doc_loader` so the config cannot drift from the
35 /// one production uses.
36 fn site_docs() -> Arc<DocLoader> {
37 static DOCS: std::sync::OnceLock<Arc<DocLoader>> = std::sync::OnceLock::new();
38 DOCS.get_or_init(|| {
39 let assumptions = makenotwork::site_docs::load_assumptions()
40 .expect("tests run from the crate root, where the assumptions file is");
41 Arc::new(makenotwork::site_docs::build_doc_loader(assumptions))
42 })
43 .clone()
44 }
45 use makenotwork::config::{
46 BuildConfig, Config, CreatorTierPricing, EmailWebhookConfig, IntegrationsConfig, ScanConfig,
47 StripeConfig,
48 };
49 use makenotwork::db::UserId;
50 use makenotwork::email::{EmailClient, EmailConfig};
51 use makenotwork::payments::PaymentCapabilities;
52 use makenotwork::payments::{PaymentProvider, StripeClient};
53 use makenotwork::scanning::ScanPipeline;
54 use makenotwork::{AppState, AppStateParts, AppStorage, build_app};
55 use sqlx::PgPool;
56 use std::sync::Arc;
57 use tower_sessions::cookie::SameSite;
58 use tower_sessions::cookie::time::Duration as CookieDuration;
59 use tower_sessions::{Expiry, SessionManagerLayer};
60 use tower_sessions_sqlx_store::PostgresStore;
61
62 use self::client::TestClient;
63 use self::db::TestDb;
64 use self::storage::InMemoryStorage;
65
66 /// Result of setting up a test creator with project and item.
67 #[allow(dead_code)]
68 pub(crate) struct CreatorSetup {
69 pub user_id: UserId,
70 pub project_id: String,
71 pub item_id: String,
72 pub slug: String,
73 }
74
75 /// Options for customizing a test harness build.
76 #[derive(Default)]
77 pub(crate) struct BuildOptions {
78 pub storage: Option<Arc<InMemoryStorage>>,
79 pub synckit_storage: Option<Arc<InMemoryStorage>>,
80 pub stripe_client: Option<Arc<dyn PaymentProvider>>,
81 /// Typed handles to the capability extensions the same double implements.
82 /// Set it alongside `stripe_client` whenever a test drives a portal,
83 /// onboarding, catalogue, refund, transfer, or customer path.
84 pub payment_caps: PaymentCapabilities,
85 pub scanner: Option<Arc<ScanPipeline>>,
86 pub admin_user_id: Option<UserId>,
87 /// Public render base for the RPM bucket. `None` leaves it unset, which
88 /// is what a dev environment looks like and what makes the publish
89 /// endpoint answer with a `public_url` of `null`.
90 pub rpm_base_url: Option<String>,
91 pub existing_db: Option<TestDb>,
92 pub postmark_webhook_token: Option<String>,
93 pub postmark_broadcast_webhook_token: Option<String>,
94 pub git_repos_path: Option<String>,
95 pub build_trigger_token: Option<String>,
96 pub postmark_inbound_webhook_token: Option<String>,
97 pub mt_base_url: Option<String>,
98 pub internal_shared_secret: Option<String>,
99 pub cli_service_token: Option<String>,
100 pub mock_email: Option<Arc<email::MockEmailTransport>>,
101 /// Overrides the CDN render base. `None` uses [`TEST_CDN_BASE`]; the base is
102 /// required config, so there is no "no CDN" mode to opt into.
103 pub cdn_base_url: Option<String>,
104 /// Site access gate. Defaults to `Open`; set to `FanPlusOrCreator` to test
105 /// the testnot-style gate.
106 pub access_gate: makenotwork::config::AccessGate,
107 /// Delegated-login (SSO) provider config. `None` = local password form.
108 pub sso: Option<makenotwork::config::SsoConfig>,
109 /// Sticker monthly creator-tier price IDs. `None` = empty (creator-tier
110 /// checkout is unconfigured and bails). Set to exercise creator-tier flows.
111 pub creator_tier_prices:
112 Option<std::collections::HashMap<makenotwork::db::CreatorTier, String>>,
113 /// Rate-limit profile. `None` relaxes the limits, which is what almost every
114 /// test wants: a test that logs in six times is not asking to be throttled.
115 /// Set `Some(RateLimits::production())` to assert the thresholds that ship,
116 /// which is what `workflows::rate_limiting` does.
117 pub rate_limits: Option<makenotwork::constants::RateLimits>,
118 /// Whether founder pricing is on offer. Defaults to closed, which is the
119 /// state every pre-existing test was written against. Set it to exercise
120 /// the surfaces that only exist while the window is open.
121 pub founder_window_open: bool,
122 }
123
124 /// Full test harness: isolated database, in-process app, cookie-aware client.
125 #[allow(dead_code)]
126 pub(crate) struct TestHarness {
127 pub client: TestClient,
128 pub db: PgPool,
129 pub storage: Option<Arc<InMemoryStorage>>,
130 /// Mock email transport, if configured. Use `.sent()` to inspect sent emails.
131 pub mock_email: Option<Arc<email::MockEmailTransport>>,
132 /// Mock payment provider, if configured. Use `.checkouts()` to inspect created sessions.
133 pub mock_stripe: Option<Arc<stripe::MockPaymentProvider>>,
134 /// Pieces needed to drain the scan worker synchronously from tests
135 /// (`drain_scan_jobs`). `None` when the harness wasn't built with a scanner.
136 scan_deps: Option<ScanDeps>,
137 /// The assembled state, kept so tests can drive scheduler jobs that take it.
138 /// `build_app` borrows rather than consumes it, so this costs a cheap clone.
139 state: makenotwork::AppState,
140 _test_db: TestDb,
141 }
142
143 struct ScanDeps {
144 s3: Arc<dyn makenotwork::storage::StorageBackend>,
145 pipeline: Arc<ScanPipeline>,
146 semaphore: Arc<tokio::sync::Semaphore>,
147 }
148
149 impl TestHarness {
150 /// Spin up a fresh database, build the app, and return a ready-to-use harness.
151 pub(crate) async fn new() -> Self {
152 Self::build(BuildOptions::default()).await
153 }
154
155 /// Harness whose router carries the rate limits that ship, rather than the
156 /// relaxed profile every other harness uses. This is the only way to assert
157 /// the real thresholds, and the reason the limits are runtime config: under
158 /// the old `fast-tests` feature the two profiles could not coexist in one
159 /// run, so CI exercised the relaxed limiter and never the production one.
160 #[allow(dead_code)]
161 pub(crate) async fn with_production_rate_limits() -> Self {
162 Self::build(BuildOptions {
163 rate_limits: Some(makenotwork::constants::RateLimits::production()),
164 ..Default::default()
165 })
166 .await
167 }
168
169 /// Harness with founder pricing on offer. Every other harness runs with the
170 /// window shut, so this is the only way to reach the banner and the
171 /// list/founder toggle on `/pricing`.
172 #[allow(dead_code)]
173 pub(crate) async fn with_founder_window_open() -> Self {
174 Self::build(BuildOptions {
175 founder_window_open: true,
176 ..Default::default()
177 })
178 .await
179 }
180
181 /// Harness with in-memory storage backend.
182 #[allow(dead_code)]
183 pub(crate) async fn with_storage() -> Self {
184 let mem = Arc::new(InMemoryStorage::new());
185 Self::build(BuildOptions {
186 storage: Some(mem),
187 ..Default::default()
188 })
189 .await
190 }
191
192 /// Harness with SyncKit in-memory storage backend (for OTA tests).
193 #[allow(dead_code)]
194 pub(crate) async fn with_synckit_storage() -> Self {
195 let mem = Arc::new(InMemoryStorage::new());
196 Self::build(BuildOptions {
197 synckit_storage: Some(mem),
198 ..Default::default()
199 })
200 .await
201 }
202
203 /// Harness with in-memory storage + file scanning pipeline.
204 #[allow(dead_code)]
205 pub(crate) async fn with_storage_and_scanner() -> Self {
206 let mem = Arc::new(InMemoryStorage::new());
207 let scanner = Self::no_op_scanner();
208 Self::build(BuildOptions {
209 storage: Some(mem),
210 scanner: Some(Arc::new(scanner)),
211 ..Default::default()
212 })
213 .await
214 }
215
216 /// Harness with admin user + in-memory storage + file scanning pipeline.
217 /// Returns (harness, admin_user_id).
218 #[allow(dead_code)]
219 pub(crate) async fn with_admin_storage_and_scanner() -> (Self, UserId) {
220 let test_db = TestDb::new().await;
221 let pool = test_db.pool.clone();
222 let admin_id = Self::insert_admin_user(&pool).await;
223
224 let mem = Arc::new(InMemoryStorage::new());
225 let scanner = Self::no_op_scanner();
226 let harness = Self::build(BuildOptions {
227 storage: Some(mem),
228 scanner: Some(Arc::new(scanner)),
229 admin_user_id: Some(admin_id),
230 existing_db: Some(test_db),
231 ..Default::default()
232 })
233 .await;
234 (harness, admin_id)
235 }
236
237 /// Harness with Stripe client configured (fake key, known webhook secrets).
238 #[allow(dead_code)]
239 pub(crate) async fn with_stripe() -> Self {
240 let stripe_config = StripeConfig {
241 secret_key: "sk_test_fake_key_for_testing".to_string(),
242 webhook_secret: vec![stripe::TEST_WEBHOOK_SECRET.to_string()],
243 webhook_secret_v2: Some(stripe::TEST_WEBHOOK_SECRET_V2.to_string()),
244 };
245 let client =
246 Arc::new(StripeClient::new(&stripe_config).expect("test Stripe client builds"));
247 Self::build(BuildOptions {
248 stripe_client: Some(client.clone() as Arc<dyn PaymentProvider>),
249 payment_caps: PaymentCapabilities::all(client),
250 ..Default::default()
251 })
252 .await
253 }
254
255 /// Harness with mock Stripe + mock email for full payment flow testing.
256 /// Access mocks via `harness.mock_stripe` and `harness.mock_email`.
257 #[allow(dead_code)]
258 pub(crate) async fn with_mocks() -> Self {
259 let mock_stripe = Arc::new(stripe::MockPaymentProvider::new());
260 let mock_email = Arc::new(email::MockEmailTransport::new());
261 let mem = Arc::new(InMemoryStorage::new());
262 let mut harness = Self::build(BuildOptions {
263 storage: Some(mem),
264 stripe_client: Some(mock_stripe.clone() as Arc<dyn PaymentProvider>),
265 payment_caps: PaymentCapabilities::all(mock_stripe.clone()),
266 mock_email: Some(mock_email),
267 ..Default::default()
268 })
269 .await;
270 harness.mock_stripe = Some(mock_stripe);
271 harness
272 }
273
274 /// Harness wired for creator-tier checkout: mock Stripe plus a configured
275 /// sticker price for the Everything tier (the founder window stays closed,
276 /// so checkout uses the sticker price ID, the mock ignores it anyway).
277 /// Exposes `mock_stripe` for asserting on the trial passed to Stripe.
278 #[allow(dead_code)]
279 pub(crate) async fn with_creator_tier_checkout() -> Self {
280 let mock_stripe = Arc::new(stripe::MockPaymentProvider::new());
281 let mut prices = std::collections::HashMap::new();
282 prices.insert(
283 makenotwork::db::CreatorTier::Everything,
284 "price_test_everything".to_string(),
285 );
286 let mut harness = Self::build(BuildOptions {
287 storage: Some(Arc::new(InMemoryStorage::new())),
288 stripe_client: Some(mock_stripe.clone() as Arc<dyn PaymentProvider>),
289 payment_caps: PaymentCapabilities::all(mock_stripe.clone()),
290 creator_tier_prices: Some(prices),
291 ..Default::default()
292 })
293 .await;
294 harness.mock_stripe = Some(mock_stripe);
295 harness
296 }
297
298 /// Harness with admin user configured. Returns (harness, admin_user_id).
299 #[allow(dead_code)]
300 pub(crate) async fn with_admin() -> (Self, UserId) {
301 let test_db = TestDb::new().await;
302 let pool = test_db.pool.clone();
303 let admin_id = Self::insert_admin_user(&pool).await;
304
305 let harness = Self::build(BuildOptions {
306 admin_user_id: Some(admin_id),
307 existing_db: Some(test_db),
308 ..Default::default()
309 })
310 .await;
311 (harness, admin_id)
312 }
313
314 /// Harness with Postmark webhook token configured.
315 #[allow(dead_code)]
316 pub(crate) async fn with_postmark() -> Self {
317 Self::build(BuildOptions {
318 postmark_webhook_token: Some("test-postmark-token".to_string()),
319 postmark_broadcast_webhook_token: Some("test-broadcast-token".to_string()),
320 ..Default::default()
321 })
322 .await
323 }
324
325 /// Harness with git repos path configured.
326 #[allow(dead_code)]
327 pub(crate) async fn with_git_repos(path: String) -> Self {
328 Self::build(BuildOptions {
329 git_repos_path: Some(path),
330 ..Default::default()
331 })
332 .await
333 }
334
335 /// Insert an admin user and return the ID.
336 async fn insert_admin_user(pool: &PgPool) -> UserId {
337 let password_hash =
338 makenotwork::auth::hash_password("password123").expect("hash_password for admin");
339 sqlx::query_scalar(
340 "INSERT INTO users (username, email, password_hash, email_verified)
341 VALUES ('admin', 'admin@test.com', $1, true)
342 RETURNING id",
343 )
344 .bind(&password_hash)
345 .fetch_one(pool)
346 .await
347 .expect("Failed to insert admin user")
348 }
349
350 /// Create a no-op scan pipeline for tests.
351 fn no_op_scanner() -> ScanPipeline {
352 let scan_config = ScanConfig {
353 clamav_socket: None,
354 yara_rules_dir: "/nonexistent".to_string(),
355 malwarebazaar_enabled: false,
356 urlhaus_enabled: false,
357 abuse_ch_auth_key: None,
358 metadefender_api_key: None,
359 yara_min_rule_files: 0,
360 clamav_max_scan_bytes: None,
361 };
362 ScanPipeline::new(&scan_config).expect("ScanPipeline::new with no-op config")
363 }
364
365 /// Builder shared by all constructors. Public so workflow tests can use custom `BuildOptions`.
366 pub(crate) async fn build(opts: BuildOptions) -> Self {
367 // `main` does this for the real binary; tests never run `main`, and
368 // without it the first Stripe connector build panics inside rustls.
369 makenotwork::crypto::install_default_crypto_provider();
370
371 let t0 = std::time::Instant::now();
372 let test_db = match opts.existing_db {
373 Some(db) => db,
374 None => TestDb::new().await,
375 };
376 let pool = test_db.pool.clone();
377
378 // Create session store (migration already applied in template DB)
379 let session_store = PostgresStore::new(pool.clone());
380 if !test_db.session_migrated {
381 session_store
382 .migrate()
383 .await
384 .expect("Failed to migrate session store");
385 }
386
387 let session_layer = SessionManagerLayer::new(session_store)
388 .with_secure(false)
389 .with_same_site(SameSite::Lax)
390 .with_expiry(Expiry::OnInactivity(CookieDuration::days(1)));
391
392 // Minimal config, no S3, no Stripe (those come from opts)
393 let config = Config {
394 host: "127.0.0.1".parse().unwrap(),
395 port: 0,
396 database_url: String::new(),
397 host_url: std::sync::Arc::from("http://localhost:3000"),
398 signing_secret: "test-signing-secret-for-integration-tests".to_string(),
399 storage: None,
400 synckit_storage: None,
401 public_storage: None,
402 rpm_storage: None,
403 rpm_base_url: opts.rpm_base_url.clone(),
404 stripe: None,
405 admin_user_id: opts.admin_user_id,
406 synckit_jwt_secret: Some("test-synckit-jwt-secret".to_string()),
407 scan: None,
408 cdn_base_url: opts
409 .cdn_base_url
410 .clone()
411 .unwrap_or_else(|| TEST_CDN_BASE.to_string()),
412 user_pages_host: std::sync::Arc::from("u.localhost"),
413 access_gate: opts.access_gate,
414 sso: opts.sso.clone(),
415 rate_limits: opts
416 .rate_limits
417 .unwrap_or_else(makenotwork::constants::RateLimits::relaxed),
418 // Askama everywhere unless a test says otherwise, matching every
419 // deployment until wave 2 flips a screen.
420 build: BuildConfig {
421 trigger_token: opts.build_trigger_token,
422 host_linux: None,
423 host_darwin: None,
424 git_repos_path: opts.git_repos_path,
425 git_ssh_host: None,
426 },
427 email_webhooks: EmailWebhookConfig {
428 webhook_token: opts.postmark_webhook_token,
429 broadcast_webhook_token: opts.postmark_broadcast_webhook_token,
430 inbound_webhook_token: opts.postmark_inbound_webhook_token,
431 enforce_sender_auth: true,
432 },
433 creator_pricing: CreatorTierPricing {
434 fan_plus_price_id: None,
435 tier_prices: opts.creator_tier_prices.unwrap_or_default(),
436 tier_annual_prices: std::collections::HashMap::new(),
437 tier_founder_prices: std::collections::HashMap::new(),
438 tier_founder_annual_prices: std::collections::HashMap::new(),
439 founder_window_open: opts.founder_window_open,
440 },
441 integrations: IntegrationsConfig {
442 // Was hardcoded `None` while `BuildOptions::mt_base_url` fed
443 // `mt_client` alone, so a test could ask for Multithreaded and
444 // get a client with no config behind it. Anything reading the
445 // config (the described forum-memberships screens do) saw it as
446 // unconfigured and refused. Default is still `None`.
447 mt_base_url: opts.mt_base_url.clone(),
448 wam_url: None,
449 internal_shared_secret: opts.internal_shared_secret.clone(),
450 cli_service_token: opts.cli_service_token.clone(),
451 alerts_ingest_token: None,
452 },
453 };
454
455 let mock_email_ref = opts.mock_email.clone();
456 let email = if let Some(ref mock) = opts.mock_email {
457 EmailClient::with_transport(mock.clone() as Arc<dyn makenotwork::email::EmailTransport>)
458 } else {
459 EmailClient::new(
460 EmailConfig {
461 postmark_token: None,
462 from_address: "test@makenot.work".to_string(),
463 from_name: "Test".to_string(),
464 },
465 Some(pool.clone()),
466 )
467 };
468
469 let rp_origin = url::Url::parse(&config.host_url).expect("test HOST_URL");
470 let rp_id = rp_origin
471 .host_str()
472 .expect("test HOST_URL host")
473 .to_string();
474 let webauthn = Arc::new(
475 webauthn_rs::WebauthnBuilder::new(&rp_id, &rp_origin)
476 .expect("WebauthnBuilder")
477 .rp_name("Test")
478 .build()
479 .expect("Webauthn"),
480 );
481
482 // Convert InMemoryStorage to trait object
483 let storage = opts.storage;
484 let s3 = storage
485 .clone()
486 .map(|s| s as Arc<dyn makenotwork::storage::StorageBackend>);
487 let synckit_s3 = opts
488 .synckit_storage
489 .map(|s| s as Arc<dyn makenotwork::storage::StorageBackend>);
490 // Public bucket shares the same in-memory backend as `s3` (one flat map,
491 // no bucket isolation) so the cross-bucket image promote resolves.
492 let public_s3 = s3.clone();
493 let rpm_s3 = s3.clone();
494
495 // Route through the same `AppState::build` constructor production uses
496 // (main.rs), so the derived in-memory state, start timestamps, the empty
497 // cache maps, the concurrency semaphores, has a single source of truth.
498 // The harness only supplies the externally-wired dependencies.
499 let state = AppState::build(AppStateParts {
500 db: pool.clone(),
501 config,
502 storage: AppStorage {
503 s3,
504 synckit_s3,
505 public_s3,
506 // The RPM bucket shares the same backend; the publish endpoint
507 // presigns into it and the tests read the object straight back.
508 rpm_s3,
509 },
510 payments: opts.stripe_client,
511 payment_caps: opts.payment_caps,
512 email,
513 docs: site_docs(),
514 tier_prices: {
515 // Install the process-global TierPrices so handler code paths
516 // that call CreatorTier::{price_cents,max_file_bytes,
517 // max_storage_bytes} work under test (they read the global,
518 // same as production). Idempotent across tests.
519 makenotwork::tier_prices::TierPrices::install_test_default();
520 makenotwork::tier_prices::TierPrices::global().clone()
521 },
522 runway_config: makenotwork::tier_prices::RunwayConfig::default(),
523 fee_calculator: makenotwork::fee_calculator::FeeCalculator::load(
524 "docs/business/assumptions.toml",
525 ),
526 scanner: opts.scanner,
527 webauthn,
528 syntax: None,
529 mt_client: opts
530 .mt_base_url
531 .zip(opts.internal_shared_secret)
532 .map(|(url, secret)| makenotwork::mt_client::MtClient::new(url, secret)),
533 wam: None,
534 domain_cache: Arc::new(dashmap::DashMap::new()),
535 metrics_handle: None,
536 page_view_tx: makenotwork::db::page_views::spawn_batcher(pool.clone()),
537 bg: makenotwork::background::spawn_pool_detached(),
538 });
539
540 // Capture scan deps before `build_app` consumes `state`.
541 let scan_deps = match (state.scanner.clone(), state.storage.s3.clone()) {
542 (Some(pipeline), Some(s3)) => Some(ScanDeps {
543 s3,
544 pipeline,
545 semaphore: state.limiters.scan_semaphore.clone(),
546 }),
547 _ => None,
548 };
549
550 let app = build_app(&state, session_layer);
551 let client = TestClient::new(app);
552
553 // Extract mock_stripe: if the stripe_client is a MockPaymentProvider,
554 // we stored the Arc in BuildOptions.stripe_client. We can't downcast the
555 // trait object, so with_mocks() stores the mock ref separately. For the
556 // general build path, mock_stripe is None.
557 let mock_stripe = None; // Set by with_mocks() post-build via direct field access
558
559 let build_ms = t0.elapsed().as_millis();
560 if build_ms > 1000 {
561 eprintln!("[test-harness] SLOW harness build: {build_ms}ms");
562 }
563
564 TestHarness {
565 client,
566 db: pool,
567 storage,
568 mock_email: mock_email_ref,
569 mock_stripe,
570 scan_deps,
571 state,
572 _test_db: test_db,
573 }
574 }
575
576 /// The config the app was built with.
577 ///
578 /// For the handful of library entry points a test drives directly rather
579 /// than through the router: `notes_index::reindex_repo` and the other
580 /// admin-side rebuilds take a `Config` because they open repositories off
581 /// disk, and the path they open is the harness's temporary directory.
582 #[allow(dead_code)]
583 pub(crate) fn config(&self) -> &makenotwork::config::Config {
584 &self.state.config
585 }
586
587 /// Run the orphaned-upload reaper once, synchronously.
588 ///
589 /// The scheduler drives this on a tick in production. Tests that want the
590 /// reaper's failure branches (a transient S3 delete handed off to the
591 /// durable queue, a best-effort multipart abort) need it to run at a known
592 /// point instead, the same reason `drain_s3_deletions` exists.
593 #[allow(dead_code)]
594 pub(crate) async fn run_orphan_upload_reaper(&self) {
595 makenotwork::scheduler::cleanup_orphaned_uploads_for_test(&self.state).await;
596 }
597
598 /// Sign up a new user via POST /join. Returns the user's ID.
599 pub(crate) async fn signup(&mut self, username: &str, email: &str, password: &str) -> UserId {
600 // Fetch a page first to establish session + CSRF
601 self.client.fetch_csrf_token().await;
602
603 let body = format!(
604 "username={}&email={}&password={}",
605 urlencoding::encode(username),
606 urlencoding::encode(email),
607 urlencoding::encode(password),
608 );
609
610 let resp = self.client.post_form("/join/step/account", &body).await;
611 assert_eq!(
612 resp.status, 200,
613 "Signup failed with status {}: {}",
614 resp.status, resp.text
615 );
616
617 // Login rotates the CSRF token, fetch the new one
618 self.client.fetch_csrf_token().await;
619
620 // Look up the user in the database
621 sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE username = $1")
622 .bind(username)
623 .fetch_one(&self.db)
624 .await
625 .expect("User not found after signup")
626 }
627
628 /// Grant creator permissions to a user via direct SQL.
629 pub(crate) async fn grant_creator(&self, user_id: UserId) {
630 sqlx::query("UPDATE users SET can_create_projects = true WHERE id = $1")
631 .bind(user_id)
632 .execute(&self.db)
633 .await
634 .expect("Failed to grant creator");
635 }
636
637 /// Trust a user for uploads via direct SQL.
638 pub(crate) async fn trust_user(&self, user_id: UserId) {
639 sqlx::query("UPDATE users SET upload_trusted = true WHERE id = $1")
640 .bind(user_id)
641 .execute(&self.db)
642 .await
643 .expect("Failed to trust user");
644 }
645
646 /// Give a user an active creator tier subscription via direct SQL.
647 /// Also syncs the denormalized `creator_tier` column on the users table.
648 pub(crate) async fn grant_tier(&self, user_id: UserId, tier: &str) {
649 sqlx::query(
650 r"INSERT INTO creator_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, tier, status)
651 VALUES ($1, 'sub_test_' || $1::text, 'cus_test_' || $1::text, $2, 'active')
652 ON CONFLICT (user_id) DO UPDATE SET tier = $2, status = 'active'",
653 )
654 .bind(user_id)
655 .bind(tier)
656 .execute(&self.db)
657 .await
658 .expect("Failed to grant tier");
659
660 sqlx::query("UPDATE users SET creator_tier = $2 WHERE id = $1")
661 .bind(user_id)
662 .bind(tier)
663 .execute(&self.db)
664 .await
665 .expect("Failed to sync creator_tier");
666 }
667
668 /// Suspend a user via direct SQL.
669 #[allow(dead_code)]
670 pub(crate) async fn suspend_user(&self, user_id: UserId) {
671 sqlx::query("UPDATE users SET suspended_at = NOW(), suspension_reason = 'test suspension' WHERE id = $1")
672 .bind(user_id)
673 .execute(&self.db)
674 .await
675 .expect("Failed to suspend user");
676 }
677
678 /// POST a single login attempt and return the response. Refreshes the
679 /// CSRF token first so the new Manual-posture `/login` (Phase 2) accepts
680 /// the form even when a previous `/logout` invalidated the cached token.
681 /// Use this for negative-path login tests (lockout, suspended, wrong
682 /// password) that need to inspect the response rather than asserting
683 /// success like `login()` does.
684 pub(crate) async fn failed_login_attempt(
685 &mut self,
686 login: &str,
687 password: &str,
688 ) -> client::TestResponse {
689 self.client.fetch_csrf_token().await;
690 let body = format!(
691 "login={}&password={}",
692 urlencoding::encode(login),
693 urlencoding::encode(password),
694 );
695 self.client.post_form("/login", &body).await
696 }
697
698 /// Synchronously drain queued scan jobs by running the worker loop in-
699 /// process until the queue is empty. Mirrors the production worker pool
700 /// without spawning a background task, integration tests call this
701 /// between upload-confirm and any assertion on `scan_status`.
702 pub(crate) async fn drain_scan_jobs(&self) {
703 let Some(ctx) = self.scan_worker_context() else {
704 return;
705 };
706 // Hard cap to avoid an infinite loop if a job re-enqueues itself.
707 for _ in 0..256 {
708 match makenotwork::scanning::worker::process_next_for_test(&ctx).await {
709 Ok(true) => {}
710 Ok(false) => return,
711 Err(e) => panic!("scan worker drain failed: {e}"),
712 }
713 }
714 panic!("drain_scan_jobs did not terminate within 256 iterations");
715 }
716
717 /// Run one scan job and return its outcome instead of panicking on failure.
718 ///
719 /// `drain_scan_jobs` treats a failing job as a broken test, which is right
720 /// for the happy paths but makes the worker's failure branch unobservable:
721 /// a job whose download fails marks itself `failed` and resets its entity to
722 /// `HeldForReview`, and no test could reach that while the only entry point
723 /// panicked. `Ok(true)` ran a job, `Ok(false)` found an empty queue, `Err`
724 /// carries the message the worker recorded in `last_error`.
725 #[allow(dead_code)]
726 pub(crate) async fn try_process_one_scan_job(&self) -> Result<bool, String> {
727 let Some(ctx) = self.scan_worker_context() else {
728 return Ok(false);
729 };
730 makenotwork::scanning::worker::process_next_for_test(&ctx)
731 .await
732 .map_err(|e| e.to_string())
733 }
734
735 /// The worker context both drain paths run against. `None` when the harness
736 /// was not built with a scanner.
737 fn scan_worker_context(&self) -> Option<makenotwork::scanning::worker::WorkerContext> {
738 let deps = self.scan_deps.as_ref()?;
739 Some(makenotwork::scanning::worker::WorkerContext {
740 db: self.db.clone(),
741 s3: deps.s3.clone(),
742 pipeline: deps.pipeline.clone(),
743 scan_semaphore: deps.semaphore.clone(),
744 wam: None,
745 bg: makenotwork::background::spawn_pool_detached(),
746 // No Cloudflare purge in tests; quarantine still deletes from origin.
747 cloudflare: None,
748 cdn_base_url: std::sync::Arc::from(TEST_CDN_BASE),
749 // OTA artifacts scan from the SyncKit bucket; tests share one backend.
750 synckit_s3: Some(deps.s3.clone()),
751 // Public bucket shares the same backend; image promotes copy here.
752 public_s3: Some(deps.s3.clone()),
753 config: self.state.config.clone(),
754 })
755 }
756
757 /// Synchronously perform any queued S3 object deletions (`main` bucket).
758 /// Handler-side deletes only enqueue to `pending_s3_deletions`; the actual
759 /// delete is the scheduler's job in production. Tests that assert an object
760 /// was removed from storage call this first (the deterministic mirror of
761 /// the production retry worker). Returns the number of objects deleted.
762 pub(crate) async fn drain_s3_deletions(&self) -> usize {
763 let Some(storage) = self.storage.as_ref() else {
764 return 0;
765 };
766 makenotwork::scheduler::drain_pending_s3_deletions_for_test(&self.db, storage.as_ref())
767 .await
768 }
769
770 /// Log in as an existing user via POST /login. The client's session
771 /// cookies are updated automatically.
772 pub(crate) async fn login(&mut self, login: &str, password: &str) {
773 // Fetch CSRF token first
774 self.client.fetch_csrf_token().await;
775
776 let body = format!(
777 "login={}&password={}",
778 urlencoding::encode(login),
779 urlencoding::encode(password),
780 );
781
782 let resp = self.client.post_form("/login", &body).await;
783 assert_eq!(
784 resp.status, 303,
785 "Login failed with status {}: {}",
786 resp.status, resp.text
787 );
788
789 // Login rotates the CSRF token, fetch the new one
790 self.client.fetch_csrf_token().await;
791 }
792
793 /// Create a test creator: signup, grant creator access, re-login.
794 /// Uses password "password123" and email "{username}@test.com".
795 pub(crate) async fn create_creator(&mut self, username: &str) -> UserId {
796 let user_id = self
797 .signup(username, &format!("{username}@test.com"), "password123")
798 .await;
799 self.grant_creator(user_id).await;
800 self.client.post_form("/logout", "").await;
801 self.login(username, "password123").await;
802 user_id
803 }
804
805 /// Create a test creator with a project and one item. Creator is logged in afterward.
806 /// Project slug: "{username}-proj". Returns all created IDs.
807 pub(crate) async fn create_creator_with_item(
808 &mut self,
809 username: &str,
810 item_type: &str,
811 price_cents: i64,
812 ) -> CreatorSetup {
813 let user_id = self.create_creator(username).await;
814
815 // Usernames may contain underscores (valid for accounts) but project
816 // slugs may not, the slug charset is lowercase letters, digits, and
817 // hyphens only. Sanitize so a `seller_vis` creator yields `seller-vis-proj`.
818 let slug = format!("{}-proj", username.replace('_', "-"));
819 let resp = self
820 .client
821 .post_form("/api/projects", &format!("slug={slug}&title=Test+Project"))
822 .await;
823 assert_eq!(resp.status, 200, "Create project failed: {}", resp.text);
824 let project: serde_json::Value = resp.json();
825 let project_id = project["id"].as_str().unwrap().to_string();
826
827 let resp = self
828 .client
829 .post_form(
830 &format!("/api/projects/{project_id}/items"),
831 &format!("title=Test+Item&item_type={item_type}&price_cents={price_cents}"),
832 )
833 .await;
834 assert_eq!(resp.status, 200, "Create item failed: {}", resp.text);
835 let item: serde_json::Value = resp.json();
836 let item_id = item["id"].as_str().unwrap().to_string();
837
838 CreatorSetup {
839 user_id,
840 project_id,
841 item_id,
842 slug,
843 }
844 }
845
846 /// Connect a user's Stripe account via direct SQL.
847 /// Sets stripe_account_id, stripe_charges_enabled, and stripe_onboarding_complete.
848 /// Use after `create_creator()` for tests that need a Stripe-connected seller.
849 pub(crate) async fn connect_stripe(&self, user_id: UserId, account_id: &str) {
850 sqlx::query(
851 "UPDATE users SET stripe_account_id = $2, stripe_charges_enabled = true, \
852 stripe_onboarding_complete = true, stripe_payouts_enabled = true WHERE id = $1",
853 )
854 .bind(user_id)
855 .bind(account_id)
856 .execute(&self.db)
857 .await
858 .expect("Failed to connect Stripe");
859 }
860
861 /// Publish both a project and an item.
862 pub(crate) async fn publish_project_and_item(&mut self, project_id: &str, item_id: &str) {
863 self.client
864 .put_json(
865 &format!("/api/projects/{project_id}"),
866 r#"{"is_public": true}"#,
867 )
868 .await;
869 self.client
870 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
871 .await;
872 }
873 }
874