Skip to main content

max / makenotwork

18.8 KB · 543 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 link_preview: multithreaded::link_preview::LinkPreviewFetcher::Noop,
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 a signed GET request to the internal API.
100 async fn get(&self, uri: &str) -> (StatusCode, String) {
101 let timestamp = chrono::Utc::now().timestamp().to_string();
102 let message = format!("{}\n", timestamp);
103 let mut mac =
104 Hmac::<Sha256>::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key");
105 mac.update(message.as_bytes());
106 let signature = hex::encode(mac.finalize().into_bytes());
107
108 let mut request = Request::builder()
109 .method(Method::GET)
110 .uri(uri)
111 .header("X-Internal-Timestamp", &timestamp)
112 .header("X-Internal-Signature", &signature)
113 .body(Body::empty())
114 .expect("build request");
115
116 request
117 .extensions_mut()
118 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
119
120 let response = self
121 .app
122 .clone()
123 .oneshot(request)
124 .await
125 .expect("send request");
126
127 let status = response.status();
128 let bytes = response
129 .into_body()
130 .collect()
131 .await
132 .expect("read body")
133 .to_bytes();
134 let text = String::from_utf8_lossy(&bytes).to_string();
135
136 (status, text)
137 }
138 }
139
140 // ============================================================================
141 // Community tests
142 // ============================================================================
143
144 #[tokio::test]
145 async fn create_community_happy_path() {
146 let h = InternalTestHarness::new().await;
147 let owner_id = Uuid::new_v4();
148
149 let body = serde_json::json!({
150 "name": "Test Project",
151 "slug": "test-project",
152 "description": "A test community",
153 "owner_mnw_id": owner_id,
154 "owner_username": "testcreator",
155 "owner_display_name": "Test Creator"
156 });
157
158 let (status, text) = h.signed_post("/internal/communities", &body.to_string()).await;
159 assert_eq!(status, StatusCode::OK, "body: {}", text);
160
161 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
162 assert!(resp["created"].as_bool().unwrap());
163 assert!(resp["community_id"].as_str().is_some());
164
165 // Verify default categories were created. Order is fixed in
166 // `routes/internal.rs`; Issues + Patches were added in step 6 to surface
167 // the email-driven workflows in fresh communities.
168 let community_id: Uuid = resp["community_id"].as_str().unwrap().parse().unwrap();
169 let categories: Vec<(String,)> = sqlx::query_as(
170 "SELECT slug FROM categories WHERE community_id = $1 ORDER BY sort_order",
171 )
172 .bind(community_id)
173 .fetch_all(&h.db)
174 .await
175 .unwrap();
176
177 let slugs: Vec<&str> = categories.iter().map(|(s,)| s.as_str()).collect();
178 assert_eq!(slugs, vec!["items", "blog", "devlog", "discussion", "issues", "patches"]);
179 }
180
181 #[tokio::test]
182 async fn create_community_idempotent() {
183 let h = InternalTestHarness::new().await;
184 let owner_id = Uuid::new_v4();
185
186 let body = serde_json::json!({
187 "name": "Idem Project",
188 "slug": "idem-project",
189 "owner_mnw_id": owner_id,
190 "owner_username": "idemcreator",
191 });
192
193 let (s1, t1) = h.signed_post("/internal/communities", &body.to_string()).await;
194 assert_eq!(s1, StatusCode::OK);
195 let r1: serde_json::Value = serde_json::from_str(&t1).unwrap();
196 assert!(r1["created"].as_bool().unwrap());
197
198 // Second call with same slug
199 let (s2, t2) = h.signed_post("/internal/communities", &body.to_string()).await;
200 assert_eq!(s2, StatusCode::OK);
201 let r2: serde_json::Value = serde_json::from_str(&t2).unwrap();
202 assert!(!r2["created"].as_bool().unwrap());
203 assert_eq!(r1["community_id"], r2["community_id"]);
204 }
205
206 #[tokio::test]
207 async fn create_community_rejects_bad_signature() {
208 let h = InternalTestHarness::new().await;
209 let body = r#"{"name":"Bad","slug":"bad","owner_mnw_id":"00000000-0000-0000-0000-000000000001","owner_username":"bad"}"#;
210
211 let timestamp = chrono::Utc::now().timestamp().to_string();
212
213 let mut request = Request::builder()
214 .method(Method::POST)
215 .uri("/internal/communities")
216 .header("Content-Type", "application/json")
217 .header("X-Internal-Timestamp", &timestamp)
218 .header("X-Internal-Signature", "deadbeef")
219 .body(Body::from(body))
220 .expect("build request");
221
222 request
223 .extensions_mut()
224 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
225
226 let response = h.app.clone().oneshot(request).await.expect("send request");
227 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
228 }
229
230 #[tokio::test]
231 async fn create_community_rejects_missing_headers() {
232 let h = InternalTestHarness::new().await;
233 let body = r#"{"name":"No Auth","slug":"noauth","owner_mnw_id":"00000000-0000-0000-0000-000000000001","owner_username":"noauth"}"#;
234
235 let mut request = Request::builder()
236 .method(Method::POST)
237 .uri("/internal/communities")
238 .header("Content-Type", "application/json")
239 .body(Body::from(body))
240 .expect("build request");
241
242 request
243 .extensions_mut()
244 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
245
246 let response = h.app.clone().oneshot(request).await.expect("send request");
247 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
248 }
249
250 // ============================================================================
251 // Thread tests
252 // ============================================================================
253
254 #[tokio::test]
255 async fn create_thread_happy_path() {
256 let h = InternalTestHarness::new().await;
257 let owner_id = Uuid::new_v4();
258
259 // First create a community
260 let comm_body = serde_json::json!({
261 "name": "Thread Project",
262 "slug": "thread-project",
263 "owner_mnw_id": owner_id,
264 "owner_username": "threadcreator",
265 });
266 let (status, _) = h.signed_post("/internal/communities", &comm_body.to_string()).await;
267 assert_eq!(status, StatusCode::OK);
268
269 // Create a thread
270 let thread_body = serde_json::json!({
271 "community_slug": "thread-project",
272 "category_slug": "items",
273 "title": "New Item Discussion",
274 "body_markdown": "Discussion for [New Item](https://example.com/i/123)",
275 "author_mnw_id": owner_id,
276 "author_username": "threadcreator",
277 "external_ref": "mnw:item:00000000-0000-0000-0000-000000000123"
278 });
279 let (status, text) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
280 assert_eq!(status, StatusCode::OK, "body: {}", text);
281
282 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
283 assert!(resp["created"].as_bool().unwrap());
284 assert!(resp["thread_id"].as_str().is_some());
285 assert!(resp["post_id"].as_str().is_some());
286
287 // Verify thread has external_ref in DB
288 let thread_id: Uuid = resp["thread_id"].as_str().unwrap().parse().unwrap();
289 let ext_ref: Option<String> = sqlx::query_scalar(
290 "SELECT external_ref FROM threads WHERE id = $1",
291 )
292 .bind(thread_id)
293 .fetch_one(&h.db)
294 .await
295 .unwrap();
296 assert_eq!(ext_ref.as_deref(), Some("mnw:item:00000000-0000-0000-0000-000000000123"));
297 }
298
299 #[tokio::test]
300 async fn create_thread_idempotent() {
301 let h = InternalTestHarness::new().await;
302 let owner_id = Uuid::new_v4();
303
304 // Create community
305 let comm_body = serde_json::json!({
306 "name": "Idem Thread Proj",
307 "slug": "idem-thread",
308 "owner_mnw_id": owner_id,
309 "owner_username": "idemthreaduser",
310 });
311 h.signed_post("/internal/communities", &comm_body.to_string()).await;
312
313 let thread_body = serde_json::json!({
314 "community_slug": "idem-thread",
315 "category_slug": "blog",
316 "title": "Blog Discussion",
317 "body_markdown": "Discussion body",
318 "author_mnw_id": owner_id,
319 "author_username": "idemthreaduser",
320 "external_ref": "mnw:blog:dedup-test"
321 });
322
323 let (s1, t1) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
324 assert_eq!(s1, StatusCode::OK);
325 let r1: serde_json::Value = serde_json::from_str(&t1).unwrap();
326 assert!(r1["created"].as_bool().unwrap());
327
328 // Second call with same external_ref
329 let (s2, t2) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
330 assert_eq!(s2, StatusCode::OK);
331 let r2: serde_json::Value = serde_json::from_str(&t2).unwrap();
332 assert!(!r2["created"].as_bool().unwrap());
333 assert_eq!(r1["thread_id"], r2["thread_id"]);
334 }
335
336 #[tokio::test]
337 async fn create_thread_missing_community() {
338 let h = InternalTestHarness::new().await;
339 let author_id = Uuid::new_v4();
340
341 let thread_body = serde_json::json!({
342 "community_slug": "nonexistent",
343 "category_slug": "items",
344 "title": "Orphan Thread",
345 "body_markdown": "Should fail",
346 "author_mnw_id": author_id,
347 "author_username": "orphan",
348 "external_ref": "mnw:item:orphan"
349 });
350 let (status, _) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
351 assert_eq!(status, StatusCode::NOT_FOUND);
352 }
353
354 // ============================================================================
355 // Thread stats tests
356 // ============================================================================
357
358 #[tokio::test]
359 async fn thread_stats_happy_path() {
360 let h = InternalTestHarness::new().await;
361 let owner_id = Uuid::new_v4();
362
363 // Create community + thread via internal API
364 let comm_body = serde_json::json!({
365 "name": "Stats Project",
366 "slug": "stats-project",
367 "owner_mnw_id": owner_id,
368 "owner_username": "statsuser",
369 });
370 h.signed_post("/internal/communities", &comm_body.to_string()).await;
371
372 let thread_body = serde_json::json!({
373 "community_slug": "stats-project",
374 "category_slug": "items",
375 "title": "Stats Thread",
376 "body_markdown": "Opening post",
377 "author_mnw_id": owner_id,
378 "author_username": "statsuser",
379 "external_ref": "mnw:item:stats-1"
380 });
381 let (_, text) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
382 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
383 let thread_id = resp["thread_id"].as_str().unwrap();
384
385 // Get stats
386 let (status, stats_text) = h.get(&format!("/internal/threads/{}/stats", thread_id)).await;
387 assert_eq!(status, StatusCode::OK, "body: {}", stats_text);
388
389 let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap();
390 assert_eq!(stats["post_count"].as_i64().unwrap(), 1); // opening post
391 assert!(stats["last_activity_at"].as_str().is_some());
392 }
393
394 #[tokio::test]
395 async fn thread_stats_nonexistent() {
396 let h = InternalTestHarness::new().await;
397 let fake_id = Uuid::new_v4();
398
399 let (status, text) = h.get(&format!("/internal/threads/{}/stats", fake_id)).await;
400 assert_eq!(status, StatusCode::OK, "body: {}", text);
401
402 let stats: serde_json::Value = serde_json::from_str(&text).unwrap();
403 assert_eq!(stats["post_count"].as_i64().unwrap(), 0);
404 }
405
406 #[tokio::test]
407 async fn thread_stats_invalid_uuid() {
408 let h = InternalTestHarness::new().await;
409
410 let (status, _) = h.get("/internal/threads/not-a-uuid/stats").await;
411 assert_eq!(status, StatusCode::NOT_FOUND);
412 }
413
414 // ============================================================================
415 // Create post tests
416 // ============================================================================
417
418 #[tokio::test]
419 async fn create_post_happy_path() {
420 let h = InternalTestHarness::new().await;
421 let owner_id = Uuid::new_v4();
422
423 // Create community + thread
424 let comm_body = serde_json::json!({
425 "name": "Post Project",
426 "slug": "post-project",
427 "owner_mnw_id": owner_id,
428 "owner_username": "postuser",
429 });
430 h.signed_post("/internal/communities", &comm_body.to_string()).await;
431
432 let thread_body = serde_json::json!({
433 "community_slug": "post-project",
434 "category_slug": "items",
435 "title": "Thread for Reply",
436 "body_markdown": "Opening post",
437 "author_mnw_id": owner_id,
438 "author_username": "postuser",
439 "external_ref": "mnw:item:post-test-1"
440 });
441 let (_, text) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
442 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
443 let thread_id = resp["thread_id"].as_str().unwrap();
444
445 // Create a reply
446 let reply_body = serde_json::json!({
447 "body_markdown": "This is a reply via internal API",
448 "author_mnw_id": owner_id,
449 "author_username": "postuser",
450 "author_display_name": "Post User"
451 });
452 let (status, text) = h
453 .signed_post(
454 &format!("/internal/threads/{}/posts", thread_id),
455 &reply_body.to_string(),
456 )
457 .await;
458 assert_eq!(status, StatusCode::OK, "body: {}", text);
459
460 let post_resp: serde_json::Value = serde_json::from_str(&text).unwrap();
461 assert!(post_resp["post_id"].as_str().is_some());
462
463 // Verify thread now has 2 posts
464 let (_, stats_text) = h.get(&format!("/internal/threads/{}/stats", thread_id)).await;
465 let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap();
466 assert_eq!(stats["post_count"].as_i64().unwrap(), 2);
467 }
468
469 #[tokio::test]
470 async fn create_post_nonexistent_thread() {
471 let h = InternalTestHarness::new().await;
472 let fake_id = Uuid::new_v4();
473
474 let body = serde_json::json!({
475 "body_markdown": "Reply to nothing",
476 "author_mnw_id": Uuid::new_v4(),
477 "author_username": "nobody",
478 });
479 let (status, _) = h
480 .signed_post(
481 &format!("/internal/threads/{}/posts", fake_id),
482 &body.to_string(),
483 )
484 .await;
485 assert_eq!(status, StatusCode::NOT_FOUND);
486 }
487
488 // ============================================================================
489 // Auto-create category tests
490 // ============================================================================
491
492 #[tokio::test]
493 async fn create_thread_auto_creates_category() {
494 let h = InternalTestHarness::new().await;
495 let owner_id = Uuid::new_v4();
496
497 // Create community (has 4 default categories)
498 let comm_body = serde_json::json!({
499 "name": "Autocat Project",
500 "slug": "autocat-project",
501 "owner_mnw_id": owner_id,
502 "owner_username": "autocatuser",
503 });
504 let (status, _) = h.signed_post("/internal/communities", &comm_body.to_string()).await;
505 assert_eq!(status, StatusCode::OK);
506
507 // Create thread with a non-existent category slug — should auto-create it.
508 // Use "releases" (not a default) since "issues"/"patches" are now seeded.
509 let thread_body = serde_json::json!({
510 "community_slug": "autocat-project",
511 "category_slug": "releases",
512 "title": "v1.0 release notes",
513 "body_markdown": "First release",
514 "author_mnw_id": owner_id,
515 "author_username": "autocatuser",
516 "external_ref": "mnw:release:autocat-test"
517 });
518 let (status, text) = h.signed_post("/internal/threads", &thread_body.to_string()).await;
519 assert_eq!(status, StatusCode::OK, "body: {}", text);
520
521 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
522 assert!(resp["created"].as_bool().unwrap());
523
524 // Verify "releases" category was auto-created
525 let comm_resp: serde_json::Value = serde_json::from_str(
526 &h.signed_post("/internal/communities", &comm_body.to_string()).await.1,
527 )
528 .unwrap();
529 let community_id: Uuid = comm_resp["community_id"].as_str().unwrap().parse().unwrap();
530
531 let categories: Vec<(String,)> = sqlx::query_as(
532 "SELECT slug FROM categories WHERE community_id = $1 ORDER BY sort_order",
533 )
534 .bind(community_id)
535 .fetch_all(&h.db)
536 .await
537 .unwrap();
538
539 let slugs: Vec<&str> = categories.iter().map(|c| c.0.as_str()).collect();
540 assert!(slugs.contains(&"releases"), "Expected 'releases' category, got: {:?}", slugs);
541 assert_eq!(categories.len(), 7); // 6 default + 1 auto-created
542 }
543