mod smtp_stub;
mod common;
use axum::http::StatusCode;
use tower::ServiceExt;
use serde_json::json;
use common::{send, send_valid, test_router, test_router_no_smtp, RequestBuilder};
use smtp_stub::{SmtpStub, StubConfig};
#[tokio::test]
async fn sec_001_no_auth_returns_401() {
let router = test_router_no_smtp();
let resp = send(
&router,
RequestBuilder::post("/v1/send")
.no_auth()
.json(common::valid_mail_body())
.build(),
)
.await;
resp.assert_status(StatusCode::UNAUTHORIZED)
.assert_code("unauthorized");
}
#[tokio::test]
async fn sec_002_wrong_token_returns_403() {
let router = test_router_no_smtp();
let resp = send(
&router,
RequestBuilder::post("/v1/send")
.bearer("totally-wrong")
.json(common::valid_mail_body())
.build(),
)
.await;
resp.assert_status(StatusCode::FORBIDDEN)
.assert_code("forbidden");
}
#[tokio::test]
async fn sec_003_disabled_key_returns_403() {
let router = test_router_no_smtp();
let resp = send(
&router,
RequestBuilder::post("/v1/send")
.bearer("disabled-secret")
.json(common::valid_mail_body())
.build(),
)
.await;
resp.assert_status(StatusCode::FORBIDDEN)
.assert_code("forbidden");
}
#[tokio::test]
async fn sec_008_unknown_field_from_rejected() {
let router = test_router_no_smtp();
let resp = send(
&router,
RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello.",
"from": "evil@evil.com"
}))
.build(),
)
.await;
assert_ne!(resp.status, StatusCode::ACCEPTED, "from field must be rejected");
}
#[tokio::test]
async fn sec_009_unknown_field_bcc_rejected() {
let router = test_router_no_smtp();
let resp = send(
&router,
RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello.",
"bcc": "spy@evil.com"
}))
.build(),
)
.await;
assert_ne!(resp.status, StatusCode::ACCEPTED, "bcc field must be rejected");
}
#[tokio::test]
async fn sec_010_unknown_field_headers_rejected() {
let router = test_router_no_smtp();
let resp = send(
&router,
RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello.",
"headers": {"X-Custom": "injected"}
}))
.build(),
)
.await;
assert_ne!(resp.status, StatusCode::ACCEPTED, "headers field must be rejected");
}
#[tokio::test]
async fn sec_011_oversized_body_returns_413() {
use http_smtp_rele::{api, AppState};
let mut cfg = common::test_config(1);
cfg.server.max_request_body_bytes = 100;
let router = api::build_router(AppState::new(cfg));
let big = "x".repeat(200);
let resp = send(
&router,
RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.raw_body(big.as_bytes())
.build(),
)
.await;
assert_eq!(resp.status, StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn sec_013_rate_limit_exceeded_returns_429() {
use http_smtp_rele::{api, AppState};
let mut cfg = common::test_config(1);
cfg.rate_limit.per_key_burst = 1;
cfg.rate_limit.per_key_per_min = 1;
let router = api::build_router(AppState::new(cfg));
let _ = send_valid(&router).await;
let resp = send_valid(&router).await;
resp.assert_status(StatusCode::TOO_MANY_REQUESTS)
.assert_code("rate_limited");
}
#[tokio::test]
async fn sec_015_auth_failure_body_has_no_token() {
let router = test_router_no_smtp();
let secret = "ultra-secret-token-xyzxxxxxxxxxx";
let resp = send(
&router,
RequestBuilder::post("/v1/send")
.bearer(secret)
.json(common::valid_mail_body())
.build(),
)
.await;
assert_eq!(resp.status, StatusCode::FORBIDDEN);
let body_str = resp.body.to_string();
assert!(
!body_str.contains(secret),
"auth failure response must not echo the submitted token; body={body_str}"
);
}
#[tokio::test]
async fn e2e_001_valid_request_reaches_smtp_and_returns_202() {
let stub = SmtpStub::start(0).await;
let router = test_router(stub.port());
let resp = send_valid(&router).await;
resp.assert_status(StatusCode::ACCEPTED)
.assert_status_field("accepted");
stub.assert_count(1);
let msg = stub.assert_one();
assert!(msg.envelope_to.contains("user@example.com"));
stub.shutdown().await;
}
#[tokio::test]
async fn e2e_002_smtp_down_returns_502() {
let router = test_router(1);
let resp = send_valid(&router).await;
resp.assert_status(StatusCode::BAD_GATEWAY)
.assert_code("smtp_unavailable");
}
#[tokio::test]
async fn e2e_003_smtp_rejects_message_returns_502() {
let stub = SmtpStub::start_with_config(
0,
StubConfig { reject_mail: true, ..Default::default() },
)
.await;
let router = test_router(stub.port());
let resp = send_valid(&router).await;
resp.assert_status(StatusCode::BAD_GATEWAY)
.assert_code("smtp_unavailable");
stub.shutdown().await;
}
#[tokio::test]
async fn e2e_004_healthz_independent_of_smtp() {
let router = test_router(1); let resp = send(
&router,
RequestBuilder::get("/healthz").build(),
)
.await;
resp.assert_status(StatusCode::OK)
.assert_status_field("ok");
}
#[tokio::test]
async fn e2e_005_readyz_ok_when_smtp_reachable() {
let stub = SmtpStub::start(0).await;
let router = test_router(stub.port());
let resp = send(&router, RequestBuilder::get("/readyz").build()).await;
resp.assert_status(StatusCode::OK);
stub.shutdown().await;
}
#[tokio::test]
async fn e2e_006_readyz_503_when_smtp_down() {
let router = test_router(1);
let resp = send(&router, RequestBuilder::get("/readyz").build()).await;
resp.assert_status(StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn e2e_007_request_id_consistent() {
let stub = SmtpStub::start(0).await;
let router = test_router(stub.port());
let req = RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(common::valid_mail_body())
.build();
let raw_resp = router.clone().oneshot(req).await.unwrap();
let x_request_id = raw_resp
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let bytes = axum::body::to_bytes(raw_resp.into_body(), 4096).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let body_request_id = body["request_id"].as_str().unwrap_or("");
assert!(!x_request_id.is_empty(), "X-Request-Id header must be set");
assert_eq!(
x_request_id, body_request_id,
"X-Request-Id header must match request_id in body"
);
stub.shutdown().await;
}
#[tokio::test]
async fn e2e_008_mail_envelope_and_body_correct() {
let stub = SmtpStub::start(0).await;
let router = test_router(stub.port());
let _ = send(
&router,
RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(json!({
"to": "alice@example.com",
"subject": "Hello Alice",
"body": "Dear Alice, this is a test."
}))
.build(),
)
.await;
stub.assert_count(1);
let msg = stub.assert_one();
assert!(msg.envelope_to.contains("alice@example.com"), "wrong RCPT TO: {}", msg.envelope_to);
assert!(
msg.body.contains("Dear Alice"),
"body not forwarded: {}",
msg.body
);
assert!(
!msg.body.contains("primary-secret-padded-to-32bytes!"),
"API secret must not appear in the submitted mail body"
);
stub.shutdown().await;
}
#[tokio::test]
async fn e2e_009_wrong_content_type_returns_415() {
let router = test_router_no_smtp();
let resp = send(
&router,
RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.content_type("text/plain")
.raw_body(b"not json".to_vec())
.build(),
)
.await;
assert_eq!(
resp.status,
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"wrong content-type must return 415; got {} body={}",
resp.status,
resp.body
);
}
#[tokio::test]
async fn from_address_always_from_config() {
let stub = SmtpStub::start(0).await;
let router = test_router(stub.port());
let _ = send(
&router,
RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello."
}))
.build(),
)
.await;
stub.assert_count(1);
let msg = stub.assert_one();
assert!(
msg.envelope_from.contains("relay@example.com"),
"MAIL FROM must be relay@example.com (config), got: {}",
msg.envelope_from
);
stub.shutdown().await;
}
#[tokio::test]
async fn per_key_burst_override_respected() {
use http_smtp_rele::{api, AppState};
let mut cfg = common::test_config(1);
cfg.security.api_keys[0].burst = 2;
cfg.rate_limit.global_burst = 50;
cfg.rate_limit.per_key_burst = 2;
let router = api::build_router(AppState::new(cfg));
let _ = send(&router, RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!").json(common::valid_mail_body()).build()).await;
let _ = send(&router, RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!").json(common::valid_mail_body()).build()).await;
let resp = send(&router, RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!").json(common::valid_mail_body()).build()).await;
resp.assert_status(StatusCode::TOO_MANY_REQUESTS)
.assert_code("rate_limited");
}
#[tokio::test]
async fn per_key_default_rate_distinct_from_ip_rate() {
use http_smtp_rele::{api, AppState};
let mut cfg = common::test_config(1);
cfg.rate_limit.per_key_per_min = 600; cfg.rate_limit.per_ip_per_min = 1; cfg.rate_limit.per_ip_burst = 1;
cfg.rate_limit.global_burst = 200;
cfg.rate_limit.per_key_burst = 200;
let router = api::build_router(AppState::new(cfg));
let _ = send(&router, RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!").json(common::valid_mail_body()).build()).await;
let resp = send(&router, RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!").json(common::valid_mail_body()).build()).await;
resp.assert_status(StatusCode::TOO_MANY_REQUESTS);
assert_eq!(resp.body["code"], "rate_limited");
}
#[tokio::test]
async fn per_address_allowlist_permits_listed_address() {
use http_smtp_rele::{api, AppState};
let mut cfg = common::test_config(1);
cfg.security.api_keys[0].allowed_recipients = vec!["alice@example.com".into()];
let router = api::build_router(AppState::new(cfg));
let resp = send(&router, RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(serde_json::json!({
"to": "alice@example.com",
"subject": "Hi",
"body": "Hello."
}))
.build()).await;
resp.assert_status(StatusCode::BAD_GATEWAY);
}
#[tokio::test]
async fn per_address_allowlist_blocks_unlisted_address() {
use http_smtp_rele::{api, AppState};
let mut cfg = common::test_config(1);
cfg.security.api_keys[0].allowed_recipients = vec!["alice@example.com".into()];
let router = api::build_router(AppState::new(cfg));
let resp = send(&router, RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(serde_json::json!({
"to": "bob@example.com",
"subject": "Hi",
"body": "Hello."
}))
.build()).await;
resp.assert_status(StatusCode::BAD_REQUEST)
.assert_code("validation_failed");
}
#[tokio::test]
async fn per_address_empty_list_falls_through_to_domain_policy() {
use http_smtp_rele::{api, AppState};
let mut cfg = common::test_config(1);
cfg.security.api_keys[0].allowed_recipients = vec![];
cfg.mail.allowed_recipient_domains = vec!["example.com".into()];
let router = api::build_router(AppState::new(cfg));
let ok = send(&router, RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(serde_json::json!({"to":"any@example.com","subject":"Hi","body":"Hi"}))
.build()).await;
assert_ne!(ok.status, StatusCode::BAD_REQUEST, "should pass domain check");
let blocked = send(&router, RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(serde_json::json!({"to":"evil@evil.com","subject":"Hi","body":"Hi"}))
.build()).await;
blocked.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn concurrency_limit_zero_is_unlimited() {
let router = test_router_no_smtp();
let resp = send_valid(&router).await;
assert_ne!(resp.status, StatusCode::SERVICE_UNAVAILABLE,
"concurrency=0 should not reject requests");
}
#[tokio::test]
async fn smtp_auth_user_only_fails_config_validation() {
use http_smtp_rele::config;
use std::path::Path;
let toml = r#"
[mail]
default_from = "r@example.com"
[[security.api_keys]]
id = "k"
secret = "sxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
enabled = true
[smtp]
auth_user = "user@example.com"
"#;
let tmp = std::env::temp_dir().join("http-smtp-rele-test-auth.toml");
std::fs::write(&tmp, toml).unwrap();
let result = config::load(Path::new(&tmp));
std::fs::remove_file(&tmp).ok();
assert!(result.is_err(), "auth_user without auth_password must fail config load");
}
#[tokio::test]
async fn multi_recipient_array_delivers_to_all() {
let stub = SmtpStub::start(0).await;
let router = test_router(stub.port());
let resp = send(&router, RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(serde_json::json!({
"to": ["alice@example.com", "bob@example.com"],
"subject": "Multi-recipient test",
"body": "Hello both."
}))
.build()).await;
resp.assert_status(StatusCode::ACCEPTED);
stub.assert_count(1);
let msg = stub.assert_one();
assert!(
msg.envelope_to.contains("alice@example.com") ||
msg.envelope_to.contains("bob@example.com"),
"expected a recipient in envelope, got: {}", msg.envelope_to
);
stub.shutdown().await;
}
#[tokio::test]
async fn multi_recipient_string_still_works() {
let stub = SmtpStub::start(0).await;
let router = test_router(stub.port());
let resp = send_valid(&router).await;
resp.assert_status(StatusCode::ACCEPTED);
stub.assert_count(1);
stub.shutdown().await;
}
#[tokio::test]
async fn multi_recipient_empty_array_rejected() {
let router = test_router_no_smtp();
let resp = send(&router, RequestBuilder::post("/v1/send")
.bearer("primary-secret-padded-to-32bytes!")
.json(serde_json::json!({
"to": [],
"subject": "Hi",
"body": "Hello."
}))
.build()).await;
assert_ne!(resp.status, StatusCode::ACCEPTED, "empty to array must be rejected");
}
#[tokio::test]
async fn forwarded_header_resolved_when_trusted_proxy() {
use http_smtp_rele::{api, AppState};
let mut cfg = common::test_config(1);
cfg.security.trust_proxy_headers = true;
cfg.security.trusted_source_cidrs = vec!["127.0.0.1/32".into()];
cfg.security.allowed_source_cidrs = vec!["1.2.3.4/32".into()];
let router = api::build_router(AppState::new(cfg));
use axum::http::header;
let resp = send(&router, axum::http::Request::builder()
.method("POST")
.uri("/v1/send")
.header(header::AUTHORIZATION, "Bearer primary-secret-padded-to-32bytes!")
.header(header::CONTENT_TYPE, "application/json")
.header("forwarded", "for=10.0.0.1")
.body(axum::body::Body::from(
serde_json::to_string(&common::valid_mail_body()).unwrap()
))
.unwrap()).await;
resp.assert_status(StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn arcswap_config_hot_swap_takes_effect_immediately() {
use http_smtp_rele::{AppState, config::*};
let cfg = common::test_config(1);
let state = AppState::new(cfg);
{
let c = state.config();
assert_eq!(c.security.api_keys[0].id, "primary");
}
let mut new_cfg = common::test_config(1);
new_cfg.security.api_keys[0] = ApiKeyConfig {
id: "new-key".into(),
secret: SecretString::new("new-secret"),
enabled: true,
description: None,
allowed_recipient_domains: vec!["example.com".into()],
allowed_recipients: vec![],
rate_limit_per_min: None,
burst: 0,
mask_recipient: None,
};
state.reload_config(new_cfg);
{
let c = state.config();
assert_eq!(c.security.api_keys[0].id, "new-key");
}
}