Skip to main content

max / multithreaded

18.2 KB · 534 lines History Blame Raw
1 //! Integration tests for the internal API (HMAC-signed requests from MNW).
2
3 use axum::body::Body;
4 use axum::extract::ConnectInfo;
5 use axum::http::{Method, Request, StatusCode};
6 use axum::Router;
7 use hmac::{Hmac, Mac};
8 use http_body_util::BodyExt;
9 use sha2::Sha256;
10 use sqlx::PgPool;
11 use std::net::SocketAddr;
12 use tower::ServiceExt;
13 use uuid::Uuid;
14
15 use crate::harness::db::TestDb;
16
17 const TEST_SECRET: &str = "test-internal-secret-key-for-hmac";
18
19 /// Minimal harness for internal API tests — no CSRF/session, just the internal routes.
20 struct InternalTestHarness {
21 app: Router,
22 db: PgPool,
23 _test_db: TestDb,
24 }
25
26 impl InternalTestHarness {
27 async fn new() -> Self {
28 let test_db = TestDb::new().await;
29 let pool = test_db.pool.clone();
30
31 let config = multithreaded::config::Config {
32 mnw_base_url: "http://127.0.0.1:9999".into(),
33 oauth_client_id: "test-client-id".to_string(),
34 oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(),
35 platform_admin_id: None,
36 cookie_secure: false,
37 s3: None,
38 internal_shared_secret: Some(TEST_SECRET.to_string()),
39 };
40
41 let state = multithreaded::AppState {
42 db: pool.clone(),
43 config,
44 http: reqwest::Client::new(),
45 preview_http: multithreaded::link_preview::build_preview_client(),
46 s3: None,
47 };
48
49 let app = multithreaded::routes::internal::internal_routes(state);
50
51 InternalTestHarness {
52 app,
53 db: pool,
54 _test_db: test_db,
55 }
56 }
57
58 /// Send a signed POST request to the internal API.
59 async fn signed_post(&self, uri: &str, body: &str) -> (StatusCode, String) {
60 let timestamp = chrono::Utc::now().timestamp().to_string();
61 let message = format!("{}\n{}", timestamp, body);
62 let mut mac =
63 Hmac::<Sha256>::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key");
64 mac.update(message.as_bytes());
65 let signature = hex::encode(mac.finalize().into_bytes());
66
67 let mut request = Request::builder()
68 .method(Method::POST)
69 .uri(uri)
70 .header("Content-Type", "application/json")
71 .header("X-Internal-Timestamp", &timestamp)
72 .header("X-Internal-Signature", &signature)
73 .body(Body::from(body.to_string()))
74 .expect("build request");
75
76 request
77 .extensions_mut()
78 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
79
80 let response = self
81 .app
82 .clone()
83 .oneshot(request)
84 .await
85 .expect("send request");
86
87 let status = response.status();
88 let bytes = response
89 .into_body()
90 .collect()
91 .await
92 .expect("read body")
93 .to_bytes();
94 let text = String::from_utf8_lossy(&bytes).to_string();
95
96 (status, text)
97 }
98
99 /// Send an unsigned GET request to the internal API.
100 async fn get(&self, uri: &str) -> (StatusCode, String) {
101 let mut request = Request::builder()
102 .method(Method::GET)
103 .uri(uri)
104 .body(Body::empty())
105 .expect("build request");
106
107 request
108 .extensions_mut()
109 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
110
111 let response = self
112 .app
113 .clone()
114 .oneshot(request)
115 .await
116 .expect("send request");
117
118 let status = response.status();
119 let bytes = response
120 .into_body()
121 .collect()
122 .await
123 .expect("read body")
124 .to_bytes();
125 let text = String::from_utf8_lossy(&bytes).to_string();
126
127 (status, text)
128 }
129 }
130
131 // ============================================================================
132 // Community tests
133 // ============================================================================
134
135 #[tokio::test]
136 async fn create_community_happy_path() {
137 let h = InternalTestHarness::new().await;
138 let owner_id = Uuid::new_v4();
139
140 let body = serde_json::json!({
141 "name": "Test Project",
142 "slug": "test-project",
143 "description": "A test community",
144 "owner_mnw_id": owner_id,
145 "owner_username": "testcreator",
146 "owner_display_name": "Test Creator"
147 });
148
149 let (status, text) = h.signed_post("/internal/communities", &body.to_string()).await;
150 assert_eq!(status, StatusCode::OK, "body: {}", text);
151
152 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
153 assert!(resp["created"].as_bool().unwrap());
154 assert!(resp["community_id"].as_str().is_some());
155
156 // Verify 4 default categories were created
157 let community_id: Uuid = resp["community_id"].as_str().unwrap().parse().unwrap();
158 let categories: Vec<(String,)> = sqlx::query_as(
159 "SELECT slug FROM categories WHERE community_id = $1 ORDER BY sort_order",
160 )
161 .bind(community_id)
162 .fetch_all(&h.db)
163 .await
164 .unwrap();
165
166 assert_eq!(categories.len(), 4);
167 assert_eq!(categories[0].0, "items");
168 assert_eq!(categories[1].0, "blog");
169 assert_eq!(categories[2].0, "devlog");
170 assert_eq!(categories[3].0, "discussion");
171 }
172
173 #[tokio::test]
174 async fn create_community_idempotent() {
175 let h = InternalTestHarness::new().await;
176 let owner_id = Uuid::new_v4();
177
178 let body = serde_json::json!({
179 "name": "Idem Project",
180 "slug": "idem-project",
181 "owner_mnw_id": owner_id,
182 "owner_username": "idemcreator",
183 });
184
185 let (s1, t1) = h.signed_post("/internal/communities", &body.to_string()).await;
186 assert_eq!(s1, StatusCode::OK);
187 let r1: serde_json::Value = serde_json::from_str(&t1).unwrap();
188 assert!(r1["created"].as_bool().unwrap());
189
190 // Second call with same slug
191 let (s2, t2) = h.signed_post("/internal/communities", &body.to_string()).await;
192 assert_eq!(s2, StatusCode::OK);
193 let r2: serde_json::Value = serde_json::from_str(&t2).unwrap();
194 assert!(!r2["created"].as_bool().unwrap());
195 assert_eq!(r1["community_id"], r2["community_id"]);
196 }
197
198 #[tokio::test]
199 async fn create_community_rejects_bad_signature() {
200 let h = InternalTestHarness::new().await;
201 let body = r#"{"name":"Bad","slug":"bad","owner_mnw_id":"00000000-0000-0000-0000-000000000001","owner_username":"bad"}"#;
202
203 let timestamp = chrono::Utc::now().timestamp().to_string();
204
205 let mut request = Request::builder()
206 .method(Method::POST)
207 .uri("/internal/communities")
208 .header("Content-Type", "application/json")
209 .header("X-Internal-Timestamp", &timestamp)
210 .header("X-Internal-Signature", "deadbeef")
211 .body(Body::from(body))
212 .expect("build request");
213
214 request
215 .extensions_mut()
216 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
217
218 let response = h.app.clone().oneshot(request).await.expect("send request");
219 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
220 }
221
222 #[tokio::test]
223 async fn create_community_rejects_missing_headers() {
224 let h = InternalTestHarness::new().await;
225 let body = r#"{"name":"No Auth","slug":"noauth","owner_mnw_id":"00000000-0000-0000-0000-000000000001","owner_username":"noauth"}"#;
226
227 let mut request = Request::builder()
228 .method(Method::POST)
229 .uri("/internal/communities")
230 .header("Content-Type", "application/json")
231 .body(Body::from(body))
232 .expect("build request");
233
234 request
235 .extensions_mut()
236 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
237
238 let response = h.app.clone().oneshot(request).await.expect("send request");
239 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
240 }
241
242 // ============================================================================
243 // Thread tests
244 // ============================================================================
245
246 #[tokio::test]
247 async fn create_thread_happy_path() {
248 let h = InternalTestHarness::new().await;
249 let owner_id = Uuid::new_v4();
250
251 // First create a community
252 let comm_body = serde_json::json!({
253 "name": "Thread Project",
254 "slug": "thread-project",
255 "owner_mnw_id": owner_id,
256 "owner_username": "threadcreator",
257 });
258 let (status, _) = h.signed_post("/internal/communities", &comm_body.to_string()).await;
259 assert_eq!(status, StatusCode::OK);
260
261 // Create a thread
262 let thread_body = serde_json::json!({
263 "community_slug": "thread-project",
264 "category_slug": "items",
265 "title": "New Item Discussion",
266 "body_markdown": "Discussion for [New Item](https://example.com/i/123)",
267 "author_mnw_id": owner_id,
268 "author_username": "threadcreator",
269 "external_ref": "mnw:item:00000000-0000-0000-0000-000000000123"
270 });
271 let (status, text) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
272 assert_eq!(status, StatusCode::OK, "body: {}", text);
273
274 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
275 assert!(resp["created"].as_bool().unwrap());
276 assert!(resp["thread_id"].as_str().is_some());
277 assert!(resp["post_id"].as_str().is_some());
278
279 // Verify thread has external_ref in DB
280 let thread_id: Uuid = resp["thread_id"].as_str().unwrap().parse().unwrap();
281 let ext_ref: Option<String> = sqlx::query_scalar(
282 "SELECT external_ref FROM threads WHERE id = $1",
283 )
284 .bind(thread_id)
285 .fetch_one(&h.db)
286 .await
287 .unwrap();
288 assert_eq!(ext_ref.as_deref(), Some("mnw:item:00000000-0000-0000-0000-000000000123"));
289 }
290
291 #[tokio::test]
292 async fn create_thread_idempotent() {
293 let h = InternalTestHarness::new().await;
294 let owner_id = Uuid::new_v4();
295
296 // Create community
297 let comm_body = serde_json::json!({
298 "name": "Idem Thread Proj",
299 "slug": "idem-thread",
300 "owner_mnw_id": owner_id,
301 "owner_username": "idemthreaduser",
302 });
303 h.signed_post("/internal/communities", &comm_body.to_string()).await;
304
305 let thread_body = serde_json::json!({
306 "community_slug": "idem-thread",
307 "category_slug": "blog",
308 "title": "Blog Discussion",
309 "body_markdown": "Discussion body",
310 "author_mnw_id": owner_id,
311 "author_username": "idemthreaduser",
312 "external_ref": "mnw:blog:dedup-test"
313 });
314
315 let (s1, t1) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
316 assert_eq!(s1, StatusCode::OK);
317 let r1: serde_json::Value = serde_json::from_str(&t1).unwrap();
318 assert!(r1["created"].as_bool().unwrap());
319
320 // Second call with same external_ref
321 let (s2, t2) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
322 assert_eq!(s2, StatusCode::OK);
323 let r2: serde_json::Value = serde_json::from_str(&t2).unwrap();
324 assert!(!r2["created"].as_bool().unwrap());
325 assert_eq!(r1["thread_id"], r2["thread_id"]);
326 }
327
328 #[tokio::test]
329 async fn create_thread_missing_community() {
330 let h = InternalTestHarness::new().await;
331 let author_id = Uuid::new_v4();
332
333 let thread_body = serde_json::json!({
334 "community_slug": "nonexistent",
335 "category_slug": "items",
336 "title": "Orphan Thread",
337 "body_markdown": "Should fail",
338 "author_mnw_id": author_id,
339 "author_username": "orphan",
340 "external_ref": "mnw:item:orphan"
341 });
342 let (status, _) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
343 assert_eq!(status, StatusCode::NOT_FOUND);
344 }
345
346 // ============================================================================
347 // Thread stats tests
348 // ============================================================================
349
350 #[tokio::test]
351 async fn thread_stats_happy_path() {
352 let h = InternalTestHarness::new().await;
353 let owner_id = Uuid::new_v4();
354
355 // Create community + thread via internal API
356 let comm_body = serde_json::json!({
357 "name": "Stats Project",
358 "slug": "stats-project",
359 "owner_mnw_id": owner_id,
360 "owner_username": "statsuser",
361 });
362 h.signed_post("/internal/communities", &comm_body.to_string()).await;
363
364 let thread_body = serde_json::json!({
365 "community_slug": "stats-project",
366 "category_slug": "items",
367 "title": "Stats Thread",
368 "body_markdown": "Opening post",
369 "author_mnw_id": owner_id,
370 "author_username": "statsuser",
371 "external_ref": "mnw:item:stats-1"
372 });
373 let (_, text) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
374 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
375 let thread_id = resp["thread_id"].as_str().unwrap();
376
377 // Get stats
378 let (status, stats_text) = h.get(&format!("/internal/threads/{}/stats", thread_id)).await;
379 assert_eq!(status, StatusCode::OK, "body: {}", stats_text);
380
381 let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap();
382 assert_eq!(stats["post_count"].as_i64().unwrap(), 1); // opening post
383 assert!(stats["last_activity_at"].as_str().is_some());
384 }
385
386 #[tokio::test]
387 async fn thread_stats_nonexistent() {
388 let h = InternalTestHarness::new().await;
389 let fake_id = Uuid::new_v4();
390
391 let (status, text) = h.get(&format!("/internal/threads/{}/stats", fake_id)).await;
392 assert_eq!(status, StatusCode::OK, "body: {}", text);
393
394 let stats: serde_json::Value = serde_json::from_str(&text).unwrap();
395 assert_eq!(stats["post_count"].as_i64().unwrap(), 0);
396 }
397
398 #[tokio::test]
399 async fn thread_stats_invalid_uuid() {
400 let h = InternalTestHarness::new().await;
401
402 let (status, _) = h.get("/internal/threads/not-a-uuid/stats").await;
403 assert_eq!(status, StatusCode::NOT_FOUND);
404 }
405
406 // ============================================================================
407 // Create post tests
408 // ============================================================================
409
410 #[tokio::test]
411 async fn create_post_happy_path() {
412 let h = InternalTestHarness::new().await;
413 let owner_id = Uuid::new_v4();
414
415 // Create community + thread
416 let comm_body = serde_json::json!({
417 "name": "Post Project",
418 "slug": "post-project",
419 "owner_mnw_id": owner_id,
420 "owner_username": "postuser",
421 });
422 h.signed_post("/internal/communities", &comm_body.to_string()).await;
423
424 let thread_body = serde_json::json!({
425 "community_slug": "post-project",
426 "category_slug": "items",
427 "title": "Thread for Reply",
428 "body_markdown": "Opening post",
429 "author_mnw_id": owner_id,
430 "author_username": "postuser",
431 "external_ref": "mnw:item:post-test-1"
432 });
433 let (_, text) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
434 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
435 let thread_id = resp["thread_id"].as_str().unwrap();
436
437 // Create a reply
438 let reply_body = serde_json::json!({
439 "body_markdown": "This is a reply via internal API",
440 "author_mnw_id": owner_id,
441 "author_username": "postuser",
442 "author_display_name": "Post User"
443 });
444 let (status, text) = h
445 .signed_post(
446 &format!("/internal/threads/{}/posts", thread_id),
447 &reply_body.to_string(),
448 )
449 .await;
450 assert_eq!(status, StatusCode::OK, "body: {}", text);
451
452 let post_resp: serde_json::Value = serde_json::from_str(&text).unwrap();
453 assert!(post_resp["post_id"].as_str().is_some());
454
455 // Verify thread now has 2 posts
456 let (_, stats_text) = h.get(&format!("/internal/threads/{}/stats", thread_id)).await;
457 let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap();
458 assert_eq!(stats["post_count"].as_i64().unwrap(), 2);
459 }
460
461 #[tokio::test]
462 async fn create_post_nonexistent_thread() {
463 let h = InternalTestHarness::new().await;
464 let fake_id = Uuid::new_v4();
465
466 let body = serde_json::json!({
467 "body_markdown": "Reply to nothing",
468 "author_mnw_id": Uuid::new_v4(),
469 "author_username": "nobody",
470 });
471 let (status, _) = h
472 .signed_post(
473 &format!("/internal/threads/{}/posts", fake_id),
474 &body.to_string(),
475 )
476 .await;
477 assert_eq!(status, StatusCode::NOT_FOUND);
478 }
479
480 // ============================================================================
481 // Auto-create category tests
482 // ============================================================================
483
484 #[tokio::test]
485 async fn create_thread_auto_creates_category() {
486 let h = InternalTestHarness::new().await;
487 let owner_id = Uuid::new_v4();
488
489 // Create community (has 4 default categories)
490 let comm_body = serde_json::json!({
491 "name": "Autocat Project",
492 "slug": "autocat-project",
493 "owner_mnw_id": owner_id,
494 "owner_username": "autocatuser",
495 });
496 let (status, _) = h.signed_post("/internal/communities", &comm_body.to_string()).await;
497 assert_eq!(status, StatusCode::OK);
498
499 // Create thread with a non-existent category slug — should auto-create it
500 let thread_body = serde_json::json!({
501 "community_slug": "autocat-project",
502 "category_slug": "patches",
503 "title": "[PATCH 1/1] Fix typo",
504 "body_markdown": "diff --git a/...",
505 "author_mnw_id": owner_id,
506 "author_username": "autocatuser",
507 "external_ref": "mnw:patch:autocat-test"
508 });
509 let (status, text) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
510 assert_eq!(status, StatusCode::OK, "body: {}", text);
511
512 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
513 assert!(resp["created"].as_bool().unwrap());
514
515 // Verify "patches" category was auto-created
516 let comm_resp: serde_json::Value = serde_json::from_str(
517 &h.signed_post("/internal/communities", &comm_body.to_string()).await.1,
518 )
519 .unwrap();
520 let community_id: Uuid = comm_resp["community_id"].as_str().unwrap().parse().unwrap();
521
522 let categories: Vec<(String,)> = sqlx::query_as(
523 "SELECT slug FROM categories WHERE community_id = $1 ORDER BY sort_order",
524 )
525 .bind(community_id)
526 .fetch_all(&h.db)
527 .await
528 .unwrap();
529
530 let slugs: Vec<&str> = categories.iter().map(|c| c.0.as_str()).collect();
531 assert!(slugs.contains(&"patches"), "Expected 'patches' category, got: {:?}", slugs);
532 assert_eq!(categories.len(), 5); // 4 default + 1 auto-created
533 }
534