Skip to main content

max / makenotwork

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