//! End-to-end tests for the inbound-email webhook receiver (v1.1.7). //! //! Gated on `DATABASE_URL` like `dispatcher_e2e.rs`: when unset the test //! prints a notice and returns early so plain `cargo test` stays green. //! //! Covers the receiver's status-code matrix (202 / 401 / 404 / 422), //! cross-app path isolation, HMAC verification (signed + unsigned //! triggers), the dispatcher routing the `email` outbox row, and the //! handler actually firing with `ctx.event.email` populated. The //! "handler fired" observation uses the same KV-marker pattern as //! `dispatcher_e2e.rs`. #![allow(clippy::needless_pass_by_value)] use std::time::Duration; use axum_test::TestServer; use hmac::{Hmac, Mac}; use serde_json::{json, Value}; use sha2::Sha256; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use uuid::Uuid; /// Fixed master key so the receiver decrypts the inbound_secret the /// admin endpoint encrypted (same key feeds build_app + the admin path). fn master_key() -> picloud_shared::MasterKey { picloud_shared::MasterKey::from_bytes([0x42u8; 32]) } async fn pool_or_skip() -> Option { let Ok(url) = std::env::var("DATABASE_URL") else { eprintln!("email_inbound: DATABASE_URL unset — skipping"); return None; }; let pool = PgPoolOptions::new() .max_connections(5) .connect(&url) .await .expect("connect to DATABASE_URL"); sqlx::migrate!("../manager-core/migrations") .run(&pool) .await .expect("apply migrations"); Some(pool) } async fn server_for(pool: PgPool, suffix: &str) -> (TestServer, String) { use picloud_manager_core::auth::hash_password; use picloud_shared::InstanceRole; let unique = format!("{suffix}-{}", Uuid::new_v4().simple()); let auth = picloud::AuthDeps::from_pool(pool.clone()); let username = format!("eml-{unique}"); let hash = hash_password("pw").expect("hash"); auth.users .create(&username, &hash, InstanceRole::Owner, None) .await .expect("seed admin"); let app = picloud::build_app(pool, auth, master_key()) .await .expect("build_app"); let mut server = TestServer::new(app).expect("TestServer"); let resp = server .post("/api/v1/admin/auth/login") .json(&json!({ "username": username, "password": "pw" })) .await; resp.assert_status_ok(); let token = resp.json::()["token"] .as_str() .expect("login token") .to_string(); server.add_header("authorization", format!("Bearer {token}")); let slug = format!("eml-{unique}"); let created: Value = server .post("/api/v1/admin/apps") .json(&json!({ "slug": slug, "name": slug })) .await .json(); let app_id = created["id"].as_str().expect("app id").to_string(); (server, app_id) } async fn create_script(server: &TestServer, app_id: &str, name: &str, source: &str) -> String { let created: Value = server .post("/api/v1/admin/scripts") .json(&json!({ "app_id": app_id, "name": name, "source": source })) .await .json(); created["id"].as_str().expect("script id").to_string() } const MARKER_HANDLER: &str = r#" let e = ctx.event; kv::collection("e2e_markers").set("marker", e); #{ ok: true } "#; async fn create_email_trigger( server: &TestServer, app_id: &str, script_id: &str, secret: Option<&str>, ) -> String { let created: Value = server .post(&format!("/api/v1/admin/apps/{app_id}/triggers/email")) .json(&json!({ "script_id": script_id, "inbound_secret": secret })) .await .json(); created["id"].as_str().expect("trigger id").to_string() } fn sign(secret: &str, body: &str) -> String { let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("hmac key"); mac.update(body.as_bytes()); hex::encode(mac.finalize().into_bytes()) } async fn poll_marker(pool: &PgPool, app_id: &str) -> Option { let app_uuid = Uuid::parse_str(app_id).expect("app uuid"); for _ in 0..100 { let row: Option<(Value,)> = sqlx::query_as( "SELECT value FROM kv_entries \ WHERE app_id = $1 AND collection = 'e2e_markers' AND key = 'marker'", ) .bind(app_uuid) .fetch_optional(pool) .await .expect("query marker"); if let Some((value,)) = row { return Some(value); } tokio::time::sleep(Duration::from_millis(100)).await; } None } const BODY: &str = r#"{"from":"sender@external.com","to":["alice@myapp.com"],"cc":["bob@myapp.com"],"subject":"Re: question","text":"hello there","message_id":""}"#; #[tokio::test] async fn signed_post_accepts_and_fires_handler() { let Some(pool) = pool_or_skip().await else { return; }; let (server, app_id) = server_for(pool.clone(), "signed").await; let handler = create_script(&server, &app_id, "eml-handler", MARKER_HANDLER).await; let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await; let sig = sign("topsecret", BODY); server .post(&format!("/api/v1/email-inbound/{app_id}/{trigger}")) .add_header("x-picloud-signature", sig) .text(BODY) .await .assert_status(axum::http::StatusCode::ACCEPTED); // Outbox row landed with source_kind = 'email'. let app_uuid = Uuid::parse_str(&app_id).unwrap(); // The dispatcher deletes the row after delivery; instead assert the // handler fired (which proves the email row was dispatched). let event = poll_marker(&pool, &app_id).await.expect("handler fired"); assert_eq!(event["source"], "email"); assert_eq!(event["op"], "receive"); assert_eq!(event["email"]["from"], "sender@external.com"); assert_eq!(event["email"]["to"][0], "alice@myapp.com"); assert_eq!(event["email"]["cc"][0], "bob@myapp.com"); assert_eq!(event["email"]["subject"], "Re: question"); assert_eq!(event["email"]["text"], "hello there"); assert_eq!(event["email"]["message_id"], ""); let _ = app_uuid; } #[tokio::test] async fn missing_signature_is_401_when_secret_configured() { let Some(pool) = pool_or_skip().await else { return; }; let (server, app_id) = server_for(pool, "nosig").await; let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await; let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await; server .post(&format!("/api/v1/email-inbound/{app_id}/{trigger}")) .text(BODY) .await .assert_status(axum::http::StatusCode::UNAUTHORIZED); } #[tokio::test] async fn wrong_signature_is_401() { let Some(pool) = pool_or_skip().await else { return; }; let (server, app_id) = server_for(pool, "wrongsig").await; let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await; let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await; server .post(&format!("/api/v1/email-inbound/{app_id}/{trigger}")) .add_header("x-picloud-signature", sign("WRONG", BODY)) .text(BODY) .await .assert_status(axum::http::StatusCode::UNAUTHORIZED); } #[tokio::test] async fn unsigned_trigger_accepts_without_signature() { let Some(pool) = pool_or_skip().await else { return; }; let (server, app_id) = server_for(pool, "unsigned").await; let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await; let trigger = create_email_trigger(&server, &app_id, &handler, None).await; server .post(&format!("/api/v1/email-inbound/{app_id}/{trigger}")) .text(BODY) .await .assert_status(axum::http::StatusCode::ACCEPTED); } #[tokio::test] async fn unknown_trigger_is_404() { let Some(pool) = pool_or_skip().await else { return; }; let (server, app_id) = server_for(pool, "missing").await; let missing = Uuid::new_v4(); server .post(&format!("/api/v1/email-inbound/{app_id}/{missing}")) .text(BODY) .await .assert_status(axum::http::StatusCode::NOT_FOUND); } #[tokio::test] async fn wrong_kind_trigger_is_404() { let Some(pool) = pool_or_skip().await else { return; }; let (server, app_id) = server_for(pool, "wrongkind").await; let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await; // A KV trigger — not an email trigger. let kv_trigger: Value = server .post(&format!("/api/v1/admin/apps/{app_id}/triggers/kv")) .json(&json!({ "script_id": handler, "collection_glob": "*" })) .await .json(); let kv_id = kv_trigger["id"].as_str().unwrap(); server .post(&format!("/api/v1/email-inbound/{app_id}/{kv_id}")) .text(BODY) .await .assert_status(axum::http::StatusCode::NOT_FOUND); } #[tokio::test] async fn malformed_body_is_422() { let Some(pool) = pool_or_skip().await else { return; }; let (server, app_id) = server_for(pool, "malformed").await; let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await; // Unsigned so we reach the parse step. let trigger = create_email_trigger(&server, &app_id, &handler, None).await; server .post(&format!("/api/v1/email-inbound/{app_id}/{trigger}")) .text("not json at all") .await .assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); } #[tokio::test] async fn cross_app_path_is_404() { let Some(pool) = pool_or_skip().await else { return; }; // Two apps under the same server. A trigger created in app B must // not be reachable via app A's path segment. let (server, app_a) = server_for(pool.clone(), "xa").await; let app_b: Value = server .post("/api/v1/admin/apps") .json(&json!({ "slug": format!("xb-{}", Uuid::new_v4().simple()), "name": "xb" })) .await .json(); let app_b_id = app_b["id"].as_str().unwrap().to_string(); let handler_b = create_script(&server, &app_b_id, "hb", MARKER_HANDLER).await; let trigger_b = create_email_trigger(&server, &app_b_id, &handler_b, None).await; // POST to app A's path with app B's trigger id → 404 (path-bound). server .post(&format!("/api/v1/email-inbound/{app_a}/{trigger_b}")) .text(BODY) .await .assert_status(axum::http::StatusCode::NOT_FOUND); }