parlov 0.3.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
//! Integration tests for the POST existence oracle pipeline.
//!
//! Each test spawns an isolated RFC-compliant POST server, drives the adaptive
//! sampling loop via `evaluate`, and asserts the final verdict and severity.

#![deny(clippy::all)]

mod fixtures {
    pub mod post_server;
}

use bytes::Bytes;
use fixtures::post_server::spawn;
use http::{HeaderMap, HeaderValue, Method, header};
use parlov_analysis::existence::ExistenceAnalyzer;
use parlov_analysis::{Analyzer, SampleDecision};
use parlov_core::{OracleResult, OracleVerdict, ProbeDefinition, ProbeSet, Severity};
use parlov_probe::http::HttpProbe;
use parlov_probe::Probe;

// --- helpers ---

fn json_headers() -> HeaderMap {
    let mut headers = HeaderMap::new();
    headers.insert(
        header::CONTENT_TYPE,
        HeaderValue::from_static("application/json"),
    );
    headers
}

fn known_body() -> Option<Bytes> {
    Some(Bytes::from_static(b"{\"email\":\"alice@corp.com\"}"))
}

fn unknown_body() -> Option<Bytes> {
    Some(Bytes::from_static(b"{\"email\":\"new@example.com\"}"))
}

fn def(url: String, body: Option<Bytes>) -> ProbeDefinition {
    ProbeDefinition {
        url,
        method: Method::POST,
        headers: json_headers(),
        body,
    }
}

fn no_redirect_probe() -> HttpProbe {
    let client = reqwest::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .build()
        .expect("client build failed");
    HttpProbe::with_client(client)
}

fn id_def(url: String) -> ProbeDefinition {
    ProbeDefinition {
        url,
        method: Method::POST,
        headers: json_headers(),
        body: Some(Bytes::from_static(b"{}")),
    }
}

/// Adaptive sampling loop for ID-based POST routes (URL path, not email body).
async fn collect_by_id(
    route_template: &str,
    baseline_id: &str,
    probe_id: &str,
    addr: std::net::SocketAddr,
) -> OracleResult {
    let client = no_redirect_probe();
    let analyzer = ExistenceAnalyzer;
    let baseline_url = format!(
        "http://{addr}{}",
        route_template.replace("{id}", baseline_id),
    );
    let probe_url = format!(
        "http://{addr}{}",
        route_template.replace("{id}", probe_id),
    );
    let mut probe_set = ProbeSet {
        baseline: Vec::new(),
        probe: Vec::new(),
    };
    loop {
        let b = client.execute(&id_def(baseline_url.clone())).await.expect("baseline failed");
        let p = client.execute(&id_def(probe_url.clone())).await.expect("probe failed");
        probe_set.baseline.push(b);
        probe_set.probe.push(p);
        if let SampleDecision::Complete(result) = analyzer.evaluate(&probe_set) {
            return result;
        }
    }
}

/// Adaptive sampling loop matching the binary's `collect_until_verdict` pattern.
async fn collect_until_verdict(route: &str, addr: std::net::SocketAddr) -> OracleResult {
    let client = no_redirect_probe();
    let analyzer = ExistenceAnalyzer;
    let baseline_url = format!("http://{addr}{route}");
    let probe_url = baseline_url.clone();
    let mut probe_set = ProbeSet {
        baseline: Vec::new(),
        probe: Vec::new(),
    };

    loop {
        let b = client.execute(&def(baseline_url.clone(), known_body())).await.expect("baseline request failed");
        let p = client.execute(&def(probe_url.clone(), unknown_body())).await.expect("probe request failed");
        probe_set.baseline.push(b);
        probe_set.probe.push(p);

        if let SampleDecision::Complete(result) = analyzer.evaluate(&probe_set) {
            return result;
        }
    }
}

// --- 409 vs 201 → Confirmed / High ---

#[tokio::test]
async fn post_409_vs_201_confirmed_high() {
    let addr = spawn().await;
    let result = collect_until_verdict("/users", addr).await;
    assert_eq!(result.verdict, OracleVerdict::Confirmed);
    assert_eq!(result.severity, Some(Severity::High));
}

