smtp: extract validate_send_input + 9 unit tests for size caps
Refactor: pull the pre-flight validation block out of send() into a standalone validate_send_input() function. send() now starts with a single validate_send_input(&input)? call. Behavior identical; the extraction is purely so unit tests can exercise the validation paths without standing up a fake SMTP server. New tests (9): - validate_accepts_minimal_input (the happy path) - validate_rejects_empty_to - validate_rejects_too_many_recipients (150 > 100 cap) - validate_recipient_cap_boundary_passes (exactly 100 OK) - validate_rejects_oversized_body - validate_rejects_oversized_body_html - validate_rejects_too_many_attachments - validate_rejects_oversized_attachment_encoded (pre-decode bound) - validate_accepts_at_attachment_boundary Test count: 18 -> 27. All passing.
This commit is contained in:
parent
6fb63b0ca0
commit
f7e698b09f
1 changed files with 118 additions and 1 deletions
|
|
@ -61,7 +61,10 @@ const MAX_ATTACHMENTS: usize = 25;
|
||||||
const MAX_BODY_BYTES: usize = 5 * 1024 * 1024;
|
const MAX_BODY_BYTES: usize = 5 * 1024 * 1024;
|
||||||
const MAX_TOTAL_RECIPIENTS: usize = 100;
|
const MAX_TOTAL_RECIPIENTS: usize = 100;
|
||||||
|
|
||||||
pub async fn send(account: &Account, input: SendInput) -> Result<SendOutput> {
|
/// Pre-flight validation on a `SendInput`. Extracted from `send()` so it's
|
||||||
|
/// callable from unit tests without a live SMTP server. All caps are enforced
|
||||||
|
/// here; the SMTP transport handles only the wire-format work after this passes.
|
||||||
|
pub fn validate_send_input(input: &SendInput) -> Result<()> {
|
||||||
if input.to.is_empty() {
|
if input.to.is_empty() {
|
||||||
return Err(anyhow!("at least one `to` address required"));
|
return Err(anyhow!("at least one `to` address required"));
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +107,11 @@ pub async fn send(account: &Account, input: SendInput) -> Result<SendOutput> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(account: &Account, input: SendInput) -> Result<SendOutput> {
|
||||||
|
validate_send_input(&input)?;
|
||||||
|
|
||||||
// Build From, To, Cc, Bcc.
|
// Build From, To, Cc, Bcc.
|
||||||
let from_str = format!("{} <{}>", account.from_name, account.from_addr);
|
let from_str = format!("{} <{}>", account.from_name, account.from_addr);
|
||||||
|
|
@ -287,6 +295,115 @@ fn civil_from_unix(t: i64) -> (i64, u32, u32, u32, u32, u32) {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// ----- validate_send_input -----
|
||||||
|
|
||||||
|
fn ok_input() -> SendInput {
|
||||||
|
SendInput {
|
||||||
|
to: vec!["a@b.com".into()],
|
||||||
|
body: "hello".into(),
|
||||||
|
subject: "test".into(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_accepts_minimal_input() {
|
||||||
|
assert!(validate_send_input(&ok_input()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_empty_to() {
|
||||||
|
let mut i = ok_input();
|
||||||
|
i.to.clear();
|
||||||
|
assert!(validate_send_input(&i)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("at least one"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_too_many_recipients() {
|
||||||
|
let mut i = ok_input();
|
||||||
|
i.to = (0..50).map(|n| format!("to-{n}@x.com")).collect();
|
||||||
|
i.cc = (0..50).map(|n| format!("cc-{n}@x.com")).collect();
|
||||||
|
i.bcc = (0..50).map(|n| format!("bcc-{n}@x.com")).collect();
|
||||||
|
// 150 total — over 100 cap
|
||||||
|
let err = validate_send_input(&i).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("too many recipients"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_recipient_cap_boundary_passes() {
|
||||||
|
let mut i = ok_input();
|
||||||
|
i.to = (0..100).map(|n| format!("to-{n}@x.com")).collect();
|
||||||
|
assert!(validate_send_input(&i).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_oversized_body() {
|
||||||
|
let mut i = ok_input();
|
||||||
|
i.body = "x".repeat(MAX_BODY_BYTES + 1);
|
||||||
|
assert!(validate_send_input(&i)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("body too large"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_oversized_body_html() {
|
||||||
|
let mut i = ok_input();
|
||||||
|
i.body_html = Some("y".repeat(MAX_BODY_BYTES + 1));
|
||||||
|
assert!(validate_send_input(&i)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("body_html too large"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_too_many_attachments() {
|
||||||
|
let mut i = ok_input();
|
||||||
|
i.attachments = (0..MAX_ATTACHMENTS + 1)
|
||||||
|
.map(|n| AttachmentSpec {
|
||||||
|
filename: format!("a-{n}.txt"),
|
||||||
|
content_base64: "aGVsbG8=".into(), // "hello"
|
||||||
|
mime_type: "text/plain".into(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(validate_send_input(&i)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("too many attachments"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_oversized_attachment_encoded() {
|
||||||
|
let mut i = ok_input();
|
||||||
|
// Encoded size ~33% > decoded cap → refused before decode.
|
||||||
|
let huge_encoded = "A".repeat((MAX_ATTACHMENT_BYTES * 4 + 2) / 3 + 100);
|
||||||
|
i.attachments.push(AttachmentSpec {
|
||||||
|
filename: "big.bin".into(),
|
||||||
|
content_base64: huge_encoded,
|
||||||
|
mime_type: "application/octet-stream".into(),
|
||||||
|
});
|
||||||
|
let err = validate_send_input(&i).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("exceeds limit"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_accepts_at_attachment_boundary() {
|
||||||
|
let mut i = ok_input();
|
||||||
|
i.attachments = (0..MAX_ATTACHMENTS)
|
||||||
|
.map(|n| AttachmentSpec {
|
||||||
|
filename: format!("a-{n}.txt"),
|
||||||
|
content_base64: "aGVsbG8=".into(),
|
||||||
|
mime_type: "text/plain".into(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(validate_send_input(&i).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- civil_from_unix -----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn civil_from_unix_epoch() {
|
fn civil_from_unix_epoch() {
|
||||||
assert_eq!(civil_from_unix(0), (1970, 1, 1, 0, 0, 0));
|
assert_eq!(civil_from_unix(0), (1970, 1, 1, 0, 0, 0));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue