use axum::{
body::Body,
http::{header, Request, StatusCode},
};
use serde_json::{json, Value};
use tower::ServiceExt;
use crate::{
api,
config::{
ApiKeyConfig, AppConfig, LoggingConfig, MailConfig, RateLimitConfig, SecretString,
SecurityConfig, ServerConfig, SmtpConfig,
},
AppState,
};
fn test_config() -> AppConfig {
AppConfig {
server: ServerConfig {
bind_address: "127.0.0.1:0".into(),
max_request_body_bytes: 256, request_timeout_seconds: 5,
shutdown_timeout_seconds: 5,
concurrency_limit: 0,
},
security: SecurityConfig {
require_auth: true,
trust_proxy_headers: false,
trusted_source_cidrs: vec![],
allowed_source_cidrs: vec![],
api_keys: vec![
ApiKeyConfig {
id: "enabled-key".into(),
secret: SecretString::new("valid-secret"),
enabled: true,
description: None,
allowed_recipient_domains: vec!["example.com".into()],
rate_limit_per_min: None,
allowed_recipients: vec![],
burst: 0,
},
ApiKeyConfig {
id: "disabled-key".into(),
secret: SecretString::new("disabled-secret"),
enabled: false,
description: None,
allowed_recipient_domains: vec![],
rate_limit_per_min: None,
allowed_recipients: vec![],
burst: 0,
},
],
},
mail: MailConfig {
default_from: "relay@example.com".into(),
default_from_name: None,
allowed_recipient_domains: vec!["example.com".into()],
max_subject_chars: 255,
max_body_bytes: 200, max_recipients: 10,
},
smtp: SmtpConfig {
mode: "smtp".into(),
host: "127.0.0.1".into(),
port: 1, connect_timeout_seconds: 1,
submission_timeout_seconds: 1,
auth_user: None,
auth_password: None,
pipe_command: "/usr/sbin/sendmail".into(),
},
rate_limit: RateLimitConfig {
global_per_min: 60,
per_ip_per_min: 20,
per_key_per_min: 30,
global_burst: 5,
per_ip_burst: 5,
per_key_burst: 5,
burst_size: 0,
ip_table_size: 100,
},
logging: LoggingConfig {
format: "text".into(),
level: "error".into(), mask_recipient: true,
},
}
}
fn test_router() -> axum::Router {
let state = AppState::new(test_config());
api::build_router(state)
}
async fn send_request(
router: &axum::Router,
auth: Option<&str>,
body: Value,
) -> (StatusCode, Value) {
let mut builder = Request::builder()
.method("POST")
.uri("/v1/send")
.header(header::CONTENT_TYPE, "application/json");
if let Some(token) = auth {
builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}"));
}
let req = builder.body(Body::from(body.to_string())).unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap();
let json: Value = serde_json::from_slice(&bytes).unwrap_or(json!({}));
(status, json)
}
fn valid_body() -> Value {
json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello."
})
}
#[tokio::test]
async fn sec_001_no_auth_header_returns_401() {
let router = test_router();
let (status, body) = send_request(&router, None, valid_body()).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "body={body}");
assert_eq!(body["code"], "unauthorized");
}
#[tokio::test]
async fn sec_002_wrong_token_returns_403() {
let router = test_router();
let (status, body) = send_request(&router, Some("completely-wrong"), valid_body()).await;
assert_eq!(status, StatusCode::FORBIDDEN, "body={body}");
assert_eq!(body["code"], "forbidden");
}
#[tokio::test]
async fn sec_003_disabled_key_returns_403() {
let router = test_router();
let (status, body) = send_request(&router, Some("disabled-secret"), valid_body()).await;
assert_eq!(status, StatusCode::FORBIDDEN, "body={body}");
assert_eq!(body["code"], "forbidden");
}
#[tokio::test]
async fn sec_008_unknown_field_from_rejected() {
let router = test_router();
let bad = json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello.",
"from": "evil@evil.com"
});
let (status, body) = send_request(&router, Some("valid-secret"), bad).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "body={body}");
}
#[tokio::test]
async fn sec_009_unknown_field_bcc_rejected() {
let router = test_router();
let bad = json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello.",
"bcc": "spy@evil.com"
});
let (status, _) = send_request(&router, Some("valid-secret"), bad).await;
assert!(
status == StatusCode::UNPROCESSABLE_ENTITY || status == StatusCode::BAD_REQUEST,
"expected 422 or 400, got {status}"
);
}
#[tokio::test]
async fn sec_010_unknown_field_headers_rejected() {
let router = test_router();
let bad = json!({
"to": "user@example.com",
"subject": "Test",
"body": "Hello.",
"headers": {"X-Custom": "injected"}
});
let (status, _) = send_request(&router, Some("valid-secret"), bad).await;
assert!(
status == StatusCode::UNPROCESSABLE_ENTITY || status == StatusCode::BAD_REQUEST,
"expected 422 or 400, got {status}"
);
}
#[tokio::test]
async fn sec_011_oversized_request_body_returns_413() {
let router = test_router();
let giant = "x".repeat(300);
let req = Request::builder()
.method("POST")
.uri("/v1/send")
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "Bearer valid-secret")
.body(Body::from(giant))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn from_address_cannot_be_overridden_via_extra_field() {
let router = test_router();
let with_from = json!({
"to": "user@example.com",
"subject": "Hi",
"body": "Text.",
"from": "spoofed@attacker.com"
});
let (status, _) = send_request(&router, Some("valid-secret"), with_from).await;
assert_ne!(
status,
StatusCode::ACCEPTED,
"A request with a 'from' field must never result in 202 Accepted"
);
}