// --- 409 vs 200 → Confirmed / High ---

#[tokio::test]
async fn post_409_vs_200_confirmed_high() {
    let addr = spawn().await;
    let result = collect_until_verdict("/users-200", addr).await;
    assert_eq!(result.verdict, OracleVerdict::Confirmed);
    assert_eq!(result.severity, Some(Severity::High));
}

// --- 409 vs 303 → Confirmed / High ---

#[tokio::test]
async fn post_409_vs_303_confirmed_high() {
    let addr = spawn().await;
    let result = collect_until_verdict("/users-303", addr).await;
    assert_eq!(result.verdict, OracleVerdict::Confirmed);
    assert_eq!(result.severity, Some(Severity::High));
}

// --- normalized (always 201) → NotPresent ---

#[tokio::test]
async fn post_normalized_not_present() {
    let addr = spawn().await;
    let result = collect_until_verdict("/normalized", addr).await;
    assert_eq!(result.verdict, OracleVerdict::NotPresent);
    assert_eq!(result.severity, None);
}

// --- {id} substitution: baseline body contains baseline ID → 409 ---

#[tokio::test]
async fn post_with_body_template_baseline_id_substituted() {
    let addr = spawn().await;
    let client = no_redirect_probe();
    // Simulate what the CLI does: substitute {id} → "alice@corp.com" for baseline.
    let body = Some(Bytes::from(r#"{"email":"alice@corp.com"}"#));
    let surface = client
        .execute(&def(format!("http://{addr}/users"), body))
        .await
        .expect("baseline request failed");
    assert_eq!(surface.status, http::StatusCode::CONFLICT);
}

// --- {id} substitution: probe body contains probe ID → 201 ---

#[tokio::test]
async fn post_with_body_template_probe_id_substituted() {
    let addr = spawn().await;
    let client = no_redirect_probe();
    // Simulate what the CLI does: substitute {id} → "nobody@corp.com" for probe.
    let body = Some(Bytes::from(r#"{"email":"nobody@corp.com"}"#));
    let surface = client
        .execute(&def(format!("http://{addr}/users"), body))
        .await
        .expect("probe request failed");
    assert_eq!(surface.status, http::StatusCode::CREATED);
}

// --- 409 vs 202 → Confirmed / High ---

#[tokio::test]
async fn post_409_vs_202_confirmed_high() {
    let addr = spawn().await;
    let result = collect_until_verdict("/users-202", addr).await;
    assert_eq!(result.verdict, OracleVerdict::Confirmed);
    assert_eq!(result.severity, Some(Severity::High));
    assert!(result.label.is_some());
    assert!(result.rfc_basis.is_some());
}

// --- 409 vs 204 → Confirmed / High ---

#[tokio::test]
async fn post_409_vs_204_confirmed_high() {
    let addr = spawn().await;
    let result = collect_until_verdict("/users-204", addr).await;
    assert_eq!(result.verdict, OracleVerdict::Confirmed);
    assert_eq!(result.severity, Some(Severity::High));
    assert!(result.label.is_some());
    assert!(result.rfc_basis.is_some());
}

// --- 413 vs 404 → Confirmed / Medium ---

#[tokio::test]
async fn post_413_vs_404_confirmed_medium() {
    let addr = spawn().await;
    let result = collect_by_id("/payload/{id}", "42", "999", addr).await;
    assert_eq!(result.verdict, OracleVerdict::Confirmed);
    assert_eq!(result.severity, Some(Severity::Medium));
    assert!(result.label.is_some());
    assert!(result.rfc_basis.is_some());
}

// --- 411 vs 404 → Confirmed / Medium ---

#[tokio::test]
async fn post_411_vs_404_confirmed_medium() {
    let addr = spawn().await;
    let result = collect_by_id("/length-required/{id}", "42", "999", addr).await;
    assert_eq!(result.verdict, OracleVerdict::Confirmed);
    assert_eq!(result.severity, Some(Severity::Medium));
    assert!(result.label.is_some());
    assert!(result.rfc_basis.is_some());
}