parlov 0.7.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
Documentation
//! Integration tests for RFC 9110 §13 cache-probing HEAD handlers.
//!
//! Each test spawns an isolated server and validates conditional
//! evaluation, content negotiation, and normalized endpoints.
//! HEAD responses MUST NOT carry a body per RFC 9110 §9.3.2.

#![deny(clippy::all)]

mod fixtures {
    pub mod head_server;
}

use fixtures::head_server::spawn;

// ========================================================================
// /cp/conditional/{id} — RFC 9110 §13.2 evaluation chain (HEAD)
// ========================================================================

// --- baseline: no conditional headers ---

#[tokio::test]
async fn head_conditional_no_headers_existing_returns_200() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 200);
}

#[tokio::test]
async fn head_conditional_no_headers_nonexistent_returns_404() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/999"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 404);
}

#[tokio::test]
async fn head_conditional_200_has_no_body() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 200);
    let body = resp.bytes().await.expect("failed to read body");
    assert!(body.is_empty(), "HEAD response must not carry a body");
}

#[tokio::test]
async fn head_conditional_200_includes_etag_and_last_modified() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 200);
    assert_eq!(resp.headers()["etag"], "\"known-etag-42\"");
    assert_eq!(
        resp.headers()["last-modified"],
        "Wed, 01 Jan 2025 00:00:00 GMT"
    );
    assert_eq!(resp.headers()["content-type"], "application/json");
}

// --- If-None-Match ---

#[tokio::test]
async fn head_conditional_if_none_match_star_returns_304() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/42"))
        .header("If-None-Match", "*")
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 304);
}

#[tokio::test]
async fn head_conditional_if_none_match_star_nonexistent_returns_404() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/999"))
        .header("If-None-Match", "*")
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 404);
}

#[tokio::test]
async fn head_conditional_if_none_match_matching_returns_304() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/42"))
        .header("If-None-Match", "\"known-etag-42\"")
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 304);
}

#[tokio::test]
async fn head_conditional_if_none_match_nonmatching_returns_200() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/42"))
        .header("If-None-Match", "\"wrong-etag\"")
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 200);
}

// --- If-Match ---

#[tokio::test]
async fn head_conditional_if_match_nonmatching_returns_412() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/42"))
        .header("If-Match", "\"wrong-etag\"")
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 412);
}

#[tokio::test]
async fn head_conditional_if_match_nonmatching_nonexistent_returns_404() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/999"))
        .header("If-Match", "\"wrong-etag\"")
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 404);
}

// --- If-Modified-Since / If-Unmodified-Since ---

#[tokio::test]
async fn head_conditional_if_modified_since_future_returns_304() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/42"))
        .header("If-Modified-Since", "Sun, 01 Jun 2025 00:00:00 GMT")
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 304);
}

#[tokio::test]
async fn head_conditional_if_unmodified_since_past_returns_412() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/conditional/42"))
        .header("If-Unmodified-Since", "Mon, 01 Jan 2024 00:00:00 GMT")
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 412);
}

// ========================================================================
// /cp/negotiated/{id} — Content negotiation (HEAD)
// ========================================================================

#[tokio::test]
async fn head_negotiated_json_accept_returns_200() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/negotiated/42"))
        .header("Accept", "application/json")
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 200);
}

#[tokio::test]
async fn head_negotiated_xml_only_returns_406() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/negotiated/42"))
        .header("Accept", "application/xml")
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 406);
}

#[tokio::test]
async fn head_negotiated_nonexistent_returns_404() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/negotiated/999"))
        .header("Accept", "application/json")
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 404);
}

// ========================================================================
// /cp/normalized/{id} — Hardened negative case (HEAD)
// ========================================================================

#[tokio::test]
async fn head_normalized_existing_returns_404() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/normalized/42"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 404);
}

#[tokio::test]
async fn head_normalized_nonexistent_returns_404() {
    let addr = spawn().await;
    let resp = reqwest::Client::new()
        .head(format!("http://{addr}/cp/normalized/999"))
        .send()
        .await
        .expect("request failed");
    assert_eq!(resp.status(), 404);
}