parlov-analysis 0.5.0

Analysis engine trait and signal detection for parlov.
Documentation
//! Unit tests for the scoring pipeline.

use super::*;
use parlov_core::{ImpactClass, NormativeStrength, OracleVerdict, Severity, Signal, SignalKind};

fn pattern(base_confidence: u8, base_impact: u8) -> PatternMatch {
    PatternMatch {
        base_confidence,
        base_impact,
        label: Some("Test pattern"),
        leaks: Some("Test leak"),
        rfc_basis: Some("RFC 9110"),
    }
}

#[test]
fn zero_base_returns_not_present() {
    let p = PatternMatch {
        base_confidence: 0,
        base_impact: 0,
        label: None,
        leaks: None,
        rfc_basis: None,
    };
    let out = compute(&p, &[], NormativeStrength::Must, 404);
    assert_eq!(out.verdict, OracleVerdict::NotPresent);
    assert_eq!(out.confidence, 0);
    assert!(out.severity.is_none());
}

#[test]
fn high_base_confidence_yields_confirmed() {
    let out = compute(&pattern(90, 50), &[], NormativeStrength::Must, 200);
    assert_eq!(out.verdict, OracleVerdict::Confirmed);
    assert_eq!(out.confidence, 90);
}

#[test]
fn moderate_base_confidence_yields_likely() {
    let out = compute(&pattern(70, 35), &[], NormativeStrength::Must, 403);
    assert_eq!(out.verdict, OracleVerdict::Likely);
    assert_eq!(out.confidence, 70);
}

#[test]
fn low_base_confidence_yields_not_present() {
    let out = compute(&pattern(40, 15), &[], NormativeStrength::Must, 418);
    assert_eq!(out.verdict, OracleVerdict::NotPresent);
    assert_eq!(out.confidence, 40);
}

#[test]
fn confirmed_verdict_gets_full_severity() {
    let out = compute(&pattern(90, 50), &[], NormativeStrength::Must, 200);
    assert_eq!(out.severity, Some(Severity::Medium));
}

#[test]
fn likely_verdict_caps_severity_one_below() {
    let out = compute(&pattern(70, 50), &[], NormativeStrength::Must, 200);
    assert_eq!(out.severity, Some(Severity::Low));
}

#[test]
fn signals_add_to_confidence() {
    let signals = vec![Signal {
        kind: SignalKind::HeaderPresence,
        evidence: "etag present in baseline, absent in probe".into(),
        rfc_basis: None,
    }];
    let out = compute(&pattern(75, 35), &signals, NormativeStrength::Must, 403);
    assert!(out.confidence >= 80);
    assert_eq!(out.verdict, OracleVerdict::Confirmed);
}

#[test]
fn confidence_clamped_to_100() {
    let signals = vec![
        Signal {
            kind: SignalKind::HeaderPresence,
            evidence: "etag present in baseline".into(),
            rfc_basis: None,
        },
        Signal {
            kind: SignalKind::HeaderPresence,
            evidence: "content-range present in baseline".into(),
            rfc_basis: None,
        },
        Signal {
            kind: SignalKind::MetadataLeak,
            evidence: "Content-Range leaks total resource size: 500 bytes".into(),
            rfc_basis: None,
        },
    ];
    let out = compute(&pattern(90, 55), &signals, NormativeStrength::Must, 206);
    assert!(out.confidence <= 100);
}

#[test]
fn content_range_size_leak_yields_high_impact() {
    let signals = vec![Signal {
        kind: SignalKind::MetadataLeak,
        evidence: "Content-Range leaks total resource size: 1024 bytes".into(),
        rfc_basis: None,
    }];
    let out = compute(&pattern(85, 55), &signals, NormativeStrength::Must, 206);
    assert_eq!(out.impact_class, Some(ImpactClass::High));
}

#[test]
fn verdict_from_confidence_thresholds() {
    assert_eq!(verdict_from_confidence(100), OracleVerdict::Confirmed);
    assert_eq!(verdict_from_confidence(80), OracleVerdict::Confirmed);
    assert_eq!(verdict_from_confidence(79), OracleVerdict::Likely);
    assert_eq!(verdict_from_confidence(60), OracleVerdict::Likely);
    assert_eq!(verdict_from_confidence(59), OracleVerdict::NotPresent);
    assert_eq!(verdict_from_confidence(0), OracleVerdict::NotPresent);
}

// --- Body-diff scoring gate tests ---

fn zero_pattern() -> PatternMatch {
    PatternMatch {
        base_confidence: 0,
        base_impact: 0,
        label: None,
        leaks: None,
        rfc_basis: None,
    }
}

fn body_content_signal() -> Signal {
    Signal {
        kind: SignalKind::BodyDiff,
        evidence: "body length: 27 (baseline) vs 45 (probe)".into(),
        rfc_basis: None,
    }
}

fn body_content_type_signal() -> Signal {
    Signal {
        kind: SignalKind::BodyDiff,
        evidence: "content-type: application/json (baseline) vs text/html (probe)".into(),
        rfc_basis: None,
    }
}

#[test]
fn same_status_no_signals_still_not_present() {
    let out = compute(&zero_pattern(), &[], NormativeStrength::Must, 403);
    assert_eq!(out.verdict, OracleVerdict::NotPresent);
    assert_eq!(out.confidence, 0);
    assert!(out.severity.is_none());
}

#[test]
fn same_status_body_diff_may_alone_below_likely() {
    // 70 * 0.75 = 52.5 -> rounds to 53 -> below 60 -> NotPresent
    let signals = vec![body_content_signal()];
    let out = compute(&zero_pattern(), &signals, NormativeStrength::May, 403);
    assert_eq!(out.confidence, 53);
    assert_eq!(out.verdict, OracleVerdict::NotPresent);
}

#[test]
fn same_status_body_diff_should_reaches_likely() {
    // 70 * 0.9 = 63.0 -> rounds to 63 -> >= 60 -> Likely
    let signals = vec![body_content_signal()];
    let out = compute(&zero_pattern(), &signals, NormativeStrength::Should, 403);
    assert_eq!(out.confidence, 63);
    assert_eq!(out.verdict, OracleVerdict::Likely);
    assert!(out.severity.is_some());
}

#[test]
fn same_status_body_diff_plus_content_type_may_reaches_likely() {
    // Body content: 70 * 0.75 = 52.5 (pos 0, capped min(52.5, 75) = 52.5)
    // Content-type: 25 * 0.75 = 18.75 (pos 1, min(18.75, 75) * 0.5 = 9.375)
    // Total: 52.5 + 9.375 = 61.875 -> rounds to 62 -> >= 60 -> Likely
    let signals = vec![body_content_signal(), body_content_type_signal()];
    let out = compute(&zero_pattern(), &signals, NormativeStrength::May, 403);
    assert_eq!(out.confidence, 62);
    assert_eq!(out.verdict, OracleVerdict::Likely);
}

#[test]
fn status_diff_plus_body_diff_attenuated() {
    // Body diff is attenuated 0.25x when status codes differ:
    // base 65 + body (70 * 0.75 May * 0.25 attenuation = 13.125) = 78.125 -> 78 -> Likely
    let signals = vec![body_content_signal()];
    let out = compute(&pattern(65, 25), &signals, NormativeStrength::May, 400);
    assert_eq!(out.confidence, 78);
    assert_eq!(out.verdict, OracleVerdict::Likely);
}