//! Inbound patch email webhook tests: auth, sender/project resolution, message-ID mapping. use crate::harness::{BuildOptions, TestHarness}; const INBOUND_TOKEN: &str = "test-inbound-patch-token"; /// Helper: POST a Postmark inbound payload with optional auth token. async fn post_inbound(h: &mut TestHarness, token: Option<&str>, body: &str) -> u16 { let mut headers = vec![("Content-Type", "application/json")]; let auth; if let Some(t) = token { auth = format!("Bearer {}", t); headers.push(("Authorization", &auth)); } let resp = h .client .request_with_headers("POST", "/postmark/inbound", Some(body), &headers) .await; resp.status.as_u16() } /// Build a harness with inbound webhook token configured. async fn harness_with_inbound() -> TestHarness { TestHarness::build(BuildOptions { postmark_inbound_webhook_token: Some(INBOUND_TOKEN.to_string()), ..Default::default() }) .await } /// Build a minimal Postmark inbound JSON payload. fn inbound_payload( from_email: &str, to: &str, subject: &str, text_body: &str, message_id: &str, extra_headers: &[(&str, &str)], ) -> String { let headers: Vec = extra_headers .iter() .map(|(name, value)| { serde_json::json!({"Name": name, "Value": value}) }) .collect(); serde_json::json!({ "FromFull": {"Email": from_email, "Name": "Test Sender"}, "From": from_email, "To": to, "Subject": subject, "TextBody": text_body, "MessageID": message_id, "Headers": headers }) .to_string() } // ── Auth ── #[tokio::test] async fn inbound_missing_token_returns_401() { let mut h = harness_with_inbound().await; let body = inbound_payload( "alice@test.com", "my-proj@patches.makenot.work", "[PATCH] Fix typo", "diff --git", "", &[], ); let status = post_inbound(&mut h, None, &body).await; assert_eq!(status, 401); } #[tokio::test] async fn inbound_invalid_token_returns_401() { let mut h = harness_with_inbound().await; let body = inbound_payload( "alice@test.com", "my-proj@patches.makenot.work", "[PATCH] Fix typo", "diff --git", "", &[], ); let status = post_inbound(&mut h, Some("wrong-token"), &body).await; assert_eq!(status, 401); } #[tokio::test] async fn inbound_no_configured_token_returns_401() { // Default harness has no inbound token configured let mut h = TestHarness::new().await; let body = inbound_payload( "alice@test.com", "my-proj@patches.makenot.work", "[PATCH] Fix typo", "diff --git", "", &[], ); let status = post_inbound(&mut h, Some(INBOUND_TOKEN), &body).await; assert_eq!(status, 401); } // ── Project / sender resolution ── #[tokio::test] async fn inbound_unknown_project_returns_200_no_side_effects() { let mut h = harness_with_inbound().await; // Create a user so sender lookup succeeds, but project doesn't exist let user_id = h.signup("patchuser", "patchuser@test.com", "password123").await; sqlx::query("UPDATE users SET email_verified = true WHERE id = $1") .bind(user_id) .execute(&h.db) .await .unwrap(); let body = inbound_payload( "patchuser@test.com", "nonexistent@patches.makenot.work", "[PATCH] Fix typo", "diff --git", "", &[], ); let status = post_inbound(&mut h, Some(INBOUND_TOKEN), &body).await; assert_eq!(status, 200); // No patch_message_ids rows should exist let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM patch_message_ids") .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0); } #[tokio::test] async fn inbound_unknown_sender_returns_200_no_side_effects() { let mut h = harness_with_inbound().await; // Create a project but no user with matching email let creator_id = h.create_creator("patchcreator").await; h.grant_creator(creator_id).await; h.client.post_form("/api/projects", "slug=test-repo&title=Test+Repo").await; // Publish the project let project_id: String = sqlx::query_scalar( "SELECT id::text FROM projects WHERE slug = 'test-repo'", ) .fetch_one(&h.db) .await .unwrap(); h.client .put_json( &format!("/api/projects/{}", project_id), r#"{"is_public": true}"#, ) .await; let body = inbound_payload( "stranger@example.com", "test-repo@patches.makenot.work", "[PATCH] Fix typo", "diff --git", "", &[], ); let status = post_inbound(&mut h, Some(INBOUND_TOKEN), &body).await; assert_eq!(status, 200); // No patch_message_ids rows should exist let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM patch_message_ids") .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0); } #[tokio::test] async fn inbound_unverified_sender_returns_200_no_side_effects() { let mut h = harness_with_inbound().await; // Create user but don't verify email h.signup("unverified", "unverified@test.com", "password123").await; let body = inbound_payload( "unverified@test.com", "some-proj@patches.makenot.work", "[PATCH] Fix typo", "diff --git", "", &[], ); let status = post_inbound(&mut h, Some(INBOUND_TOKEN), &body).await; assert_eq!(status, 200); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM patch_message_ids") .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0); } // ── Database layer: message-ID mapping ── #[tokio::test] async fn patch_message_id_insert_and_lookup() { let h = harness_with_inbound().await; // Create a project to reference let project_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO users (username, email, password_hash) VALUES ('dbuser', 'db@test.com', 'hash') RETURNING id", ) .fetch_one(&h.db) .await .unwrap(); let proj_id: makenotwork::db::ProjectId = sqlx::query_scalar( "INSERT INTO projects (user_id, slug, title, project_type) VALUES ($1, 'db-proj', 'DB Project', 'software') RETURNING id", ) .bind(project_id) .fetch_one(&h.db) .await .unwrap(); let thread_id = makenotwork::db::MtThreadId::new(); // Insert makenotwork::db::patches::insert_patch_message_id( &h.db, "", proj_id, thread_id, ) .await .unwrap(); // Lookup by single ID let found = makenotwork::db::patches::get_thread_id_by_message_id( &h.db, "", ) .await .unwrap(); assert_eq!(found, Some(thread_id)); // Lookup non-existent let not_found = makenotwork::db::patches::get_thread_id_by_message_id( &h.db, "", ) .await .unwrap(); assert_eq!(not_found, None); } #[tokio::test] async fn patch_message_id_lookup_any() { let h = harness_with_inbound().await; let user_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO users (username, email, password_hash) VALUES ('anyuser', 'any@test.com', 'hash') RETURNING id", ) .fetch_one(&h.db) .await .unwrap(); let proj_id: makenotwork::db::ProjectId = sqlx::query_scalar( "INSERT INTO projects (user_id, slug, title, project_type) VALUES ($1, 'any-proj', 'Any Project', 'software') RETURNING id", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); let thread_id = makenotwork::db::MtThreadId::new(); // Insert a message ID makenotwork::db::patches::insert_patch_message_id( &h.db, "", proj_id, thread_id, ) .await .unwrap(); // Lookup with a mix of known and unknown IDs (simulates References header) let found = makenotwork::db::patches::get_thread_id_by_any_message_id( &h.db, &["", "", ""], ) .await .unwrap(); assert_eq!(found, Some(thread_id)); // Lookup with all unknown IDs let not_found = makenotwork::db::patches::get_thread_id_by_any_message_id( &h.db, &["", ""], ) .await .unwrap(); assert_eq!(not_found, None); // Lookup with empty list let empty = makenotwork::db::patches::get_thread_id_by_any_message_id( &h.db, &[], ) .await .unwrap(); assert_eq!(empty, None); } #[tokio::test] async fn patch_message_id_duplicate_is_idempotent() { let h = harness_with_inbound().await; let user_id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO users (username, email, password_hash) VALUES ('dupeuser', 'dupe@test.com', 'hash') RETURNING id", ) .fetch_one(&h.db) .await .unwrap(); let proj_id: makenotwork::db::ProjectId = sqlx::query_scalar( "INSERT INTO projects (user_id, slug, title, project_type) VALUES ($1, 'dupe-proj', 'Dupe Project', 'software') RETURNING id", ) .bind(user_id) .fetch_one(&h.db) .await .unwrap(); let thread_id = makenotwork::db::MtThreadId::new(); // Insert twice with same message_id makenotwork::db::patches::insert_patch_message_id( &h.db, "", proj_id, thread_id, ) .await .unwrap(); makenotwork::db::patches::insert_patch_message_id( &h.db, "", proj_id, thread_id, ) .await .unwrap(); // Should have exactly one row let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM patch_message_ids WHERE message_id = ''", ) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1); }