Skip to main content

max / makenotwork

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