Skip to main content

max / makenotwork

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