use super::webhook_http::*;
use std::io::Read;
use std::time::Duration;
struct ChunkReader {
chunks: Vec<Vec<u8>>,
idx: usize,
off: usize,
}
impl ChunkReader {
fn new(chunks: &[&[u8]]) -> Self {
Self {
chunks: chunks.iter().map(|c| c.to_vec()).collect(),
idx: 0,
off: 0,
}
}
}
impl Read for ChunkReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.idx >= self.chunks.len() {
return Ok(0);
}
let chunk = &self.chunks[self.idx];
let remaining = &chunk[self.off..];
let n = remaining.len().min(buf.len());
buf[..n].copy_from_slice(&remaining[..n]);
self.off += n;
if self.off >= chunk.len() {
self.idx += 1;
self.off = 0;
}
Ok(n)
}
}
fn deadline() -> Duration {
Duration::from_secs(5)
}
fn head(body_len: usize) -> String {
format!(
"POST /webhook HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nContent-Length: {body_len}\r\n\r\n"
)
}
#[test]
fn body_in_one_segment_is_read() {
let body = br#"{"action":"deploy"}"#;
let raw = format!("{}{}", head(body.len()), String::from_utf8_lossy(body));
let mut r = ChunkReader::new(&[raw.as_bytes()]);
match read_request(&mut r, 4096, deadline()) {
ReadOutcome::Complete {
method,
path,
body: b,
..
} => {
assert_eq!(method, "POST");
assert_eq!(path, "/webhook");
assert_eq!(b, body);
}
other => panic!("expected Complete, got {other:?}"),
}
}
#[test]
fn body_split_across_segments_is_reassembled() {
let body = br#"{"action":"deploy"}"#;
let h = head(body.len());
let mut r = ChunkReader::new(&[h.as_bytes(), body]);
match read_request(&mut r, 4096, deadline()) {
ReadOutcome::Complete { body: b, .. } => assert_eq!(b, body),
other => panic!("split delivery must reassemble, got {other:?}"),
}
}
#[test]
fn body_split_one_byte_at_a_time() {
let body = br#"{"a":1}"#;
let h = head(body.len());
let mut chunks: Vec<&[u8]> = vec![h.as_bytes()];
let singles: Vec<[u8; 1]> = body.iter().map(|b| [*b]).collect();
for s in &singles {
chunks.push(s);
}
let mut r = ChunkReader::new(&chunks);
match read_request(&mut r, 4096, deadline()) {
ReadOutcome::Complete { body: b, .. } => assert_eq!(b, body),
other => panic!("expected Complete, got {other:?}"),
}
}
#[test]
fn head_split_across_segments() {
let body = b"{}";
let h = head(body.len());
let (a, b) = h.split_at(12);
let mut r = ChunkReader::new(&[a.as_bytes(), b.as_bytes(), body]);
assert!(matches!(
read_request(&mut r, 4096, deadline()),
ReadOutcome::Complete { .. }
));
}
#[test]
fn body_longer_than_content_length_is_truncated() {
let h = head(2);
let raw = format!("{h}{{}}EXTRA");
let mut r = ChunkReader::new(&[raw.as_bytes()]);
match read_request(&mut r, 4096, deadline()) {
ReadOutcome::Complete { body, .. } => assert_eq!(body, b"{}"),
other => panic!("expected Complete, got {other:?}"),
}
}
#[test]
fn missing_content_length_means_empty_body() {
let raw = "POST /webhook HTTP/1.1\r\nHost: x\r\n\r\n";
let mut r = ChunkReader::new(&[raw.as_bytes()]);
match read_request(&mut r, 4096, deadline()) {
ReadOutcome::Complete { body, .. } => assert!(body.is_empty()),
other => panic!("expected Complete, got {other:?}"),
}
}
#[test]
fn oversize_content_length_is_rejected_before_reading_the_body() {
let h = head(10_000);
let mut r = ChunkReader::new(&[h.as_bytes()]);
assert_eq!(
read_request(&mut r, 1024, deadline()),
ReadOutcome::Rejected {
status: 413,
code: "body_too_large"
}
);
}
#[test]
fn max_body_above_64k_is_honoured() {
let body = vec![b'x'; 200_000];
let h = head(body.len());
let mut r = ChunkReader::new(&[h.as_bytes(), &body]);
match read_request(&mut r, 1024 * 1024, deadline()) {
ReadOutcome::Complete { body: b, .. } => assert_eq!(b.len(), 200_000),
other => panic!("1MiB max must accept a 200KB body, got {other:?}"),
}
}
#[test]
fn oversize_head_is_rejected_with_431() {
let mut big = String::from("POST /webhook HTTP/1.1\r\n");
for i in 0..2000 {
big.push_str(&format!("X-Pad-{i}: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r\n"));
}
let mut r = ChunkReader::new(&[big.as_bytes()]);
assert_eq!(
read_request(&mut r, 4096, deadline()),
ReadOutcome::Rejected {
status: 431,
code: "head_too_large"
}
);
}
#[test]
fn duplicate_content_length_is_rejected() {
let raw = "POST /webhook HTTP/1.1\r\nContent-Length: 2\r\nContent-Length: 3\r\n\r\n{}";
let mut r = ChunkReader::new(&[raw.as_bytes()]);
assert_eq!(
read_request(&mut r, 4096, deadline()),
ReadOutcome::Rejected {
status: 400,
code: "duplicate_content_length"
}
);
}
#[test]
fn non_numeric_content_length_is_rejected() {
let raw = "POST /webhook HTTP/1.1\r\nContent-Length: banana\r\n\r\n";
let mut r = ChunkReader::new(&[raw.as_bytes()]);
assert_eq!(
read_request(&mut r, 4096, deadline()),
ReadOutcome::Rejected {
status: 400,
code: "invalid_content_length"
}
);
}
#[test]
fn transfer_encoding_is_rejected_with_501() {
let raw = "POST /webhook HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n";
let mut r = ChunkReader::new(&[raw.as_bytes()]);
assert_eq!(
read_request(&mut r, 4096, deadline()),
ReadOutcome::Rejected {
status: 501,
code: "transfer_encoding_unsupported"
}
);
}
#[test]
fn empty_connection_owes_no_response() {
let mut r = ChunkReader::new(&[]);
assert_eq!(read_request(&mut r, 4096, deadline()), ReadOutcome::Empty);
}
#[test]
fn malformed_request_line_is_rejected() {
let raw = "BADLINE\r\n\r\n";
let mut r = ChunkReader::new(&[raw.as_bytes()]);
assert_eq!(
read_request(&mut r, 4096, deadline()),
ReadOutcome::Rejected {
status: 400,
code: "malformed_request_line"
}
);
}
#[test]
fn truncated_body_is_rejected() {
let h = head(100);
let mut r = ChunkReader::new(&[h.as_bytes(), b"short"]);
assert_eq!(
read_request(&mut r, 4096, deadline()),
ReadOutcome::Rejected {
status: 400,
code: "incomplete_body"
}
);
}
#[test]
fn bare_lf_is_not_a_head_terminator() {
let raw = "POST /webhook HTTP/1.1\nContent-Length: 2\n\n{}";
let mut r = ChunkReader::new(&[raw.as_bytes()]);
assert!(matches!(
read_request(&mut r, 4096, deadline()),
ReadOutcome::Rejected { .. }
));
}
#[test]
fn headers_are_lowercased_and_query_preserved() {
let raw = "POST /webhook?x=1 HTTP/1.1\r\nX-Forjar-Signature: t=1,v1=ab\r\n\r\n";
let mut r = ChunkReader::new(&[raw.as_bytes()]);
match read_request(&mut r, 4096, deadline()) {
ReadOutcome::Complete { path, headers, .. } => {
assert_eq!(path, "/webhook?x=1");
assert!(headers
.iter()
.any(|(k, v)| k == "x-forjar-signature" && v == "t=1,v1=ab"));
}
other => panic!("expected Complete, got {other:?}"),
}
}
#[test]
fn every_response_body_is_valid_json() {
for status in [
200u16, 400, 401, 403, 404, 405, 408, 413, 431, 500, 501, 503,
] {
let raw = response(status, "some_code");
let text = String::from_utf8(raw).expect("response must be UTF-8");
let (_, body) = text
.split_once("\r\n\r\n")
.unwrap_or_else(|| panic!("no header/body split for {status}"));
let parsed: serde_json::Value = serde_json::from_str(body)
.unwrap_or_else(|e| panic!("status {status} body is not JSON: {e} ({body})"));
assert_eq!(parsed["status"], "some_code");
}
}
#[test]
fn response_escapes_hostile_codes() {
let raw = response(404, r#"PathNotAllowed { path: "/evil" }"#);
let text = String::from_utf8(raw).unwrap();
let (_, body) = text.split_once("\r\n\r\n").unwrap();
let parsed: serde_json::Value = serde_json::from_str(body).expect("must stay valid JSON");
assert_eq!(parsed["status"], r#"PathNotAllowed { path: "/evil" }"#);
}
#[test]
fn response_declares_length_and_closes() {
let text = String::from_utf8(response(200, "accepted")).unwrap();
let (head, body) = text.split_once("\r\n\r\n").unwrap();
assert!(head.contains("Connection: close"));
assert!(head.contains(&format!("Content-Length: {}", body.len())));
}
#[test]
fn method_not_allowed_carries_allow_header() {
let text = String::from_utf8(response(405, "method_not_allowed")).unwrap();
assert!(text.contains("Allow: POST"), "405 must advertise Allow");
assert!(text.starts_with("HTTP/1.1 405 Method Not Allowed"));
}
#[test]
fn service_unavailable_carries_retry_after() {
let text = String::from_utf8(response(503, "queue_full")).unwrap();
assert!(text.contains("Retry-After:"));
}
#[test]
fn previously_unreachable_statuses_have_reasons() {
for (status, reason) in [
(401u16, "Unauthorized"),
(404, "Not Found"),
(405, "Method Not Allowed"),
(413, "Content Too Large"),
(431, "Request Header Fields Too Large"),
(501, "Not Implemented"),
] {
let text = String::from_utf8(response(status, "c")).unwrap();
assert!(
text.starts_with(&format!("HTTP/1.1 {status} {reason}")),
"status {status} rendered as {:?}",
text.lines().next()
);
}
}