//! Test harness for in-process integration tests. pub(crate) mod client; pub(crate) mod db; pub(crate) mod s3; use multithreaded::{AppState, config::Config, csrf, routes}; use sqlx::PgPool; use tower_sessions::cookie::SameSite; use tower_sessions::{Expiry, SessionManagerLayer}; use tower_sessions_sqlx_store::PostgresStore; use uuid::Uuid; use self::client::TestClient; use self::db::TestDb; use self::s3::S3Stub; pub(crate) struct TestHarness { pub client: TestClient, pub db: PgPool, /// The in-process object store, present only when `HarnessOptions::s3` was /// set. Tests assert against it to prove an upload did (or did not) land. pub s3: Option, _test_db: TestDb, } /// Options for customizing a [`TestHarness`]. #[derive(Default)] pub(crate) struct HarnessOptions { pub platform_admin_id: Option, /// Override MNW base URL, point at a wiremock server for tests that exercise /// OAuth/userinfo flows. Defaults to a black-hole URL that fails fast. pub mnw_base_url: Option, /// Wire the real SSRF-safe link-preview HTTP client instead of `Noop`, so /// tests can drive `/img-proxy` through the production fetch path. Off by /// default (post-creation preview fetches stay inert in the common case). pub link_preview_http: bool, /// Start an in-process S3 stub and point the app at it. Off by default, so /// `upload_returns_503_without_s3` still exercises the unconfigured path. pub s3: bool, } impl TestHarness { pub(crate) async fn new() -> Self { Self::with_options(HarnessOptions::default()).await } pub(crate) async fn with_options(opts: HarnessOptions) -> Self { let test_db = TestDb::new().await; let pool = test_db.pool.clone(); let session_store = PostgresStore::new(pool.clone()); session_store .migrate() .await .expect("Failed to migrate session store"); let session_layer = SessionManagerLayer::new(session_store) .with_secure(false) .with_same_site(SameSite::Lax) .with_expiry(Expiry::OnInactivity( tower_sessions::cookie::time::Duration::days(1), )); let s3_stub = if opts.s3 { Some(S3Stub::start().await) } else { None }; let s3_config = s3_stub .as_ref() .map(|stub| multithreaded::config::S3Config { endpoint: stub.endpoint.clone(), bucket: stub.bucket.clone(), access_key: "test-access-key".to_string(), secret_key: "test-secret-key".to_string(), region: "us-east-1".to_string(), }); let config = Config { mnw_base_url: opts .mnw_base_url .as_deref() .unwrap_or("http://127.0.0.1:9999") .into(), oauth_client_id: "test-client-id".to_string(), oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(), platform_admin_id: opts.platform_admin_id, cookie_secure: false, s3: s3_config.clone(), internal_shared_secret: None, trusted_proxies: std::sync::Arc::from([std::net::IpAddr::from([127, 0, 0, 1])]), }; multithreaded::error_page::init(config.mnw_base_url.clone()); let state = AppState { db: pool.clone(), config, http: multithreaded::tls::builder().build().unwrap(), link_preview: if opts.link_preview_http { multithreaded::link_preview::LinkPreviewFetcher::Http( multithreaded::link_preview::build_preview_client(), ) } else { multithreaded::link_preview::LinkPreviewFetcher::Noop }, s3: match &s3_config { Some(cfg) => Some(std::sync::Arc::new( multithreaded::storage::S3Storage::new(cfg) .await .expect("failed to build S3 client against the stub"), )), None => None, }, chat: std::sync::Arc::new(multithreaded::chat::new_chat()), chat_identities: multithreaded::chat::IdentityCache::new(), }; // Build the app with a /_test/login route for setting sessions without OAuth let test_login = axum::Router::new() .route("/_test/login", axum::routing::post(test_login_handler)) .with_state(state.clone()); let app = routes::forum_routes(state) .merge(test_login) .layer(axum::middleware::from_fn(csrf::csrf_middleware)) .layer(session_layer); let client = TestClient::new(app); TestHarness { client, db: pool, s3: s3_stub, _test_db: test_db, } } /// Create a harness backed by the in-process S3 stub, so upload handlers /// run past the `state.s3` guard. pub(crate) async fn new_with_s3() -> Self { Self::with_options(HarnessOptions { s3: true, ..Default::default() }) .await } /// Log in as a user by username. Creates the user if needed. Returns the user's UUID. pub(crate) async fn login_as(&mut self, username: &str) -> Uuid { let user_id = Uuid::new_v4(); sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $3) ON CONFLICT (mnw_account_id) DO NOTHING", ) .bind(user_id) .bind(username) .bind(username) .execute(&self.db) .await .expect("Failed to insert test user"); // GET a page to establish session + CSRF token self.client.get("/").await; // POST to /_test/login to set session (exempt from CSRF) let body = serde_json::json!({ "user_id": user_id.to_string(), "username": username, }); self.client .post_json("/_test/login", &body.to_string()) .await; user_id } /// Create a community via direct SQL. Returns the community ID. pub(crate) async fn create_community(&self, name: &str, slug: &str) -> Uuid { sqlx::query_scalar( "INSERT INTO communities (name, slug) VALUES ($1, $2) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name RETURNING id", ) .bind(name) .bind(slug) .fetch_one(&self.db) .await .expect("Failed to create community") } /// Create a category via direct SQL. Returns the category ID. pub(crate) async fn create_category(&self, community_id: Uuid, name: &str, slug: &str) -> Uuid { sqlx::query_scalar( "INSERT INTO categories (community_id, name, slug, sort_order) VALUES ($1, $2, $3, 0) ON CONFLICT (community_id, slug) DO UPDATE SET name = EXCLUDED.name RETURNING id", ) .bind(community_id) .bind(name) .bind(slug) .fetch_one(&self.db) .await .expect("Failed to create category") } /// Add a membership via direct SQL. pub(crate) async fn add_membership(&self, user_id: Uuid, community_id: Uuid, role: &str) { sqlx::query( "INSERT INTO memberships (user_id, community_id, role) VALUES ($1, $2, $3) ON CONFLICT (user_id, community_id) DO UPDATE SET role = $3", ) .bind(user_id) .bind(community_id) .bind(role) .execute(&self.db) .await .expect("Failed to add membership"); } /// Create a harness with a specific platform admin user ID. pub(crate) async fn new_with_admin(admin_id: Uuid) -> Self { Self::with_options(HarnessOptions { platform_admin_id: Some(admin_id), ..Default::default() }) .await } /// Ban a user in a community via direct SQL. pub(crate) async fn ban_user( &self, community_id: Uuid, user_id: Uuid, banned_by: Uuid, ban_type: &str, ) { sqlx::query( "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type) VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, user_id, ban_type) DO NOTHING", ) .bind(community_id) .bind(user_id) .bind(banned_by) .bind(ban_type) .execute(&self.db) .await .expect("Failed to ban user"); } /// Create a thread with an initial post via direct SQL. Returns thread ID. pub(crate) async fn create_thread_with_post( &self, category_id: Uuid, author_id: Uuid, title: &str, body: &str, ) -> Uuid { let (thread_id, _post_id) = mt_db::mutations::create_thread_with_op( &self.db, category_id, author_id, title, body, &format!("

{body}

"), ) .await .expect("Failed to create thread with op"); thread_id } } /// Handler for `POST /_test/login`, sets session keys without OAuth. /// /// Accepts optional `refresh_token` and `perks` (JSON object) fields to seed the /// session keys normally populated by the OAuth callback. The RP stores a /// rotating refresh token (not an access token) at rest, see finding S13. async fn test_login_handler( session: tower_sessions::Session, axum::Json(payload): axum::Json, ) -> axum::http::StatusCode { let user_id = payload["user_id"] .as_str() .and_then(|s| Uuid::parse_str(s).ok()) .expect("user_id required"); let username = payload["username"].as_str().expect("username required"); let _ = session.insert("user_id", user_id).await; let _ = session.insert("username", username).await; if let Some(token) = payload.get("refresh_token").and_then(|v| v.as_str()) { let _ = session.insert("mnw_refresh_token", token).await; } if let Some(perks) = payload.get("perks") { let _ = session.insert("perks", perks).await; } axum::http::StatusCode::OK }