parlov-output 0.8.0

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

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

fn confirmed_with_metadata() -> OracleResult {
    OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::Confirmed,
        severity: Some(Severity::High),
        confidence: 0,
        impact_class: None,
        reasons: vec![],
        signals: vec![Signal {
            kind: SignalKind::StatusCodeDiff,
            evidence: "403 (baseline) vs 404 (probe)".into(),
            rfc_basis: None,
        }],
        technique_id: None,
        vector: None,
        normative_strength: None,
        label: Some("Authorization-based differential".into()),
        leaks: Some("Resource existence confirmed".into()),
        rfc_basis: Some("RFC 9110 \u{00a7}15.5.4".into()),
    }
}

fn not_present_no_metadata() -> OracleResult {
    OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::NotPresent,
        severity: None,
        confidence: 0,
        impact_class: None,
        reasons: vec![],
        signals: vec![Signal {
            kind: SignalKind::StatusCodeDiff,
            evidence: "404 (baseline) vs 404 (probe)".into(),
            rfc_basis: None,
        }],
        technique_id: None,
        vector: None,
        normative_strength: None,
        label: None,
        leaks: None,
        rfc_basis: None,
    }
}

#[test]
fn table_includes_label_when_present() {
    let table = render_table(&confirmed_with_metadata());
    assert!(table.contains("Authorization-based differential"));
}

#[test]
fn table_includes_leaks_when_present() {
    let table = render_table(&confirmed_with_metadata());
    assert!(table.contains("Resource existence confirmed"));
}

#[test]
fn table_includes_rfc_basis_when_present() {
    let table = render_table(&confirmed_with_metadata());
    assert!(table.contains("RFC 9110 \u{00a7}15.5.4"));
}

#[test]
fn table_omits_label_row_when_none() {
    let table = render_table(&not_present_no_metadata());
    assert!(!table.contains("Label"));
}

#[test]
fn table_omits_leaks_row_when_none() {
    let table = render_table(&not_present_no_metadata());
    assert!(!table.contains("Leaks"));
}

#[test]
fn table_omits_rfc_basis_row_when_none() {
    let table = render_table(&not_present_no_metadata());
    assert!(!table.contains("RFC Basis"));
}

#[test]
fn table_shows_primary_evidence_from_signals() {
    let table = render_table(&confirmed_with_metadata());
    assert!(table.contains("403 (baseline) vs 404 (probe)"));
}

#[test]
fn scan_table_contains_strategy_name_and_method() {
    let findings = vec![crate::ScanFinding {
        target_url: "https://api.example.com/users/1".to_owned(),
        strategy_id: "accept-elicit".to_owned(),
        strategy_name: "Accept header elicitation".to_owned(),
        method: "GET".to_owned(),
        result: not_present_no_metadata(),
        repro: None,
        probe: probe_ctx(
            "GET",
            "https://api.example.com/users/1",
            "https://api.example.com/users/9999",
        ),
        exchange: exchange_ctx(404, 404),
        chain_provenance: None,
    }];
    let table = render_scan_table(&findings);
    assert!(table.contains("Accept header elicitation"));
    assert!(table.contains("GET"));
}

#[test]
fn scan_table_confirmed_verdict_appears() {
    let findings = vec![crate::ScanFinding {
        target_url: "https://api.example.com/users/1".to_owned(),
        strategy_id: "s1".to_owned(),
        strategy_name: "test".to_owned(),
        method: "GET".to_owned(),
        result: confirmed_with_metadata(),
        repro: None,
        probe: probe_ctx(
            "GET",
            "https://api.example.com/users/1",
            "https://api.example.com/users/9999",
        ),
        exchange: exchange_ctx(403, 404),
        chain_provenance: None,
    }];
    let table = render_scan_table(&findings);
    assert!(table.contains("Confirmed"));
}

#[test]
fn scan_table_empty_findings_no_panic() {
    let table = render_scan_table(&[]);
    assert!(!table.is_empty());
}

// --- render_endpoint_verdict_table tests ---

fn confirmed_endpoint_verdict() -> parlov_core::EndpointVerdict {
    use parlov_core::{
        EndpointStopReason, EndpointVerdict, ObservabilityStatus, OracleClass, OracleVerdict,
        Severity,
    };
    EndpointVerdict {
        oracle_class: OracleClass::Existence,
        posterior_probability: 0.87,
        verdict: OracleVerdict::Confirmed,
        severity: Some(Severity::High),
        strategies_run: 8,
        strategies_total: 20,
        stop_reason: Some(EndpointStopReason::EarlyAccept),
        first_threshold_crossed_by: None,
        final_confirming_strategy: None,
        contributing_findings: vec![],
        observability_status: ObservabilityStatus::EvidenceObserved,
        block_summary: None,
    }
}

