haematite-cli 0.21.8

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
use std::io::Cursor;

use super::{Response, read};

type TestResult = Result<(), Box<dyn std::error::Error>>;

const HEAD: usize = 16_384;
const BODY: usize = 1 << 20;

#[test]
fn a_request_is_read_with_its_path_query_cookies_and_body() -> TestResult {
    let mut wire = Cursor::new(
        b"POST /api/haem/wards?x=1 HTTP/1.1\r\nHost: studio.example\r\nCookie: a=1; haem_session=abc\r\nContent-Length: 4\r\n\r\nbodyEXTRA".to_vec(),
    );
    let request = read(&mut wire, HEAD, BODY)?;
    assert_eq!(request.method, "POST");
    assert_eq!(request.path, "/api/haem/wards");
    assert_eq!(request.query, "x=1");
    assert_eq!(request.cookie("haem_session"), Some("abc"));
    assert_eq!(request.cookie("absent"), None);
    assert_eq!(request.body, b"body");
    Ok(())
}

#[test]
fn a_query_parameter_is_decoded() -> TestResult {
    let mut wire = Cursor::new(b"GET /callback?code=a%2Fb&state=s+1 HTTP/1.1\r\n\r\n".to_vec());
    let request = read(&mut wire, HEAD, BODY)?;
    assert_eq!(request.parameter("code").as_deref(), Some("a/b"));
    assert_eq!(request.parameter("state").as_deref(), Some("s 1"));
    assert_eq!(request.parameter("nothing"), None);
    Ok(())
}

#[test]
fn a_head_or_body_past_its_bound_or_a_chunked_body_is_refused() {
    let long = format!("GET /{} HTTP/1.1\r\n\r\n", "a".repeat(HEAD));
    assert!(read(&mut Cursor::new(long.into_bytes()), HEAD, BODY).is_err());
    let big = b"POST / HTTP/1.1\r\nContent-Length: 9999999\r\n\r\n".to_vec();
    assert!(read(&mut Cursor::new(big), HEAD, BODY).is_err());
    let chunked = b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n".to_vec();
    assert!(read(&mut Cursor::new(chunked), HEAD, BODY).is_err());
    let cut = b"POST / HTTP/1.1\r\nContent-Length: 10\r\n\r\nshort".to_vec();
    assert!(read(&mut Cursor::new(cut), HEAD, BODY).is_err());
}

#[test]
fn a_response_carries_its_status_type_headers_and_no_cache() -> TestResult {
    let mut out = Vec::new();
    Response::redirect("https://lys.example/oauth/authorize?state=s")
        .with("Set-Cookie", "haem_state=b; HttpOnly")
        .write(&mut out)?;
    let text = String::from_utf8(out)?;
    assert!(text.starts_with("HTTP/1.1 303 See Other\r\n"), "{text}");
    assert!(text.contains("\r\nLocation: https://lys.example/oauth/authorize?state=s\r\n"));
    assert!(text.contains("\r\nSet-Cookie: haem_state=b; HttpOnly\r\n"));
    assert!(text.contains("\r\nCache-Control: no-store\r\n"));
    assert!(text.ends_with("\r\n\r\n"));
    Ok(())
}

/// A 204 has no body, so it says no Content-Type and no Content-Length.
#[test]
fn a_no_content_answer_carries_no_body_headers() -> TestResult {
    let mut out = Vec::new();
    Response::no_content()
        .with("Set-Cookie", "haem_session=; Max-Age=0")
        .write(&mut out)?;
    let text = String::from_utf8(out)?;
    assert!(text.starts_with("HTTP/1.1 204 No Content\r\n"), "{text}");
    assert!(!text.contains("Content-Length"), "{text}");
    assert!(!text.contains("Content-Type"), "{text}");
    assert!(text.contains("\r\nSet-Cookie: haem_session=; Max-Age=0\r\n"));
    assert!(text.ends_with("\r\n\r\n"));
    Ok(())
}

#[test]
fn a_header_value_that_would_split_the_response_is_refused() {
    let mut out = Vec::new();
    let split = Response::redirect("/x\r\nSet-Cookie: stolen=1").write(&mut out);
    assert!(split.is_err());
    assert!(out.is_empty(), "a split response was written");
}