Skip to main content

max / makenotwork

10.0 KB · 307 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: reqwest::Client::new(),
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 };
117
118 // Build the app with a /_test/login route for setting sessions without OAuth
119 let test_login = axum::Router::new()
120 .route("/_test/login", axum::routing::post(test_login_handler))
121 .with_state(state.clone());
122
123 let app = routes::forum_routes(state)
124 .merge(test_login)
125 .layer(axum::middleware::from_fn(csrf::csrf_middleware))
126 .layer(session_layer);
127
128 let client = TestClient::new(app);
129
130 TestHarness {
131 client,
132 db: pool,
133 s3: s3_stub,
134 _test_db: test_db,
135 }
136 }
137
138 /// Create a harness backed by the in-process S3 stub, so upload handlers
139 /// run past the `state.s3` guard.
140 pub(crate) async fn new_with_s3() -> Self {
141 Self::with_options(HarnessOptions {
142 s3: true,
143 ..Default::default()
144 })
145 .await
146 }
147
148 /// Log in as a user by username. Creates the user if needed. Returns the user's UUID.
149 pub(crate) async fn login_as(&mut self, username: &str) -> Uuid {
150 let user_id = Uuid::new_v4();
151
152 // Insert user into the database
153 sqlx::query(
154 "INSERT INTO users (mnw_account_id, username, display_name)
155 VALUES ($1, $2, $3)
156 ON CONFLICT (mnw_account_id) DO NOTHING",
157 )
158 .bind(user_id)
159 .bind(username)
160 .bind(username)
161 .execute(&self.db)
162 .await
163 .expect("Failed to insert test user");
164
165 // GET a page to establish session + CSRF token
166 self.client.get("/").await;
167
168 // POST to /_test/login to set session (exempt from CSRF)
169 let body = serde_json::json!({
170 "user_id": user_id.to_string(),
171 "username": username,
172 });
173 self.client
174 .post_json("/_test/login", &body.to_string())
175 .await;
176
177 user_id
178 }
179
180 /// Create a community via direct SQL. Returns the community ID.
181 pub(crate) async fn create_community(&self, name: &str, slug: &str) -> Uuid {
182 sqlx::query_scalar(
183 "INSERT INTO communities (name, slug)
184 VALUES ($1, $2)
185 ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
186 RETURNING id",
187 )
188 .bind(name)
189 .bind(slug)
190 .fetch_one(&self.db)
191 .await
192 .expect("Failed to create community")
193 }
194
195 /// Create a category via direct SQL. Returns the category ID.
196 pub(crate) async fn create_category(&self, community_id: Uuid, name: &str, slug: &str) -> Uuid {
197 sqlx::query_scalar(
198 "INSERT INTO categories (community_id, name, slug, sort_order)
199 VALUES ($1, $2, $3, 0)
200 ON CONFLICT (community_id, slug) DO UPDATE SET name = EXCLUDED.name
201 RETURNING id",
202 )
203 .bind(community_id)
204 .bind(name)
205 .bind(slug)
206 .fetch_one(&self.db)
207 .await
208 .expect("Failed to create category")
209 }
210
211 /// Add a membership via direct SQL.
212 pub(crate) async fn add_membership(&self, user_id: Uuid, community_id: Uuid, role: &str) {
213 sqlx::query(
214 "INSERT INTO memberships (user_id, community_id, role)
215 VALUES ($1, $2, $3)
216 ON CONFLICT (user_id, community_id) DO UPDATE SET role = $3",
217 )
218 .bind(user_id)
219 .bind(community_id)
220 .bind(role)
221 .execute(&self.db)
222 .await
223 .expect("Failed to add membership");
224 }
225
226 /// Create a harness with a specific platform admin user ID.
227 pub(crate) async fn new_with_admin(admin_id: Uuid) -> Self {
228 Self::with_options(HarnessOptions {
229 platform_admin_id: Some(admin_id),
230 ..Default::default()
231 })
232 .await
233 }
234
235 /// Ban a user in a community via direct SQL.
236 pub(crate) async fn ban_user(
237 &self,
238 community_id: Uuid,
239 user_id: Uuid,
240 banned_by: Uuid,
241 ban_type: &str,
242 ) {
243 sqlx::query(
244 "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type)
245 VALUES ($1, $2, $3, $4)
246 ON CONFLICT (community_id, user_id, ban_type) DO NOTHING",
247 )
248 .bind(community_id)
249 .bind(user_id)
250 .bind(banned_by)
251 .bind(ban_type)
252 .execute(&self.db)
253 .await
254 .expect("Failed to ban user");
255 }
256
257 /// Create a thread with an initial post via direct SQL. Returns thread ID.
258 pub(crate) async fn create_thread_with_post(
259 &self,
260 category_id: Uuid,
261 author_id: Uuid,
262 title: &str,
263 body: &str,
264 ) -> Uuid {
265 let (thread_id, _post_id) = mt_db::mutations::create_thread_with_op(
266 &self.db,
267 category_id,
268 author_id,
269 title,
270 body,
271 &format!("<p>{body}</p>"),
272 )
273 .await
274 .expect("Failed to create thread with op");
275
276 thread_id
277 }
278 }
279
280 /// Handler for `POST /_test/login`, sets session keys without OAuth.
281 ///
282 /// Accepts optional `refresh_token` and `perks` (JSON object) fields to seed the
283 /// session keys normally populated by the OAuth callback. The RP stores a
284 /// rotating refresh token (not an access token) at rest, see finding S13.
285 async fn test_login_handler(
286 session: tower_sessions::Session,
287 axum::Json(payload): axum::Json<serde_json::Value>,
288 ) -> axum::http::StatusCode {
289 let user_id = payload["user_id"]
290 .as_str()
291 .and_then(|s| Uuid::parse_str(s).ok())
292 .expect("user_id required");
293 let username = payload["username"].as_str().expect("username required");
294
295 let _ = session.insert("user_id", user_id).await;
296 let _ = session.insert("username", username).await;
297
298 if let Some(token) = payload.get("refresh_token").and_then(|v| v.as_str()) {
299 let _ = session.insert("mnw_refresh_token", token).await;
300 }
301 if let Some(perks) = payload.get("perks") {
302 let _ = session.insert("perks", perks).await;
303 }
304
305 axum::http::StatusCode::OK
306 }
307