parlov-output 0.8.0

Output formatters for parlov: SARIF, terminal table, and raw JSON.
Documentation
use super::*;
use parlov_core::{OracleClass, OracleResult, OracleVerdict, Severity, Signal, SignalKind};

use crate::context::test_helpers::{exchange_ctx, probe_ctx};

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: "403 (baseline) vs 404 (probe)".into(),
            rfc_basis: None,
        }],
        technique_id: Some("test-tech".into()),
        vector: Some(Vector::StatusCodeDiff),
        normative_strength: Some(NormativeStrength::Should),
        label: Some("Auth differential".into()),
        leaks: Some("Resource existence".into()),
        rfc_basis: Some("RFC 9110".into()),
    }
}

fn not_present() -> OracleResult {
    OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::NotPresent,
        severity: None,
        confidence: 0,
        impact_class: None,
        reasons: vec![],
        signals: vec![],
        technique_id: None,
        vector: None,
        normative_strength: None,
        label: None,
        leaks: None,
        rfc_basis: None,
    }
}

#[test]
fn single_json_has_schema_version_and_target() {
    let json =
        render_json("https://x.com/api", &confirmed(), "s1", "n1", "GET").expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    assert_eq!(v["schema_version"], "1.2.0");
    assert_eq!(v["target_url"], "https://x.com/api");
}

#[test]
fn single_json_has_finding_id_12_hex() {
    let json =
        render_json("https://x.com", &confirmed(), "s1", "n1", "GET").expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    let fid = v["finding"]["finding_id"].as_str().expect("string");
    assert_eq!(fid.len(), 12);
    assert!(fid.chars().all(|c| c.is_ascii_hexdigit()));
}

#[test]
fn single_json_omits_none_matched_pattern_fields() {
    let json =
        render_json("https://x.com", &not_present(), "s1", "n1", "GET").expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    let mp = &v["finding"]["matched_pattern"];
    assert!(mp.get("label").is_none());
    assert!(mp.get("leaks").is_none());
}

#[test]
fn scan_json_uses_findings_array() {
    let findings = vec![ScanFinding {
        target_url: "https://x.com".to_owned(),
        strategy_id: "s1".to_owned(),
        strategy_name: "n1".to_owned(),
        method: "GET".to_owned(),
        result: confirmed(),
        repro: None,
        probe: probe_ctx("GET", "https://x.com", "https://x.com/9999"),
        exchange: exchange_ctx(200, 404),
        chain_provenance: None,
    }];
    let json = render_scan_json("https://x.com", &findings).expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    assert!(v["findings"].is_array());
    assert_eq!(v["findings"].as_array().expect("array").len(), 1);
}

#[test]
fn scan_json_empty_findings_produces_empty_array() {
    let json = render_scan_json("https://x.com", &[]).expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    assert!(v["findings"].as_array().expect("array").is_empty());
}

// --- render_endpoint_verdict_json tests ---

fn confirmed_verdict() -> parlov_core::EndpointVerdict {
    use parlov_core::{
        ContributingFinding, EndpointStopReason, EndpointVerdict, OracleClass, OracleVerdict,
        Severity, StrategyOutcomeKind,
    };
    EndpointVerdict {
        oracle_class: OracleClass::Existence,
        posterior_probability: 0.92,
        verdict: OracleVerdict::Confirmed,
        severity: Some(Severity::High),
        strategies_run: 10,
        strategies_total: 20,
        stop_reason: Some(EndpointStopReason::EarlyAccept),
        first_threshold_crossed_by: None,
        final_confirming_strategy: None,
        contributing_findings: vec![ContributingFinding {
            strategy_id: "existence-get-200-404".to_owned(),
            strategy_name: "GET 200/404 existence".to_owned(),
            outcome_kind: StrategyOutcomeKind::Positive,
            log_odds_contribution: 2.77,
            block_family: None,
            block_reason: None,
        }],
        observability_status: parlov_core::ObservabilityStatus::EvidenceObserved,
        block_summary: None,
    }
}

fn not_present_verdict() -> parlov_core::EndpointVerdict {
    use parlov_core::{EndpointVerdict, OracleClass, OracleVerdict};
    EndpointVerdict {
        oracle_class: OracleClass::Existence,
        posterior_probability: 0.10,
        verdict: OracleVerdict::NotPresent,
        severity: None,
        strategies_run: 5,
        strategies_total: 5,
        stop_reason: None,
        first_threshold_crossed_by: None,
        final_confirming_strategy: None,
        contributing_findings: vec![],
        observability_status: parlov_core::ObservabilityStatus::ProbedNoEvidence,
        block_summary: None,
    }
}

#[test]
fn endpoint_verdict_json_has_endpoint_verdict_at_root() {
    let json = render_endpoint_verdict_json("https://x.com/api", &confirmed_verdict(), &[])
        .expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    assert!(
        v.get("endpoint_verdict").is_some(),
        "missing endpoint_verdict key"
    );
}

#[test]
fn endpoint_verdict_json_verdict_is_confirmed_string() {
    let json = render_endpoint_verdict_json("https://x.com/api", &confirmed_verdict(), &[])
        .expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    assert_eq!(v["endpoint_verdict"]["verdict"], "Confirmed");
}

#[test]
fn endpoint_verdict_json_posterior_probability_present() {
    let json = render_endpoint_verdict_json("https://x.com/api", &confirmed_verdict(), &[])
        .expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    let p = v["endpoint_verdict"]["posterior_probability"]
        .as_f64()
        .expect("f64");
    assert!((p - 0.92).abs() < 1e-9);
}

#[test]
fn endpoint_verdict_json_findings_array_present() {
    let findings = vec![ScanFinding {
        target_url: "https://x.com/api".to_owned(),
        strategy_id: "s1".to_owned(),
        strategy_name: "n1".to_owned(),
        method: "GET".to_owned(),
        result: confirmed(),
        repro: None,
        probe: probe_ctx("GET", "https://x.com/api", "https://x.com/api/9999"),
        exchange: exchange_ctx(200, 404),
        chain_provenance: None,
    }];
    let json = render_endpoint_verdict_json("https://x.com/api", &confirmed_verdict(), &findings)
        .expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    assert!(v["findings"].is_array());
    assert_eq!(v["findings"].as_array().expect("array").len(), 1);
}

#[test]
fn endpoint_verdict_json_stop_reason_absent_when_none() {
    let json = render_endpoint_verdict_json("https://x.com/api", &not_present_verdict(), &[])
        .expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    assert!(
        v["endpoint_verdict"].get("stop_reason").is_none(),
        "stop_reason should be absent when None"
    );
}

#[test]
fn endpoint_verdict_json_stop_reason_present_when_set() {
    let json = render_endpoint_verdict_json("https://x.com/api", &confirmed_verdict(), &[])
        .expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    assert_eq!(v["endpoint_verdict"]["stop_reason"], "EarlyAccept");
}

// Issue #7: render_endpoint_verdict_json must stamp the current schema_version
// (bumped to 1.2.0 when observability_status + block_summary fields were added).
#[test]
fn endpoint_verdict_json_has_schema_version() {
    let json = render_endpoint_verdict_json("https://x.com/api", &confirmed_verdict(), &[])
        .expect("serialization");
    let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
    assert_eq!(
        v["schema_version"], "1.2.0",
        "schema_version must be 1.2.0 in render_endpoint_verdict_json output"
    );
}