Skip to main content

max / makenotwork

22.6 KB · 698 lines History Blame Raw
1 //! Integration tests for the internal API (HMAC-signed requests from MNW).
2
3 use axum::Router;
4 use axum::body::Body;
5 use axum::extract::ConnectInfo;
6 use axum::http::{Method, Request, StatusCode};
7 use hmac::{Hmac, KeyInit, 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 trusted_proxies: std::sync::Arc::from([std::net::IpAddr::from([127, 0, 0, 1])]),
40 };
41
42 let state = multithreaded::AppState {
43 db: pool.clone(),
44 config,
45 http: reqwest::Client::new(),
46 link_preview: multithreaded::link_preview::LinkPreviewFetcher::Noop,
47 s3: None,
48 };
49
50 let app = multithreaded::routes::internal::internal_routes(state);
51
52 InternalTestHarness {
53 app,
54 db: pool,
55 _test_db: test_db,
56 }
57 }
58
59 /// Send a v2-signed request, binding method + `sign_path` + nonce. The
60 /// request is sent to `uri`; `sign_path` is what the signature covers, pass
61 /// the same value normally, or a different one to test path-binding.
62 async fn send_signed(
63 &self,
64 method: Method,
65 uri: &str,
66 sign_path: &str,
67 nonce: &str,
68 body: &str,
69 ) -> (StatusCode, String) {
70 let timestamp = chrono::Utc::now().timestamp().to_string();
71 let mut mac = Hmac::<Sha256>::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key");
72 for field in [timestamp.as_str(), method.as_str(), sign_path, nonce] {
73 mac.update(field.as_bytes());
74 mac.update(b"\n");
75 }
76 mac.update(body.as_bytes());
77 let signature = hex::encode(mac.finalize().into_bytes());
78
79 let mut builder = Request::builder()
80 .method(method)
81 .uri(uri)
82 .header("X-Internal-Timestamp", &timestamp)
83 .header("X-Internal-Signature", &signature)
84 .header("X-Internal-Nonce", nonce);
85 if !body.is_empty() {
86 builder = builder.header("Content-Type", "application/json");
87 }
88 let mut request = builder
89 .body(Body::from(body.to_string()))
90 .expect("build request");
91
92 request
93 .extensions_mut()
94 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
95
96 let response = self
97 .app
98 .clone()
99 .oneshot(request)
100 .await
101 .expect("send request");
102 let status = response.status();
103 let bytes = response
104 .into_body()
105 .collect()
106 .await
107 .expect("read body")
108 .to_bytes();
109 (status, String::from_utf8_lossy(&bytes).to_string())
110 }
111
112 /// Send a v2-signed POST with a fresh nonce.
113 async fn signed_post(&self, uri: &str, body: &str) -> (StatusCode, String) {
114 let nonce = Uuid::new_v4().simple().to_string();
115 self.send_signed(Method::POST, uri, uri, &nonce, body).await
116 }
117
118 /// Send a v2-signed GET with a fresh nonce.
119 async fn get(&self, uri: &str) -> (StatusCode, String) {
120 let nonce = Uuid::new_v4().simple().to_string();
121 self.send_signed(Method::GET, uri, uri, &nonce, "").await
122 }
123 }
124
125 // Community tests
126
127 #[tokio::test]
128 async fn create_community_happy_path() {
129 let h = InternalTestHarness::new().await;
130 let owner_id = Uuid::new_v4();
131
132 let body = serde_json::json!({
133 "name": "Test Project",
134 "slug": "test-project",
135 "description": "A test community",
136 "owner_mnw_id": owner_id,
137 "owner_username": "testcreator",
138 "owner_display_name": "Test Creator"
139 });
140
141 let (status, text) = h
142 .signed_post("/internal/communities", &body.to_string())
143 .await;
144 assert_eq!(status, StatusCode::OK, "body: {text}");
145
146 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
147 assert!(resp["created"].as_bool().unwrap());
148 assert!(resp["community_id"].as_str().is_some());
149
150 // Verify default categories were created. Order is fixed in
151 // `routes/internal.rs`; Issues + Patches were added in step 6 to surface
152 // the email-driven workflows in fresh communities.
153 let community_id: Uuid = resp["community_id"].as_str().unwrap().parse().unwrap();
154 let categories: Vec<(String,)> =
155 sqlx::query_as("SELECT slug FROM categories WHERE community_id = $1 ORDER BY sort_order")
156 .bind(community_id)
157 .fetch_all(&h.db)
158 .await
159 .unwrap();
160
161 let slugs: Vec<&str> = categories.iter().map(|(s,)| s.as_str()).collect();
162 assert_eq!(
163 slugs,
164 vec!["items", "blog", "devlog", "discussion", "issues", "patches"]
165 );
166 }
167
168 #[tokio::test]
169 async fn create_community_idempotent() {
170 let h = InternalTestHarness::new().await;
171 let owner_id = Uuid::new_v4();
172
173 let body = serde_json::json!({
174 "name": "Idem Project",
175 "slug": "idem-project",
176 "owner_mnw_id": owner_id,
177 "owner_username": "idemcreator",
178 });
179
180 let (s1, t1) = h
181 .signed_post("/internal/communities", &body.to_string())
182 .await;
183 assert_eq!(s1, StatusCode::OK);
184 let r1: serde_json::Value = serde_json::from_str(&t1).unwrap();
185 assert!(r1["created"].as_bool().unwrap());
186
187 // Second call with same slug
188 let (s2, t2) = h
189 .signed_post("/internal/communities", &body.to_string())
190 .await;
191 assert_eq!(s2, StatusCode::OK);
192 let r2: serde_json::Value = serde_json::from_str(&t2).unwrap();
193 assert!(!r2["created"].as_bool().unwrap());
194 assert_eq!(r1["community_id"], r2["community_id"]);
195 }
196
197 #[tokio::test]
198 async fn create_community_rejects_bad_signature() {
199 let h = InternalTestHarness::new().await;
200 let body = r#"{"name":"Bad","slug":"bad","owner_mnw_id":"00000000-0000-0000-0000-000000000001","owner_username":"bad"}"#;
201
202 let timestamp = chrono::Utc::now().timestamp().to_string();
203
204 let mut request = Request::builder()
205 .method(Method::POST)
206 .uri("/internal/communities")
207 .header("Content-Type", "application/json")
208 .header("X-Internal-Timestamp", &timestamp)
209 .header("X-Internal-Signature", "deadbeef")
210 .body(Body::from(body))
211 .expect("build request");
212
213 request
214 .extensions_mut()
215 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
216
217 let response = h.app.clone().oneshot(request).await.expect("send request");
218 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
219 }
220
221 #[tokio::test]
222 async fn create_community_rejects_missing_headers() {
223 let h = InternalTestHarness::new().await;
224 let body = r#"{"name":"No Auth","slug":"noauth","owner_mnw_id":"00000000-0000-0000-0000-000000000001","owner_username":"noauth"}"#;
225
226 let mut request = Request::builder()
227 .method(Method::POST)
228 .uri("/internal/communities")
229 .header("Content-Type", "application/json")
230 .body(Body::from(body))
231 .expect("build request");
232
233 request
234 .extensions_mut()
235 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
236
237 let response = h.app.clone().oneshot(request).await.expect("send request");
238 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
239 }
240
241 // Thread tests
242
243 #[tokio::test]
244 async fn create_thread_happy_path() {
245 let h = InternalTestHarness::new().await;
246 let owner_id = Uuid::new_v4();
247
248 // First create a community
249 let comm_body = serde_json::json!({
250 "name": "Thread Project",
251 "slug": "thread-project",
252 "owner_mnw_id": owner_id,
253 "owner_username": "threadcreator",
254 });
255 let (status, _) = h
256 .signed_post("/internal/communities", &comm_body.to_string())
257 .await;
258 assert_eq!(status, StatusCode::OK);
259
260 // Create a thread
261 let thread_body = serde_json::json!({
262 "community_slug": "thread-project",
263 "category_slug": "items",
264 "title": "New Item Discussion",
265 "body_markdown": "Discussion for [New Item](https://example.com/i/123)",
266 "author_mnw_id": owner_id,
267 "author_username": "threadcreator",
268 "external_ref": "mnw:item:00000000-0000-0000-0000-000000000123"
269 });
270 let (status, text) = h
271 .signed_post("/internal/threads", &thread_body.to_string())
272 .await;
273 assert_eq!(status, StatusCode::OK, "body: {text}");
274
275 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
276 assert!(resp["created"].as_bool().unwrap());
277 assert!(resp["thread_id"].as_str().is_some());
278 assert!(resp["post_id"].as_str().is_some());
279
280 // Verify thread has external_ref in DB
281 let thread_id: Uuid = resp["thread_id"].as_str().unwrap().parse().unwrap();
282 let ext_ref: Option<String> =
283 sqlx::query_scalar("SELECT external_ref FROM threads WHERE id = $1")
284 .bind(thread_id)
285 .fetch_one(&h.db)
286 .await
287 .unwrap();
288 assert_eq!(
289 ext_ref.as_deref(),
290 Some("mnw:item:00000000-0000-0000-0000-000000000123")
291 );
292 }
293
294 #[tokio::test]
295 async fn create_thread_idempotent() {
296 let h = InternalTestHarness::new().await;
297 let owner_id = Uuid::new_v4();
298
299 // Create community
300 let comm_body = serde_json::json!({
301 "name": "Idem Thread Proj",
302 "slug": "idem-thread",
303 "owner_mnw_id": owner_id,
304 "owner_username": "idemthreaduser",
305 });
306 h.signed_post("/internal/communities", &comm_body.to_string())
307 .await;
308
309 let thread_body = serde_json::json!({
310 "community_slug": "idem-thread",
311 "category_slug": "blog",
312 "title": "Blog Discussion",
313 "body_markdown": "Discussion body",
314 "author_mnw_id": owner_id,
315 "author_username": "idemthreaduser",
316 "external_ref": "mnw:blog:dedup-test"
317 });
318
319 let (s1, t1) = h
320 .signed_post("/internal/threads", &thread_body.to_string())
321 .await;
322 assert_eq!(s1, StatusCode::OK);
323 let r1: serde_json::Value = serde_json::from_str(&t1).unwrap();
324 assert!(r1["created"].as_bool().unwrap());
325
326 // Second call with same external_ref
327 let (s2, t2) = h
328 .signed_post("/internal/threads", &thread_body.to_string())
329 .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
351 .signed_post("/internal/threads", &thread_body.to_string())
352 .await;
353 assert_eq!(status, StatusCode::NOT_FOUND);
354 }
355
356 // Thread stats tests
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())
371 .await;
372
373 let thread_body = serde_json::json!({
374 "community_slug": "stats-project",
375 "category_slug": "items",
376 "title": "Stats Thread",
377 "body_markdown": "Opening post",
378 "author_mnw_id": owner_id,
379 "author_username": "statsuser",
380 "external_ref": "mnw:item:stats-1"
381 });
382 let (_, text) = h
383 .signed_post("/internal/threads", &thread_body.to_string())
384 .await;
385 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
386 let thread_id = resp["thread_id"].as_str().unwrap();
387
388 let (status, stats_text) = h.get(&format!("/internal/threads/{thread_id}/stats")).await;
389 assert_eq!(status, StatusCode::OK, "body: {stats_text}");
390
391 let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap();
392 assert_eq!(stats["post_count"].as_i64().unwrap(), 1); // opening post
393 assert!(stats["last_activity_at"].as_str().is_some());
394 }
395
396 #[tokio::test]
397 async fn thread_stats_nonexistent() {
398 let h = InternalTestHarness::new().await;
399 let fake_id = Uuid::new_v4();
400
401 let (status, text) = h.get(&format!("/internal/threads/{fake_id}/stats")).await;
402 assert_eq!(status, StatusCode::OK, "body: {text}");
403
404 let stats: serde_json::Value = serde_json::from_str(&text).unwrap();
405 assert_eq!(stats["post_count"].as_i64().unwrap(), 0);
406 }
407
408 #[tokio::test]
409 async fn thread_stats_invalid_uuid() {
410 let h = InternalTestHarness::new().await;
411
412 let (status, _) = h.get("/internal/threads/not-a-uuid/stats").await;
413 assert_eq!(status, StatusCode::NOT_FOUND);
414 }
415
416 // Create post tests
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())
431 .await;
432
433 let thread_body = serde_json::json!({
434 "community_slug": "post-project",
435 "category_slug": "items",
436 "title": "Thread for Reply",
437 "body_markdown": "Opening post",
438 "author_mnw_id": owner_id,
439 "author_username": "postuser",
440 "external_ref": "mnw:item:post-test-1"
441 });
442 let (_, text) = h
443 .signed_post("/internal/threads", &thread_body.to_string())
444 .await;
445 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
446 let thread_id = resp["thread_id"].as_str().unwrap();
447
448 // Create a reply
449 let reply_body = serde_json::json!({
450 "body_markdown": "This is a reply via internal API",
451 "author_mnw_id": owner_id,
452 "author_username": "postuser",
453 "author_display_name": "Post User",
454 "external_ref": "mnw:post:reply-1"
455 });
456 let (status, text) = h
457 .signed_post(
458 &format!("/internal/threads/{thread_id}/posts"),
459 &reply_body.to_string(),
460 )
461 .await;
462 assert_eq!(status, StatusCode::OK, "body: {text}");
463
464 let post_resp: serde_json::Value = serde_json::from_str(&text).unwrap();
465 let first_post_id = post_resp["post_id"].as_str().unwrap().to_string();
466 assert!(
467 post_resp["created"].as_bool().unwrap(),
468 "first call creates"
469 );
470
471 // Verify thread now has 2 posts
472 let (_, stats_text) = h.get(&format!("/internal/threads/{thread_id}/stats")).await;
473 let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap();
474 assert_eq!(stats["post_count"].as_i64().unwrap(), 2);
475
476 // Replay the same external_ref (a retried/replayed call): same post id,
477 // created=false, and the post count does not grow (audit E1).
478 let (status, text2) = h
479 .signed_post(
480 &format!("/internal/threads/{thread_id}/posts"),
481 &reply_body.to_string(),
482 )
483 .await;
484 assert_eq!(status, StatusCode::OK, "body: {text2}");
485 let replay: serde_json::Value = serde_json::from_str(&text2).unwrap();
486 assert_eq!(
487 replay["post_id"].as_str().unwrap(),
488 first_post_id,
489 "replay returns original"
490 );
491 assert!(
492 !replay["created"].as_bool().unwrap(),
493 "replay does not create"
494 );
495 let (_, stats_text2) = h.get(&format!("/internal/threads/{thread_id}/stats")).await;
496 let stats2: serde_json::Value = serde_json::from_str(&stats_text2).unwrap();
497 assert_eq!(
498 stats2["post_count"].as_i64().unwrap(),
499 2,
500 "no duplicate reply"
501 );
502 }
503
504 #[tokio::test]
505 async fn create_post_nonexistent_thread() {
506 let h = InternalTestHarness::new().await;
507 let fake_id = Uuid::new_v4();
508
509 let body = serde_json::json!({
510 "body_markdown": "Reply to nothing",
511 "author_mnw_id": Uuid::new_v4(),
512 "author_username": "nobody",
513 "external_ref": "mnw:post:orphan-reply",
514 });
515 let (status, _) = h
516 .signed_post(
517 &format!("/internal/threads/{fake_id}/posts"),
518 &body.to_string(),
519 )
520 .await;
521 assert_eq!(status, StatusCode::NOT_FOUND);
522 }
523
524 // Auto-create category tests
525
526 #[tokio::test]
527 async fn create_thread_auto_creates_category() {
528 let h = InternalTestHarness::new().await;
529 let owner_id = Uuid::new_v4();
530
531 // Create community (has 4 default categories)
532 let comm_body = serde_json::json!({
533 "name": "Autocat Project",
534 "slug": "autocat-project",
535 "owner_mnw_id": owner_id,
536 "owner_username": "autocatuser",
537 });
538 let (status, _) = h
539 .signed_post("/internal/communities", &comm_body.to_string())
540 .await;
541 assert_eq!(status, StatusCode::OK);
542
543 // Create thread with a non-existent category slug, should auto-create it.
544 // Use "releases" (not a default) since "issues"/"patches" are now seeded.
545 let thread_body = serde_json::json!({
546 "community_slug": "autocat-project",
547 "category_slug": "releases",
548 "title": "v1.0 release notes",
549 "body_markdown": "First release",
550 "author_mnw_id": owner_id,
551 "author_username": "autocatuser",
552 "external_ref": "mnw:release:autocat-test"
553 });
554 let (status, text) = h
555 .signed_post("/internal/threads", &thread_body.to_string())
556 .await;
557 assert_eq!(status, StatusCode::OK, "body: {text}");
558
559 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
560 assert!(resp["created"].as_bool().unwrap());
561
562 // Verify "releases" category was auto-created
563 let comm_resp: serde_json::Value = serde_json::from_str(
564 &h.signed_post("/internal/communities", &comm_body.to_string())
565 .await
566 .1,
567 )
568 .unwrap();
569 let community_id: Uuid = comm_resp["community_id"].as_str().unwrap().parse().unwrap();
570
571 let categories: Vec<(String,)> =
572 sqlx::query_as("SELECT slug FROM categories WHERE community_id = $1 ORDER BY sort_order")
573 .bind(community_id)
574 .fetch_all(&h.db)
575 .await
576 .unwrap();
577
578 let slugs: Vec<&str> = categories.iter().map(|c| c.0.as_str()).collect();
579 assert!(
580 slugs.contains(&"releases"),
581 "Expected 'releases' category, got: {slugs:?}"
582 );
583 assert_eq!(categories.len(), 7); // 6 default + 1 auto-created
584 }
585
586 // ── v2 signing: method/path binding + nonce replay protection ──
587
588 fn community_body(slug: &str) -> String {
589 serde_json::json!({
590 "name": "Replay Project",
591 "slug": slug,
592 "description": null,
593 "owner_mnw_id": Uuid::new_v4(),
594 "owner_username": "replayowner",
595 "owner_display_name": null,
596 })
597 .to_string()
598 }
599
600 #[tokio::test]
601 async fn replayed_nonce_is_rejected() {
602 let h = InternalTestHarness::new().await;
603 let body = community_body("replay-proj");
604 let nonce = Uuid::new_v4().simple().to_string();
605
606 // First use of the nonce succeeds.
607 let (status, text) = h
608 .send_signed(
609 Method::POST,
610 "/internal/communities",
611 "/internal/communities",
612 &nonce,
613 &body,
614 )
615 .await;
616 assert_eq!(
617 status,
618 StatusCode::OK,
619 "first request should succeed: {text}"
620 );
621
622 // Replaying the exact same signed request (same nonce) is rejected, even
623 // though create_community is otherwise idempotent.
624 let (status, text) = h
625 .send_signed(
626 Method::POST,
627 "/internal/communities",
628 "/internal/communities",
629 &nonce,
630 &body,
631 )
632 .await;
633 assert_eq!(
634 status,
635 StatusCode::UNAUTHORIZED,
636 "replayed nonce must be rejected: {text}"
637 );
638 }
639
640 #[tokio::test]
641 async fn signature_bound_to_path() {
642 let h = InternalTestHarness::new().await;
643 let body = community_body("wrongpath-proj");
644 let nonce = Uuid::new_v4().simple().to_string();
645
646 // Sign for a different path than the request is sent to → signature mismatch.
647 let (status, _text) = h
648 .send_signed(
649 Method::POST,
650 "/internal/communities",
651 "/internal/threads",
652 &nonce,
653 &body,
654 )
655 .await;
656 assert_eq!(
657 status,
658 StatusCode::UNAUTHORIZED,
659 "path-mismatched signature must be rejected"
660 );
661 }
662
663 #[tokio::test]
664 async fn signature_bound_to_method() {
665 let h = InternalTestHarness::new().await;
666 let nonce = Uuid::new_v4().simple().to_string();
667
668 // Sign as GET but the stats endpoint is reached via GET with a POST-signed
669 // message → mismatch. (Sign method "POST", send via GET.)
670 let id = Uuid::new_v4();
671 let uri = format!("/internal/threads/{id}/stats");
672 let timestamp = chrono::Utc::now().timestamp().to_string();
673 let mut mac = Hmac::<Sha256>::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key");
674 for field in [timestamp.as_str(), "POST", uri.as_str(), nonce.as_str()] {
675 mac.update(field.as_bytes());
676 mac.update(b"\n");
677 }
678 let signature = hex::encode(mac.finalize().into_bytes());
679
680 let mut request = Request::builder()
681 .method(Method::GET)
682 .uri(&uri)
683 .header("X-Internal-Timestamp", &timestamp)
684 .header("X-Internal-Signature", &signature)
685 .header("X-Internal-Nonce", &nonce)
686 .body(Body::empty())
687 .expect("build request");
688 request
689 .extensions_mut()
690 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
691 let response = h.app.clone().oneshot(request).await.expect("send");
692 assert_eq!(
693 response.status(),
694 StatusCode::UNAUTHORIZED,
695 "method-mismatched signature must be rejected"
696 );
697 }
698