fn inconclusive_endpoint_verdict() -> parlov_core::EndpointVerdict {
    use parlov_core::{EndpointVerdict, ObservabilityStatus, OracleClass, OracleVerdict};
    EndpointVerdict {
        oracle_class: OracleClass::Existence,
        posterior_probability: 0.50,
        verdict: OracleVerdict::Inconclusive,
        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: ObservabilityStatus::Underpowered,
        block_summary: None,
    }
}

#[test]
fn endpoint_table_contains_posterior_percentage() {
    let table = render_endpoint_verdict_table(&confirmed_endpoint_verdict(), &[]);
    assert!(
        table.contains("87%"),
        "expected posterior percentage in output, got:\n{table}"
    );
}

#[test]
fn endpoint_table_contains_verdict_string() {
    let table = render_endpoint_verdict_table(&confirmed_endpoint_verdict(), &[]);
    assert!(
        table.contains("Confirmed"),
        "expected verdict string in output, got:\n{table}"
    );
}

#[test]
fn endpoint_table_contains_stop_reason_when_set() {
    let table = render_endpoint_verdict_table(&confirmed_endpoint_verdict(), &[]);
    assert!(
        table.contains("EarlyAccept"),
        "expected stop_reason in output, got:\n{table}"
    );
}

#[test]
fn endpoint_table_no_stop_reason_shows_dash() {
    let table = render_endpoint_verdict_table(&inconclusive_endpoint_verdict(), &[]);
    // stop_reason is None, so the summary should show em-dash
    assert!(table.contains('\u{2014}') || table.contains(""));
}

// Issue #6: first column header must be "Oracle / Strategy" so the endpoint summary row
// and per-strategy rows both make sense under the same header.
#[test]
fn endpoint_table_first_column_header_is_oracle_slash_strategy() {
    let table = render_endpoint_verdict_table(&confirmed_endpoint_verdict(), &[]);
    assert!(
        table.contains("Oracle / Strategy"),
        "expected 'Oracle / Strategy' column header, got:\n{table}"
    );
}

fn blocked_endpoint_verdict() -> parlov_core::EndpointVerdict {
    use parlov_core::{
        BlockFamily, BlockSummary, EndpointVerdict, ObservabilityStatus, OracleClass, OracleVerdict,
    };
    EndpointVerdict {
        oracle_class: OracleClass::Existence,
        posterior_probability: 0.50,
        verdict: OracleVerdict::Inconclusive,
        severity: None,
        strategies_run: 8,
        strategies_total: 8,
        stop_reason: None,
        first_threshold_crossed_by: None,
        final_confirming_strategy: None,
        contributing_findings: vec![],
        observability_status: ObservabilityStatus::BlockedBeforeOracleLayer,
        block_summary: Some(BlockSummary {
            expected_observation_opportunities: 8,
            blocked_before_oracle_layer: 8,
            blocked_fraction: 1.0,
            dominant_block_family: BlockFamily::Authorization,
            dominant_block_reasons: vec![
                "auth gate fired before technique (no credential provided)".to_owned(),
            ],
            operator_action: Some(
                r#"Retry with --header "Authorization: Bearer <token>""#.to_owned(),
            ),
        }),
    }
}

#[test]
fn blocked_endpoint_table_contains_observability_label() {
    let table = render_endpoint_verdict_table(&blocked_endpoint_verdict(), &[]);
    assert!(
        table.contains("Observability"),
        "expected 'Observability' label in blocked verdict table, got:\n{table}"
    );
}

#[test]
fn blocked_endpoint_table_contains_blocked_before_oracle_layer() {
    let table = render_endpoint_verdict_table(&blocked_endpoint_verdict(), &[]);
    assert!(
        table.contains("blocked before oracle layer") || table.contains("BlockedBeforeOracleLayer"),
        "expected blocked-before-oracle-layer status in table, got:\n{table}"
    );
}

#[test]
fn blocked_endpoint_table_contains_blocked_fraction() {
    let table = render_endpoint_verdict_table(&blocked_endpoint_verdict(), &[]);
    // BlockSummary has blocked=8, opportunities=8 → "8/8"
    assert!(
        table.contains("8/8"),
        "expected blocked fraction '8/8' in table, got:\n{table}"
    );
}

#[test]
fn blocked_endpoint_table_contains_action_row() {
    let table = render_endpoint_verdict_table(&blocked_endpoint_verdict(), &[]);
    assert!(
        table.contains("Action"),
        "expected 'Action' row in blocked verdict table, got:\n{table}"
    );
}