Skip to main content

max / makenotwork

11.0 KB · 325 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 s3;
6
7 use multithreaded::{AppState, config::Config, csrf, routes};
8 use sqlx::PgPool;
9 use tower_sessions::cookie::SameSite;
10 use tower_sessions::{Expiry, SessionManagerLayer};
11 use tower_sessions_sqlx_store::PostgresStore;
12 use uuid::Uuid;
13
14 use self::client::TestClient;
15 use self::db::TestDb;
16 use self::s3::S3Stub;
17
18 pub(crate) struct TestHarness {
19 pub client: TestClient,
20 pub db: PgPool,
21 /// The in-process object store, present only when `HarnessOptions::s3` was
22 /// set. Tests assert against it to prove an upload did (or did not) land.
23 pub s3: Option<S3Stub>,
24 _test_db: TestDb,
25 }
26
27 /// Options for customizing a [`TestHarness`].
28 #[derive(Default)]
29 pub(crate) struct HarnessOptions {
30 pub platform_admin_id: Option<Uuid>,
31 /// Override MNW base URL, point at a wiremock server for tests that exercise
32 /// OAuth/userinfo flows. Defaults to a black-hole URL that fails fast.
33 pub mnw_base_url: Option<String>,
34 /// Wire the real SSRF-safe link-preview HTTP client instead of `Noop`, so
35 /// tests can drive `/img-proxy` through the production fetch path. Off by
36 /// default (post-creation preview fetches stay inert in the common case).
37 pub link_preview_http: bool,
38 /// Start an in-process S3 stub and point the app at it. Off by default, so
39 /// `upload_returns_503_without_s3` still exercises the unconfigured path.
40 pub s3: bool,
41 }
42
43 impl TestHarness {
44 pub(crate) async fn new() -> Self {
45 Self::with_options(HarnessOptions::default()).await
46 }
47
48 pub(crate) async fn with_options(opts: HarnessOptions) -> Self {
49 let test_db = TestDb::new().await;
50 let pool = test_db.pool.clone();
51
52 let session_store = PostgresStore::new(pool.clone());
53 session_store
54 .migrate()
55 .await
56 .expect("Failed to migrate session store");
57
58 let session_layer = SessionManagerLayer::new(session_store)
59 .with_secure(false)
60 .with_same_site(SameSite::Lax)
61 .with_expiry(Expiry::OnInactivity(
62 tower_sessions::cookie::time::Duration::days(1),
63 ));
64
65 let s3_stub = if opts.s3 {
66 Some(S3Stub::start().await)
67 } else {
68 None
69 };
70 let s3_config = s3_stub
71 .as_ref()
72 .map(|stub| multithreaded::config::S3Config {
73 endpoint: stub.endpoint.clone(),
74 bucket: stub.bucket.clone(),
75 access_key: "test-access-key".to_string(),
76 secret_key: "test-secret-key".to_string(),
77 region: "us-east-1".to_string(),
78 });
79
80 let config = Config {
81 mnw_base_url: opts
82 .mnw_base_url
83 .as_deref()
84 .unwrap_or("http://127.0.0.1:9999")
85 .into(),
86 oauth_client_id: "test-client-id".to_string(),
87 oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(),
88 platform_admin_id: opts.platform_admin_id,
89 cookie_secure: false,
90 s3: s3_config.clone(),
91 internal_shared_secret: None,
92 trusted_proxies: std::sync::Arc::from([std::net::IpAddr::from([127, 0, 0, 1])]),
93 };
94
95 multithreaded::error_page::init(config.mnw_base_url.clone());
96
97 let state = AppState {
98 db: pool.clone(),
99 config,
100 http: multithreaded::tls::builder().build().unwrap(),
101 link_preview: if opts.link_preview_http {
102 multithreaded::link_preview::LinkPreviewFetcher::Http(
103 multithreaded::link_preview::build_preview_client(),
104 )
105 } else {
106 multithreaded::link_preview::LinkPreviewFetcher::Noop
107 },
108 s3: match &s3_config {
109 Some(cfg) => Some(std::sync::Arc::new(
110 multithreaded::storage::S3Storage::new(cfg)
111 .await
112 .expect("failed to build S3 client against the stub"),
113 )),
114 None => None,
115 },
116 chat: std::sync::Arc::new(multithreaded::chat::new_chat()),
117 chat_identities: multithreaded::chat::IdentityCache::new(),
118 };
119
120 // Build the app with a /_test/login route for setting sessions without OAuth
121 let test_login = axum::Router::new()
122 .route("/_test/login", axum::routing::post(test_login_handler))
123 .with_state(state.clone());
124
125 let app = routes::forum_routes(state)
126 .merge(test_login)
127 .layer(axum::middleware::from_fn(csrf::csrf_middleware))
128 .layer(session_layer);
129
130 let client = TestClient::new(app);
131
132 TestHarness {
133 client,
134 db: pool,
135 s3: s3_stub,
136 _test_db: test_db,
137 }
138 }
139
140 /// Create a harness backed by the in-process S3 stub, so upload handlers
141 /// run past the `state.s3` guard.
142 pub(crate) async fn new_with_s3() -> Self {
143 Self::with_options(HarnessOptions {
144 s3: true,
145 ..Default::default()
146 })
147 .await
148 }
149
150 /// Log in as a user by username. Creates the user if needed. Returns the user's UUID.
151 pub(crate) async fn login_as(&mut self, username: &str) -> Uuid {
152 self.login_as_id(Uuid::new_v4(), username).await
153 }
154
155 /// Log in as a user with a caller-chosen UUID. Creates the user if needed.
156 ///
157 /// Tests that need the session `user_id` to match an id the app already
158 /// knows about, such as the platform admin id handed to
159 /// [`TestHarness::new_with_admin`], use this rather than `login_as`, which
160 /// picks a random UUID.
161 pub(crate) async fn login_as_id(&mut self, user_id: Uuid, username: &str) -> Uuid {
162 sqlx::query(
163 "INSERT INTO users (mnw_account_id, username, display_name)
164 VALUES ($1, $2, $3)
165 ON CONFLICT (mnw_account_id) DO NOTHING",
166 )
167 .bind(user_id)
168 .bind(username)
169 .bind(username)
170 .execute(&self.db)
171 .await
172 .expect("Failed to insert test user");
173
174 // GET a page to establish session + CSRF token
175 self.client.get("/").await;
176
177 // POST to /_test/login to set session (exempt from CSRF)
178 let body = serde_json::json!({
179 "user_id": user_id.to_string(),
180 "username": username,
181 });
182 self.client
183 .post_json("/_test/login", &body.to_string())
184 .await;
185
186 user_id
187 }
188
189 /// Create a community via direct SQL. Returns the community ID.
190 pub(crate) async fn create_community(&self, name: &str, slug: &str) -> Uuid {
191 sqlx::query_scalar(
192 "INSERT INTO communities (name, slug)
193 VALUES ($1, $2)
194 ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
195 RETURNING id",
196 )
197 .bind(name)
198 .bind(slug)
199 .fetch_one(&self.db)
200 .await
201 .expect("Failed to create community")
202 }
203
204 /// Create a category via direct SQL. Returns the category ID.
205 pub(crate) async fn create_category(&self, community_id: Uuid, name: &str, slug: &str) -> Uuid {
206 sqlx::query_scalar(
207 "INSERT INTO categories (community_id, name, slug, sort_order)
208 VALUES ($1, $2, $3, 0)
209 ON CONFLICT (community_id, slug) DO UPDATE SET name = EXCLUDED.name
210 RETURNING id",
211 )
212 .bind(community_id)
213 .bind(name)
214 .bind(slug)
215 .fetch_one(&self.db)
216 .await
217 .expect("Failed to create category")
218 }
219
220 /// Add a membership via direct SQL.
221 pub(crate) async fn add_membership(&self, user_id: Uuid, community_id: Uuid, role: &str) {
222 sqlx::query(
223 "INSERT INTO memberships (user_id, community_id, role)
224 VALUES ($1, $2, $3)
225 ON CONFLICT (user_id, community_id) DO UPDATE SET role = $3",
226 )
227 .bind(user_id)
228 .bind(community_id)
229 .bind(role)
230 .execute(&self.db)
231 .await
232 .expect("Failed to add membership");
233 }
234
235 /// Create a harness with a specific platform admin user ID.
236 pub(crate) async fn new_with_admin(admin_id: Uuid) -> Self {
237 Self::with_options(HarnessOptions {
238 platform_admin_id: Some(admin_id),
239 ..Default::default()
240 })
241 .await
242 }
243
244 /// Create a harness with a specific platform admin user ID and a session
245 /// already logged in as that admin. One call covers what admin route tests
246 /// need before they can touch `/_admin`.
247 pub(crate) async fn new_with_admin_session(admin_id: Uuid) -> Self {
248 let mut h = Self::new_with_admin(admin_id).await;
249 h.login_as_id(admin_id, "admin").await;
250 h
251 }
252
253 /// Ban a user in a community via direct SQL.
254 pub(crate) async fn ban_user(
255 &self,
256 community_id: Uuid,
257 user_id: Uuid,
258 banned_by: Uuid,
259 ban_type: &str,
260 ) {
261 sqlx::query(
262 "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type)
263 VALUES ($1, $2, $3, $4)
264 ON CONFLICT (community_id, user_id, ban_type) DO NOTHING",
265 )
266 .bind(community_id)
267 .bind(user_id)
268 .bind(banned_by)
269 .bind(ban_type)
270 .execute(&self.db)
271 .await
272 .expect("Failed to ban user");
273 }
274
275 /// Create a thread with an initial post via direct SQL. Returns thread ID.
276 pub(crate) async fn create_thread_with_post(
277 &self,
278 category_id: Uuid,
279 author_id: Uuid,
280 title: &str,
281 body: &str,
282 ) -> Uuid {
283 let (thread_id, _post_id) = mt_db::mutations::create_thread_with_op(
284 &self.db,
285 category_id,
286 author_id,
287 title,
288 body,
289 &format!("<p>{body}</p>"),
290 )
291 .await
292 .expect("Failed to create thread with op");
293
294 thread_id
295 }
296 }
297
298 /// Handler for `POST /_test/login`, sets session keys without OAuth.
299 ///
300 /// Accepts optional `refresh_token` and `perks` (JSON object) fields to seed the
301 /// session keys normally populated by the OAuth callback. The RP stores a
302 /// rotating refresh token (not an access token) at rest, see finding S13.
303 async fn test_login_handler(
304 session: tower_sessions::Session,
305 axum::Json(payload): axum::Json<serde_json::Value>,
306 ) -> axum::http::StatusCode {
307 let user_id = payload["user_id"]
308 .as_str()
309 .and_then(|s| Uuid::parse_str(s).ok())
310 .expect("user_id required");
311 let username = payload["username"].as_str().expect("username required");
312
313 let _ = session.insert("user_id", user_id).await;
314 let _ = session.insert("username", username).await;
315
316 if let Some(token) = payload.get("refresh_token").and_then(|v| v.as_str()) {
317 let _ = session.insert("mnw_refresh_token", token).await;
318 }
319 if let Some(perks) = payload.get("perks") {
320 let _ = session.insert("perks", perks).await;
321 }
322
323 axum::http::StatusCode::OK
324 }
325