//! Integration tests for the internal API (HMAC-signed requests from MNW). use axum::Router; use axum::body::Body; use axum::extract::ConnectInfo; use axum::http::{Method, Request, StatusCode}; use hmac::{Hmac, KeyInit, Mac}; use http_body_util::BodyExt; use sha2::Sha256; use sqlx::PgPool; use std::net::SocketAddr; use tower::ServiceExt; use uuid::Uuid; use crate::harness::db::TestDb; const TEST_SECRET: &str = "test-internal-secret-key-for-hmac"; /// Minimal harness for internal API tests, no CSRF/session, just the internal routes. struct InternalTestHarness { app: Router, db: PgPool, _test_db: TestDb, } impl InternalTestHarness { async fn new() -> Self { let test_db = TestDb::new().await; let pool = test_db.pool.clone(); let config = multithreaded::config::Config { mnw_base_url: "http://127.0.0.1:9999".into(), oauth_client_id: "test-client-id".to_string(), oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(), platform_admin_id: None, cookie_secure: false, s3: None, internal_shared_secret: Some(TEST_SECRET.to_string()), trusted_proxies: std::sync::Arc::from([std::net::IpAddr::from([127, 0, 0, 1])]), }; let state = multithreaded::AppState { db: pool.clone(), config, http: reqwest::Client::new(), link_preview: multithreaded::link_preview::LinkPreviewFetcher::Noop, s3: None, }; let app = multithreaded::routes::internal::internal_routes(state); InternalTestHarness { app, db: pool, _test_db: test_db, } } /// Send a v2-signed request, binding method + `sign_path` + nonce. The /// request is sent to `uri`; `sign_path` is what the signature covers, pass /// the same value normally, or a different one to test path-binding. async fn send_signed( &self, method: Method, uri: &str, sign_path: &str, nonce: &str, body: &str, ) -> (StatusCode, String) { let timestamp = chrono::Utc::now().timestamp().to_string(); let mut mac = Hmac::::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key"); for field in [timestamp.as_str(), method.as_str(), sign_path, nonce] { mac.update(field.as_bytes()); mac.update(b"\n"); } mac.update(body.as_bytes()); let signature = hex::encode(mac.finalize().into_bytes()); let mut builder = Request::builder() .method(method) .uri(uri) .header("X-Internal-Timestamp", ×tamp) .header("X-Internal-Signature", &signature) .header("X-Internal-Nonce", nonce); if !body.is_empty() { builder = builder.header("Content-Type", "application/json"); } let mut request = builder .body(Body::from(body.to_string())) .expect("build request"); request .extensions_mut() .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0)))); let response = self .app .clone() .oneshot(request) .await .expect("send request"); let status = response.status(); let bytes = response .into_body() .collect() .await .expect("read body") .to_bytes(); (status, String::from_utf8_lossy(&bytes).to_string()) } /// Send a v2-signed POST with a fresh nonce. async fn signed_post(&self, uri: &str, body: &str) -> (StatusCode, String) { let nonce = Uuid::new_v4().simple().to_string(); self.send_signed(Method::POST, uri, uri, &nonce, body).await } /// Send a v2-signed GET with a fresh nonce. async fn get(&self, uri: &str) -> (StatusCode, String) { let nonce = Uuid::new_v4().simple().to_string(); self.send_signed(Method::GET, uri, uri, &nonce, "").await } } // Community tests #[tokio::test] async fn create_community_happy_path() { let h = InternalTestHarness::new().await; let owner_id = Uuid::new_v4(); let body = serde_json::json!({ "name": "Test Project", "slug": "test-project", "description": "A test community", "owner_mnw_id": owner_id, "owner_username": "testcreator", "owner_display_name": "Test Creator" }); let (status, text) = h .signed_post("/internal/communities", &body.to_string()) .await; assert_eq!(status, StatusCode::OK, "body: {text}"); let resp: serde_json::Value = serde_json::from_str(&text).unwrap(); assert!(resp["created"].as_bool().unwrap()); assert!(resp["community_id"].as_str().is_some()); // Verify default categories were created. Order is fixed in // `routes/internal.rs`; Issues + Patches were added in step 6 to surface // the email-driven workflows in fresh communities. let community_id: Uuid = resp["community_id"].as_str().unwrap().parse().unwrap(); let categories: Vec<(String,)> = sqlx::query_as("SELECT slug FROM categories WHERE community_id = $1 ORDER BY sort_order") .bind(community_id) .fetch_all(&h.db) .await .unwrap(); let slugs: Vec<&str> = categories.iter().map(|(s,)| s.as_str()).collect(); assert_eq!( slugs, vec!["items", "blog", "devlog", "discussion", "issues", "patches"] ); } #[tokio::test] async fn create_community_idempotent() { let h = InternalTestHarness::new().await; let owner_id = Uuid::new_v4(); let body = serde_json::json!({ "name": "Idem Project", "slug": "idem-project", "owner_mnw_id": owner_id, "owner_username": "idemcreator", }); let (s1, t1) = h .signed_post("/internal/communities", &body.to_string()) .await; assert_eq!(s1, StatusCode::OK); let r1: serde_json::Value = serde_json::from_str(&t1).unwrap(); assert!(r1["created"].as_bool().unwrap()); // Second call with same slug let (s2, t2) = h .signed_post("/internal/communities", &body.to_string()) .await; assert_eq!(s2, StatusCode::OK); let r2: serde_json::Value = serde_json::from_str(&t2).unwrap(); assert!(!r2["created"].as_bool().unwrap()); assert_eq!(r1["community_id"], r2["community_id"]); } #[tokio::test] async fn create_community_rejects_bad_signature() { let h = InternalTestHarness::new().await; let body = r#"{"name":"Bad","slug":"bad","owner_mnw_id":"00000000-0000-0000-0000-000000000001","owner_username":"bad"}"#; let timestamp = chrono::Utc::now().timestamp().to_string(); let mut request = Request::builder() .method(Method::POST) .uri("/internal/communities") .header("Content-Type", "application/json") .header("X-Internal-Timestamp", ×tamp) .header("X-Internal-Signature", "deadbeef") .body(Body::from(body)) .expect("build request"); request .extensions_mut() .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0)))); let response = h.app.clone().oneshot(request).await.expect("send request"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn create_community_rejects_missing_headers() { let h = InternalTestHarness::new().await; let body = r#"{"name":"No Auth","slug":"noauth","owner_mnw_id":"00000000-0000-0000-0000-000000000001","owner_username":"noauth"}"#; let mut request = Request::builder() .method(Method::POST) .uri("/internal/communities") .header("Content-Type", "application/json") .body(Body::from(body)) .expect("build request"); request .extensions_mut() .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0)))); let response = h.app.clone().oneshot(request).await.expect("send request"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } // Thread tests #[tokio::test] async fn create_thread_happy_path() { let h = InternalTestHarness::new().await; let owner_id = Uuid::new_v4(); // First create a community let comm_body = serde_json::json!({ "name": "Thread Project", "slug": "thread-project", "owner_mnw_id": owner_id, "owner_username": "threadcreator", }); let (status, _) = h .signed_post("/internal/communities", &comm_body.to_string()) .await; assert_eq!(status, StatusCode::OK); // Create a thread let thread_body = serde_json::json!({ "community_slug": "thread-project", "category_slug": "items", "title": "New Item Discussion", "body_markdown": "Discussion for [New Item](https://example.com/i/123)", "author_mnw_id": owner_id, "author_username": "threadcreator", "external_ref": "mnw:item:00000000-0000-0000-0000-000000000123" }); let (status, text) = h .signed_post("/internal/threads", &thread_body.to_string()) .await; assert_eq!(status, StatusCode::OK, "body: {text}"); let resp: serde_json::Value = serde_json::from_str(&text).unwrap(); assert!(resp["created"].as_bool().unwrap()); assert!(resp["thread_id"].as_str().is_some()); assert!(resp["post_id"].as_str().is_some()); // Verify thread has external_ref in DB let thread_id: Uuid = resp["thread_id"].as_str().unwrap().parse().unwrap(); let ext_ref: Option = sqlx::query_scalar("SELECT external_ref FROM threads WHERE id = $1") .bind(thread_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( ext_ref.as_deref(), Some("mnw:item:00000000-0000-0000-0000-000000000123") ); } #[tokio::test] async fn create_thread_idempotent() { let h = InternalTestHarness::new().await; let owner_id = Uuid::new_v4(); // Create community let comm_body = serde_json::json!({ "name": "Idem Thread Proj", "slug": "idem-thread", "owner_mnw_id": owner_id, "owner_username": "idemthreaduser", }); h.signed_post("/internal/communities", &comm_body.to_string()) .await; let thread_body = serde_json::json!({ "community_slug": "idem-thread", "category_slug": "blog", "title": "Blog Discussion", "body_markdown": "Discussion body", "author_mnw_id": owner_id, "author_username": "idemthreaduser", "external_ref": "mnw:blog:dedup-test" }); let (s1, t1) = h .signed_post("/internal/threads", &thread_body.to_string()) .await; assert_eq!(s1, StatusCode::OK); let r1: serde_json::Value = serde_json::from_str(&t1).unwrap(); assert!(r1["created"].as_bool().unwrap()); // Second call with same external_ref let (s2, t2) = h .signed_post("/internal/threads", &thread_body.to_string()) .await; assert_eq!(s2, StatusCode::OK); let r2: serde_json::Value = serde_json::from_str(&t2).unwrap(); assert!(!r2["created"].as_bool().unwrap()); assert_eq!(r1["thread_id"], r2["thread_id"]); } #[tokio::test] async fn create_thread_missing_community() { let h = InternalTestHarness::new().await; let author_id = Uuid::new_v4(); let thread_body = serde_json::json!({ "community_slug": "nonexistent", "category_slug": "items", "title": "Orphan Thread", "body_markdown": "Should fail", "author_mnw_id": author_id, "author_username": "orphan", "external_ref": "mnw:item:orphan" }); let (status, _) = h .signed_post("/internal/threads", &thread_body.to_string()) .await; assert_eq!(status, StatusCode::NOT_FOUND); } // Thread stats tests #[tokio::test] async fn thread_stats_happy_path() { let h = InternalTestHarness::new().await; let owner_id = Uuid::new_v4(); // Create community + thread via internal API let comm_body = serde_json::json!({ "name": "Stats Project", "slug": "stats-project", "owner_mnw_id": owner_id, "owner_username": "statsuser", }); h.signed_post("/internal/communities", &comm_body.to_string()) .await; let thread_body = serde_json::json!({ "community_slug": "stats-project", "category_slug": "items", "title": "Stats Thread", "body_markdown": "Opening post", "author_mnw_id": owner_id, "author_username": "statsuser", "external_ref": "mnw:item:stats-1" }); let (_, text) = h .signed_post("/internal/threads", &thread_body.to_string()) .await; let resp: serde_json::Value = serde_json::from_str(&text).unwrap(); let thread_id = resp["thread_id"].as_str().unwrap(); let (status, stats_text) = h.get(&format!("/internal/threads/{thread_id}/stats")).await; assert_eq!(status, StatusCode::OK, "body: {stats_text}"); let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap(); assert_eq!(stats["post_count"].as_i64().unwrap(), 1); // opening post assert!(stats["last_activity_at"].as_str().is_some()); } #[tokio::test] async fn thread_stats_nonexistent() { let h = InternalTestHarness::new().await; let fake_id = Uuid::new_v4(); let (status, text) = h.get(&format!("/internal/threads/{fake_id}/stats")).await; assert_eq!(status, StatusCode::OK, "body: {text}"); let stats: serde_json::Value = serde_json::from_str(&text).unwrap(); assert_eq!(stats["post_count"].as_i64().unwrap(), 0); } #[tokio::test] async fn thread_stats_invalid_uuid() { let h = InternalTestHarness::new().await; let (status, _) = h.get("/internal/threads/not-a-uuid/stats").await; assert_eq!(status, StatusCode::NOT_FOUND); } // Create post tests #[tokio::test] async fn create_post_happy_path() { let h = InternalTestHarness::new().await; let owner_id = Uuid::new_v4(); // Create community + thread let comm_body = serde_json::json!({ "name": "Post Project", "slug": "post-project", "owner_mnw_id": owner_id, "owner_username": "postuser", }); h.signed_post("/internal/communities", &comm_body.to_string()) .await; let thread_body = serde_json::json!({ "community_slug": "post-project", "category_slug": "items", "title": "Thread for Reply", "body_markdown": "Opening post", "author_mnw_id": owner_id, "author_username": "postuser", "external_ref": "mnw:item:post-test-1" }); let (_, text) = h .signed_post("/internal/threads", &thread_body.to_string()) .await; let resp: serde_json::Value = serde_json::from_str(&text).unwrap(); let thread_id = resp["thread_id"].as_str().unwrap(); // Create a reply let reply_body = serde_json::json!({ "body_markdown": "This is a reply via internal API", "author_mnw_id": owner_id, "author_username": "postuser", "author_display_name": "Post User", "external_ref": "mnw:post:reply-1" }); let (status, text) = h .signed_post( &format!("/internal/threads/{thread_id}/posts"), &reply_body.to_string(), ) .await; assert_eq!(status, StatusCode::OK, "body: {text}"); let post_resp: serde_json::Value = serde_json::from_str(&text).unwrap(); let first_post_id = post_resp["post_id"].as_str().unwrap().to_string(); assert!( post_resp["created"].as_bool().unwrap(), "first call creates" ); // Verify thread now has 2 posts let (_, stats_text) = h.get(&format!("/internal/threads/{thread_id}/stats")).await; let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap(); assert_eq!(stats["post_count"].as_i64().unwrap(), 2); // Replay the same external_ref (a retried/replayed call): same post id, // created=false, and the post count does not grow (audit E1). let (status, text2) = h .signed_post( &format!("/internal/threads/{thread_id}/posts"), &reply_body.to_string(), ) .await; assert_eq!(status, StatusCode::OK, "body: {text2}"); let replay: serde_json::Value = serde_json::from_str(&text2).unwrap(); assert_eq!( replay["post_id"].as_str().unwrap(), first_post_id, "replay returns original" ); assert!( !replay["created"].as_bool().unwrap(), "replay does not create" ); let (_, stats_text2) = h.get(&format!("/internal/threads/{thread_id}/stats")).await; let stats2: serde_json::Value = serde_json::from_str(&stats_text2).unwrap(); assert_eq!( stats2["post_count"].as_i64().unwrap(), 2, "no duplicate reply" ); } #[tokio::test] async fn create_post_nonexistent_thread() { let h = InternalTestHarness::new().await; let fake_id = Uuid::new_v4(); let body = serde_json::json!({ "body_markdown": "Reply to nothing", "author_mnw_id": Uuid::new_v4(), "author_username": "nobody", "external_ref": "mnw:post:orphan-reply", }); let (status, _) = h .signed_post( &format!("/internal/threads/{fake_id}/posts"), &body.to_string(), ) .await; assert_eq!(status, StatusCode::NOT_FOUND); } // Auto-create category tests #[tokio::test] async fn create_thread_auto_creates_category() { let h = InternalTestHarness::new().await; let owner_id = Uuid::new_v4(); // Create community (has 4 default categories) let comm_body = serde_json::json!({ "name": "Autocat Project", "slug": "autocat-project", "owner_mnw_id": owner_id, "owner_username": "autocatuser", }); let (status, _) = h .signed_post("/internal/communities", &comm_body.to_string()) .await; assert_eq!(status, StatusCode::OK); // Create thread with a non-existent category slug, should auto-create it. // Use "releases" (not a default) since "issues"/"patches" are now seeded. let thread_body = serde_json::json!({ "community_slug": "autocat-project", "category_slug": "releases", "title": "v1.0 release notes", "body_markdown": "First release", "author_mnw_id": owner_id, "author_username": "autocatuser", "external_ref": "mnw:release:autocat-test" }); let (status, text) = h .signed_post("/internal/threads", &thread_body.to_string()) .await; assert_eq!(status, StatusCode::OK, "body: {text}"); let resp: serde_json::Value = serde_json::from_str(&text).unwrap(); assert!(resp["created"].as_bool().unwrap()); // Verify "releases" category was auto-created let comm_resp: serde_json::Value = serde_json::from_str( &h.signed_post("/internal/communities", &comm_body.to_string()) .await .1, ) .unwrap(); let community_id: Uuid = comm_resp["community_id"].as_str().unwrap().parse().unwrap(); let categories: Vec<(String,)> = sqlx::query_as("SELECT slug FROM categories WHERE community_id = $1 ORDER BY sort_order") .bind(community_id) .fetch_all(&h.db) .await .unwrap(); let slugs: Vec<&str> = categories.iter().map(|c| c.0.as_str()).collect(); assert!( slugs.contains(&"releases"), "Expected 'releases' category, got: {slugs:?}" ); assert_eq!(categories.len(), 7); // 6 default + 1 auto-created } // ── v2 signing: method/path binding + nonce replay protection ── fn community_body(slug: &str) -> String { serde_json::json!({ "name": "Replay Project", "slug": slug, "description": null, "owner_mnw_id": Uuid::new_v4(), "owner_username": "replayowner", "owner_display_name": null, }) .to_string() } #[tokio::test] async fn replayed_nonce_is_rejected() { let h = InternalTestHarness::new().await; let body = community_body("replay-proj"); let nonce = Uuid::new_v4().simple().to_string(); // First use of the nonce succeeds. let (status, text) = h .send_signed( Method::POST, "/internal/communities", "/internal/communities", &nonce, &body, ) .await; assert_eq!( status, StatusCode::OK, "first request should succeed: {text}" ); // Replaying the exact same signed request (same nonce) is rejected, even // though create_community is otherwise idempotent. let (status, text) = h .send_signed( Method::POST, "/internal/communities", "/internal/communities", &nonce, &body, ) .await; assert_eq!( status, StatusCode::UNAUTHORIZED, "replayed nonce must be rejected: {text}" ); } #[tokio::test] async fn signature_bound_to_path() { let h = InternalTestHarness::new().await; let body = community_body("wrongpath-proj"); let nonce = Uuid::new_v4().simple().to_string(); // Sign for a different path than the request is sent to → signature mismatch. let (status, _text) = h .send_signed( Method::POST, "/internal/communities", "/internal/threads", &nonce, &body, ) .await; assert_eq!( status, StatusCode::UNAUTHORIZED, "path-mismatched signature must be rejected" ); } #[tokio::test] async fn signature_bound_to_method() { let h = InternalTestHarness::new().await; let nonce = Uuid::new_v4().simple().to_string(); // Sign as GET but the stats endpoint is reached via GET with a POST-signed // message → mismatch. (Sign method "POST", send via GET.) let id = Uuid::new_v4(); let uri = format!("/internal/threads/{id}/stats"); let timestamp = chrono::Utc::now().timestamp().to_string(); let mut mac = Hmac::::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key"); for field in [timestamp.as_str(), "POST", uri.as_str(), nonce.as_str()] { mac.update(field.as_bytes()); mac.update(b"\n"); } let signature = hex::encode(mac.finalize().into_bytes()); let mut request = Request::builder() .method(Method::GET) .uri(&uri) .header("X-Internal-Timestamp", ×tamp) .header("X-Internal-Signature", &signature) .header("X-Internal-Nonce", &nonce) .body(Body::empty()) .expect("build request"); request .extensions_mut() .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0)))); let response = h.app.clone().oneshot(request).await.expect("send"); assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "method-mismatched signature must be rejected" ); }