parlov-output 0.8.0

Output formatters for parlov: SARIF, terminal table, and raw JSON.
Documentation
//! Output-format tests verifying that `ProbeContext`, `ExchangeContext`, and
//! `ChainProvenance` propagate through JSON, SARIF, and table renderers, and
//! that verbose-only fields appear only when populated.

use std::collections::BTreeMap;

use parlov_core::{OracleClass, OracleResult, OracleVerdict, Severity, Signal, SignalKind, Vector};

use crate::{
    render_scan_json, render_scan_sarif, render_scan_table, BodySamplesBundle, ChainProvenance,
    ExchangeContext, HeadersBundle, ProbeContext, ScanFinding,
};

const TARGET: &str = "http://x/y";

fn confirmed() -> OracleResult {
    OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::Confirmed,
        severity: Some(Severity::High),
        confidence: 85,
        impact_class: None,
        reasons: vec![],
        signals: vec![Signal {
            kind: SignalKind::StatusCodeDiff,
            evidence: "200 vs 404".into(),
            rfc_basis: None,
        }],
        technique_id: Some("get-200-404".into()),
        vector: Some(Vector::StatusCodeDiff),
        normative_strength: None,
        label: None,
        leaks: None,
        rfc_basis: None,
    }
}

fn make_finding(
    verbose: bool,
    chain_provenance: Option<ChainProvenance>,
    verdict: OracleVerdict,
) -> ScanFinding {
    let mut headers = BTreeMap::new();
    headers.insert("if-match".to_owned(), "W/\"v1\"".to_owned());
    let probe_headers = if verbose {
        Some(HeadersBundle {
            baseline: headers.clone(),
            probe: headers,
        })
    } else {
        None
    };
    let resp_headers = if verbose {
        let mut h = BTreeMap::new();
        h.insert(
            "www-authenticate".to_owned(),
            "Bearer realm=\"api\"".to_owned(),
        );
        Some(HeadersBundle {
            baseline: h.clone(),
            probe: h,
        })
    } else {
        None
    };
    let body_samples = if verbose {
        Some(BodySamplesBundle {
            baseline: "small body".to_owned(),
            probe: "small body".to_owned(),
        })
    } else {
        None
    };
    let mut result = confirmed();
    result.verdict = verdict;
    ScanFinding {
        target_url: TARGET.to_owned(),
        strategy_id: "if-match-elicit".to_owned(),
        strategy_name: "If-Match elicitation".to_owned(),
        method: "PATCH".to_owned(),
        result,
        repro: None,
        probe: ProbeContext {
            baseline_url: format!("{TARGET}/1"),
            probe_url: format!("{TARGET}/9999"),
            method: "PATCH".to_owned(),
            headers: probe_headers,
        },
        exchange: ExchangeContext {
            baseline_status: 401,
            probe_status: 401,
            headers: resp_headers,
            body_samples,
        },
        chain_provenance,
    }
}

fn etag_provenance() -> ChainProvenance {
    ChainProvenance {
        producer_kind: "Etag".to_owned(),
        producer_value: "W/\"abc\"".to_owned(),
    }
}

#[test]
fn json_finding_includes_probe_and_exchange_default() {
    let findings = vec![make_finding(false, None, OracleVerdict::Confirmed)];
    let json = render_scan_json(TARGET, &findings).expect("render");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    let f = &v["findings"][0];
    assert_eq!(f["probe"]["method"], "PATCH");
    assert_eq!(f["probe"]["baseline_url"], format!("{TARGET}/1"));
    assert_eq!(f["probe"]["probe_url"], format!("{TARGET}/9999"));
    assert_eq!(f["exchange"]["baseline_status"], 401);
    assert_eq!(f["exchange"]["probe_status"], 401);
    assert!(
        f["probe"].get("headers").is_none(),
        "headers should be omitted by default"
    );
    assert!(
        f["exchange"].get("body_samples").is_none(),
        "body_samples should be omitted by default"
    );
}

