parlov-analysis 0.7.0

Analysis engine trait and signal detection for parlov.
Documentation
use super::*;
use parlov_core::{ImpactClass, Signal, SignalKind};

// -- Likely / Low (weak patterns, base_confidence 65) --

#[test]
fn payment_required_vs_not_found() {
    assert_pattern(
        402,
        404,
        OracleVerdict::Likely,
        Some(Severity::Low),
        Some("Payment-gate differential"),
        true,
        Some("RFC 9110 §15.5.3"),
    );
}

#[test]
fn bad_request_vs_created() {
    assert_pattern(
        400,
        201,
        OracleVerdict::Likely,
        Some(Severity::Low),
        Some("Client-error creation differential"),
        true,
        Some("RFC 9110 §15.5.1"),
    );
}

#[test]
fn bad_request_vs_ok() {
    assert_pattern(
        400,
        200,
        OracleVerdict::Likely,
        Some(Severity::Low),
        Some("Client-error differential"),
        true,
        Some("RFC 9110 §15.5.1"),
    );
}

#[test]
fn too_many_requests_vs_not_found() {
    assert_pattern(
        429,
        404,
        OracleVerdict::Likely,
        Some(Severity::Low),
        Some("Rate-limit-based differential"),
        true,
        Some("RFC 6585 §4"),
    );
}

#[test]
fn multiple_choices_vs_not_found() {
    assert_pattern(
        300,
        404,
        OracleVerdict::Likely,
        Some(Severity::Low),
        Some("Multiple-choices differential"),
        true,
        Some("RFC 9110 §15.4.1"),
    );
}

// -- NotPresent (same status) --

#[test]
fn same_status_not_present() {
    assert_pattern(404, 404, OracleVerdict::NotPresent, None, None, false, None);
}

#[test]
fn same_status_200_not_present() {
    assert_pattern(200, 200, OracleVerdict::NotPresent, None, None, false, None);
}

// -- Unrecognised differential (below Likely threshold) --

#[test]
fn unrecognised_diff_not_present() {
    assert_pattern(418, 404, OracleVerdict::NotPresent, None, None, false, None);
}

#[test]
fn unrecognised_diff_503_vs_200() {
    assert_pattern(503, 200, OracleVerdict::NotPresent, None, None, false, None);
}

// -- Scoring field validation --

#[test]
fn classify_populates_confidence() {
    let b = StatusCode::from_u16(403).expect("valid status");
    let p = StatusCode::from_u16(404).expect("valid status");
    let technique = status_code_diff_technique();
    let r = classify(b, p, vec![], &technique);
    assert!(r.confidence >= 80);
    assert!(r.impact_class.is_some());
    assert!(!r.reasons.is_empty());
}

#[test]
fn classify_populates_technique_metadata() {
    let b = StatusCode::from_u16(403).expect("valid status");
    let p = StatusCode::from_u16(404).expect("valid status");
    let technique = status_code_diff_technique();
    let r = classify(b, p, vec![], &technique);
    assert_eq!(r.technique_id.as_deref(), Some("test-status-diff"));
    assert!(r.vector.is_some());
    assert!(r.normative_strength.is_some());
}

// -- Normative calibration via scoring --

#[test]
fn may_strength_reduces_signal_confidence() {
    use parlov_core::{
        always_applicable, NormativeStrength, OracleClass, SignalSurface, Technique, Vector,
    };

    let b = StatusCode::from_u16(403).expect("valid status");
    let p = StatusCode::from_u16(404).expect("valid status");
    let technique = Technique {
        id: "test-may",
        name: "May-level test",
        oracle_class: OracleClass::Existence,
        vector: Vector::StatusCodeDiff,
        strength: NormativeStrength::May,
        normalization_weight: Some(0.2),
        inverted_signal_weight: None,
        method_relevant: false,
        parser_relevant: false,
        applicability: always_applicable,
        contradiction_surface: SignalSurface::Status,
    };
    // May-strength techniques are capped at Likely even when base_confidence >= 80.
    // The normative gate prevents a single May-grounded technique from claiming Confirmed.
    let r = classify(b, p, vec![], &technique);
    assert_eq!(r.verdict, OracleVerdict::Likely);
}

#[test]
fn signals_can_push_likely_to_confirmed() {
    let b = StatusCode::from_u16(410).expect("valid status");
    let p = StatusCode::from_u16(404).expect("valid status");
    let technique = status_code_diff_technique();
    // 410/404 has base_confidence=80, right at threshold
    let r = classify(b, p, vec![], &technique);
    assert_eq!(r.verdict, OracleVerdict::Confirmed);

    // Adding a header signal should keep or strengthen Confirmed
    let signals = vec![Signal {
        kind: SignalKind::HeaderPresence,
        evidence: "etag present in baseline, absent in probe".into(),
        rfc_basis: None,
    }];
    let r2 = classify(b, p, signals, &technique);
    assert_eq!(r2.verdict, OracleVerdict::Confirmed);
    assert!(r2.confidence > r.confidence);
}

// -- Impact class from metadata --

#[test]
fn content_range_size_leak_produces_high_impact() {
    let b = StatusCode::from_u16(206).expect("valid status");
    let p = StatusCode::from_u16(404).expect("valid status");
    let technique = status_code_diff_technique();
    let signals = vec![Signal {
        kind: SignalKind::MetadataLeak,
        evidence: "Content-Range leaks total resource size: 1024 bytes".into(),
        rfc_basis: Some("RFC 9110 §14.4".into()),
    }];
    let r = classify(b, p, signals, &technique);
    assert_eq!(r.impact_class, Some(ImpactClass::High));
    assert_eq!(r.severity, Some(Severity::High));
}

#[test]
fn etag_metadata_produces_medium_impact() {
    let b = StatusCode::from_u16(200).expect("valid status");
    let p = StatusCode::from_u16(404).expect("valid status");
    let technique = status_code_diff_technique();
    let signals = vec![Signal {
        kind: SignalKind::MetadataLeak,
        evidence: "ETag value \"v2\" leaks resource version identifier".into(),
        rfc_basis: None,
    }];
    let r = classify(b, p, signals, &technique);
    assert_eq!(r.impact_class, Some(ImpactClass::Medium));
}

// -- Corroboration bonus --

#[test]
fn multiple_signal_families_add_corroboration() {
    let b = StatusCode::from_u16(206).expect("valid status");
    let p = StatusCode::from_u16(404).expect("valid status");
    let technique = status_code_diff_technique();

    let single = vec![Signal {
        kind: SignalKind::HeaderPresence,
        evidence: "content-range present in baseline".into(),
        rfc_basis: None,
    }];
    let r1 = classify(b, p, single, &technique);

    let multi = vec![
        Signal {
            kind: SignalKind::HeaderPresence,
            evidence: "content-range present in baseline".into(),
            rfc_basis: None,
        },
        Signal {
            kind: SignalKind::HeaderPresence,
            evidence: "etag present in baseline".into(),
            rfc_basis: None,
        },
    ];
    let r2 = classify(b, p, multi, &technique);
    assert!(r2.confidence >= r1.confidence);
}