//! Integration tests for the internal API (HMAC-signed requests from MNW). use axum::body::Body; use axum::extract::ConnectInfo; use axum::http::{Method, Request, StatusCode}; use axum::Router; use hmac::{Hmac, 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()), }; 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 signed POST request to the internal API. async fn signed_post(&self, uri: &str, body: &str) -> (StatusCode, String) { let timestamp = chrono::Utc::now().timestamp().to_string(); let message = format!("{}\n{}", timestamp, body); let mut mac = Hmac::::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key"); mac.update(message.as_bytes()); let signature = hex::encode(mac.finalize().into_bytes()); let mut request = Request::builder() .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") .header("X-Internal-Timestamp", ×tamp) .header("X-Internal-Signature", &signature) .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(); let text = String::from_utf8_lossy(&bytes).to_string(); (status, text) } /// Send a signed GET request to the internal API. async fn get(&self, uri: &str) -> (StatusCode, String) { let timestamp = chrono::Utc::now().timestamp().to_string(); let message = format!("{}\n", timestamp); let mut mac = Hmac::::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key"); mac.update(message.as_bytes()); 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) .body(Body::empty()) .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(); let text = String::from_utf8_lossy(&bytes).to_string(); (status, text) } } // ============================================================================ // 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(); // Get stats let (status, stats_text) = h.get(&format!("/internal/threads/{}/stats", thread_id)).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/{}/stats", fake_id)).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" }); let (status, text) = h .signed_post( &format!("/internal/threads/{}/posts", thread_id), &reply_body.to_string(), ) .await; assert_eq!(status, StatusCode::OK, "body: {}", text); let post_resp: serde_json::Value = serde_json::from_str(&text).unwrap(); assert!(post_resp["post_id"].as_str().is_some()); // Verify thread now has 2 posts let (_, stats_text) = h.get(&format!("/internal/threads/{}/stats", thread_id)).await; let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap(); assert_eq!(stats["post_count"].as_i64().unwrap(), 2); } #[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", }); let (status, _) = h .signed_post( &format!("/internal/threads/{}/posts", fake_id), &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 }