#[test]
fn json_phase2_finding_includes_chain_provenance() {
    let findings = vec![make_finding(
        false,
        Some(etag_provenance()),
        OracleVerdict::Confirmed,
    )];
    let json = render_scan_json(TARGET, &findings).expect("render");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    let prov = &v["findings"][0]["chain_provenance"];
    assert_eq!(prov["producer_kind"], "Etag");
    assert_eq!(prov["producer_value"], "W/\"abc\"");
}

#[test]
fn json_phase1_finding_omits_chain_provenance() {
    let findings = vec![make_finding(false, None, OracleVerdict::Confirmed)];
    let json = render_scan_json(TARGET, &findings).expect("render");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    assert!(v["findings"][0].get("chain_provenance").is_none());
}

#[test]
fn json_verbose_finding_includes_filtered_headers() {
    let findings = vec![make_finding(true, None, OracleVerdict::Confirmed)];
    let json = render_scan_json(TARGET, &findings).expect("render");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    let baseline_hdrs = &v["findings"][0]["probe"]["headers"]["baseline"];
    assert_eq!(baseline_hdrs["if-match"], "W/\"v1\"");
}

#[test]
fn json_verbose_finding_includes_body_samples() {
    let findings = vec![make_finding(true, None, OracleVerdict::Confirmed)];
    let json = render_scan_json(TARGET, &findings).expect("render");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    let samples = &v["findings"][0]["exchange"]["body_samples"];
    assert_eq!(samples["baseline"], "small body");
}

#[test]
fn json_schema_version_is_1_2_0() {
    let findings = vec![make_finding(false, None, OracleVerdict::Confirmed)];
    let json = render_scan_json(TARGET, &findings).expect("render");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    assert_eq!(v["schema_version"], "1.2.0");
}

#[test]
fn sarif_finding_has_probe_and_exchange_properties() {
    let findings = vec![make_finding(false, None, OracleVerdict::Confirmed)];
    let json = render_scan_sarif(TARGET, &findings).expect("render");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    let props = &v["runs"][0]["results"][0]["properties"];
    assert_eq!(props["probe"]["method"], "PATCH");
    assert_eq!(props["exchange"]["baseline_status"], 401);
}

#[test]
fn sarif_phase2_finding_has_chain_provenance_property() {
    let findings = vec![make_finding(
        false,
        Some(etag_provenance()),
        OracleVerdict::Confirmed,
    )];
    let json = render_scan_sarif(TARGET, &findings).expect("render");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    let props = &v["runs"][0]["results"][0]["properties"];
    assert_eq!(props["chain_provenance"]["producer_kind"], "Etag");
}

#[test]
fn table_renders_probe_row_under_non_not_present_finding() {
    let findings = vec![make_finding(false, None, OracleVerdict::Confirmed)];
    let table = render_scan_table(&findings);
    assert!(
        table.contains("Probe: PATCH"),
        "probe row missing:\n{table}"
    );
}

#[test]
fn table_does_not_render_probe_row_under_not_present_finding() {
    let findings = vec![make_finding(false, None, OracleVerdict::NotPresent)];
    let table = render_scan_table(&findings);
    assert!(
        !table.contains("Probe: PATCH"),
        "probe row should suppress:\n{table}"
    );
}

#[test]
fn table_renders_chain_row_when_provenance_present() {
    let findings = vec![make_finding(
        false,
        Some(etag_provenance()),
        OracleVerdict::Confirmed,
    )];
    let table = render_scan_table(&findings);
    assert!(
        table.contains("Chain: derived from Etag="),
        "chain row missing:\n{table}"
    );
}

#[test]
fn table_verbose_adds_headers_section() {
    let findings = vec![make_finding(true, None, OracleVerdict::Confirmed)];
    let table = render_scan_table(&findings);
    assert!(
        table.contains("Request headers (baseline)"),
        "verbose request header section missing:\n{table}"
    );
    assert!(
        table.contains("if-match"),
        "filtered if-match value should appear:\n{table}"
    );
}