Skip to main content

max / makenotwork

7.8 KB · 257 lines History Blame Raw
1 //! Test harness for in-process integration tests.
2
3 pub mod client;
4 pub mod db;
5
6 use multithreaded::{csrf, routes, AppState, config::Config};
7 use sqlx::PgPool;
8 use tower_sessions::cookie::SameSite;
9 use tower_sessions::{Expiry, SessionManagerLayer};
10 use tower_sessions_sqlx_store::PostgresStore;
11 use uuid::Uuid;
12
13 use self::client::TestClient;
14 use self::db::TestDb;
15
16 pub struct TestHarness {
17 pub client: TestClient,
18 pub db: PgPool,
19 _test_db: TestDb,
20 }
21
22 /// Options for customizing a [`TestHarness`].
23 #[derive(Default)]
24 pub struct HarnessOptions {
25 pub platform_admin_id: Option<Uuid>,
26 /// Override MNW base URL — point at a wiremock server for tests that exercise
27 /// OAuth/userinfo flows. Defaults to a black-hole URL that fails fast.
28 pub mnw_base_url: Option<String>,
29 }
30
31 impl TestHarness {
32 pub async fn new() -> Self {
33 Self::with_options(HarnessOptions::default()).await
34 }
35
36 pub async fn with_options(opts: HarnessOptions) -> Self {
37 let test_db = TestDb::new().await;
38 let pool = test_db.pool.clone();
39
40 let session_store = PostgresStore::new(pool.clone());
41 session_store
42 .migrate()
43 .await
44 .expect("Failed to migrate session store");
45
46 let session_layer = SessionManagerLayer::new(session_store)
47 .with_secure(false)
48 .with_same_site(SameSite::Lax)
49 .with_expiry(Expiry::OnInactivity(
50 tower_sessions::cookie::time::Duration::days(1),
51 ));
52
53 let config = Config {
54 mnw_base_url: opts
55 .mnw_base_url
56 .as_deref()
57 .unwrap_or("http://127.0.0.1:9999")
58 .into(),
59 oauth_client_id: "test-client-id".to_string(),
60 oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(),
61 platform_admin_id: opts.platform_admin_id,
62 cookie_secure: false,
63 s3: None,
64 internal_shared_secret: None,
65 };
66
67 let state = AppState {
68 db: pool.clone(),
69 config,
70 http: reqwest::Client::new(),
71 link_preview: multithreaded::link_preview::LinkPreviewFetcher::Noop,
72 s3: None,
73 };
74
75 // Build the app with a /_test/login route for setting sessions without OAuth
76 let test_login = axum::Router::new()
77 .route("/_test/login", axum::routing::post(test_login_handler))
78 .with_state(state.clone());
79
80 let app = routes::forum_routes(state)
81 .merge(test_login)
82 .layer(axum::middleware::from_fn(csrf::csrf_middleware))
83 .layer(session_layer);
84
85 let client = TestClient::new(app);
86
87 TestHarness {
88 client,
89 db: pool,
90 _test_db: test_db,
91 }
92 }
93
94 /// Log in as a user by username. Creates the user if needed. Returns the user's UUID.
95 pub async fn login_as(&mut self, username: &str) -> Uuid {
96 let user_id = Uuid::new_v4();
97
98 // Insert user into the database
99 sqlx::query(
100 "INSERT INTO users (mnw_account_id, username, display_name)
101 VALUES ($1, $2, $3)
102 ON CONFLICT (mnw_account_id) DO NOTHING",
103 )
104 .bind(user_id)
105 .bind(username)
106 .bind(username)
107 .execute(&self.db)
108 .await
109 .expect("Failed to insert test user");
110
111 // GET a page to establish session + CSRF token
112 self.client.get("/").await;
113
114 // POST to /_test/login to set session (exempt from CSRF)
115 let body = serde_json::json!({
116 "user_id": user_id.to_string(),
117 "username": username,
118 });
119 self.client
120 .post_json("/_test/login", &body.to_string())
121 .await;
122
123 user_id
124 }
125
126 /// Create a community via direct SQL. Returns the community ID.
127 pub async fn create_community(&self, name: &str, slug: &str) -> Uuid {
128 sqlx::query_scalar(
129 "INSERT INTO communities (name, slug)
130 VALUES ($1, $2)
131 ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
132 RETURNING id",
133 )
134 .bind(name)
135 .bind(slug)
136 .fetch_one(&self.db)
137 .await
138 .expect("Failed to create community")
139 }
140
141 /// Create a category via direct SQL. Returns the category ID.
142 pub async fn create_category(
143 &self,
144 community_id: Uuid,
145 name: &str,
146 slug: &str,
147 ) -> Uuid {
148 sqlx::query_scalar(
149 "INSERT INTO categories (community_id, name, slug, sort_order)
150 VALUES ($1, $2, $3, 0)
151 ON CONFLICT (community_id, slug) DO UPDATE SET name = EXCLUDED.name
152 RETURNING id",
153 )
154 .bind(community_id)
155 .bind(name)
156 .bind(slug)
157 .fetch_one(&self.db)
158 .await
159 .expect("Failed to create category")
160 }
161
162 /// Add a membership via direct SQL.
163 pub async fn add_membership(&self, user_id: Uuid, community_id: Uuid, role: &str) {
164 sqlx::query(
165 "INSERT INTO memberships (user_id, community_id, role)
166 VALUES ($1, $2, $3)
167 ON CONFLICT (user_id, community_id) DO UPDATE SET role = $3",
168 )
169 .bind(user_id)
170 .bind(community_id)
171 .bind(role)
172 .execute(&self.db)
173 .await
174 .expect("Failed to add membership");
175 }
176
177 /// Create a harness with a specific platform admin user ID.
178 pub async fn new_with_admin(admin_id: Uuid) -> Self {
179 Self::with_options(HarnessOptions {
180 platform_admin_id: Some(admin_id),
181 ..Default::default()
182 })
183 .await
184 }
185
186 /// Ban a user in a community via direct SQL.
187 pub async fn ban_user(&self, community_id: Uuid, user_id: Uuid, banned_by: Uuid, ban_type: &str) {
188 sqlx::query(
189 "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type)
190 VALUES ($1, $2, $3, $4)
191 ON CONFLICT (community_id, user_id, ban_type) DO NOTHING",
192 )
193 .bind(community_id)
194 .bind(user_id)
195 .bind(banned_by)
196 .bind(ban_type)
197 .execute(&self.db)
198 .await
199 .expect("Failed to ban user");
200 }
201
202 /// Create a thread with an initial post via direct SQL. Returns thread ID.
203 pub async fn create_thread_with_post(
204 &self,
205 category_id: Uuid,
206 author_id: Uuid,
207 title: &str,
208 body: &str,
209 ) -> Uuid {
210 let thread_id = mt_db::mutations::create_thread(&self.db, category_id, author_id, title)
211 .await
212 .expect("Failed to create thread");
213
214 mt_db::mutations::create_post(
215 &self.db,
216 thread_id,
217 author_id,
218 body,
219 &format!("<p>{}</p>", body),
220 false,
221 )
222 .await
223 .expect("Failed to create post");
224
225 thread_id
226 }
227 }
228
229 /// Handler for `POST /_test/login` — sets session keys without OAuth.
230 ///
231 /// Accepts optional `access_token` and `perks` (JSON object) fields to seed the
232 /// fields normally populated by the OAuth callback.
233 async fn test_login_handler(
234 session: tower_sessions::Session,
235 axum::Json(payload): axum::Json<serde_json::Value>,
236 ) -> axum::http::StatusCode {
237 let user_id = payload["user_id"]
238 .as_str()
239 .and_then(|s| Uuid::parse_str(s).ok())
240 .expect("user_id required");
241 let username = payload["username"]
242 .as_str()
243 .expect("username required");
244
245 let _ = session.insert("user_id", user_id).await;
246 let _ = session.insert("username", username).await;
247
248 if let Some(token) = payload.get("access_token").and_then(|v| v.as_str()) {
249 let _ = session.insert("mnw_access_token", token).await;
250 }
251 if let Some(perks) = payload.get("perks") {
252 let _ = session.insert("perks", perks).await;
253 }
254
255 axum::http::StatusCode::OK
256 }
257