parlov 0.8.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
Documentation
use super::*;
use parlov_elicit::RiskLevel;
use parlov_probe::http::HttpProbe;

#[test]
fn build_scan_context_target_matches_and_risk_defaults_to_safe() {
    let args = minimal_args("https://api.example.com/users/{id}", "1001");
    let ctx = build_scan_context(&args, &[]).unwrap();
    assert_eq!(ctx.target, "https://api.example.com/users/{id}");
    assert_eq!(ctx.max_risk, RiskLevel::Safe);
}

#[test]
fn spec_strategy_id_returns_pair_strategy_id() {
    use http::{HeaderMap, Method};
    use parlov_core::{
        always_applicable, NormativeStrength, OracleClass, ProbeDefinition, SignalSurface,
        Technique, Vector,
    };
    use parlov_elicit::{ProbePair, StrategyMetadata};

    let pair = ProbePair {
        baseline: ProbeDefinition {
            url: "https://example.com/1".to_owned(),
            method: Method::GET,
            headers: HeaderMap::new(),
            body: None,
        },
        probe: ProbeDefinition {
            url: "https://example.com/2".to_owned(),
            method: Method::GET,
            headers: HeaderMap::new(),
            body: None,
        },
        canonical_baseline: None,
        metadata: StrategyMetadata {
            strategy_id: "auth-strip-elicit",
            strategy_name: "Auth Strip Elicitation",
            risk: RiskLevel::Safe,
        },
        technique: Technique {
            id: "auth-strip",
            name: "Auth strip",
            oracle_class: OracleClass::Existence,
            vector: Vector::StatusCodeDiff,
            strength: NormativeStrength::Should,
            normalization_weight: Some(0.2),
            inverted_signal_weight: None,
            method_relevant: false,
            parser_relevant: false,
            applicability: always_applicable,
            contradiction_surface: SignalSurface::Status,
        },
        chain_provenance: None,
    };
    let spec = ProbeSpec::Pair(pair);
    assert_eq!(spec_strategy_id(&spec), "auth-strip-elicit");
}

#[test]
fn harvest_allowlist_admits_h1_h2_and_h3_classes() {
    use http::{HeaderMap, HeaderValue, StatusCode};
    use parlov_core::ResponseClass;

    // H1: Success class is admitted
    assert!(matches!(
        ResponseClass::classify(StatusCode::OK, &HeaderMap::new()),
        ResponseClass::Success
    ));

    // H2: Redirect with Location is admitted
    let mut redirect_headers = HeaderMap::new();
    redirect_headers.insert(
        http::header::LOCATION,
        HeaderValue::from_static("https://example.com/new"),
    );
    assert!(matches!(
        ResponseClass::classify(StatusCode::MOVED_PERMANENTLY, &redirect_headers),
        ResponseClass::Redirect
    ));

    // H3: 206 Partial Content is admitted
    assert!(matches!(
        ResponseClass::classify(StatusCode::PARTIAL_CONTENT, &HeaderMap::new()),
        ResponseClass::PartialContent
    ));

    // H3: 416 Range Not Satisfiable is admitted
    assert!(matches!(
        ResponseClass::classify(StatusCode::RANGE_NOT_SATISFIABLE, &HeaderMap::new()),
        ResponseClass::RangeNotSatisfiable
    ));

    // Auth-challenge (401) is not in the allowlist
    assert!(!matches!(
        ResponseClass::classify(StatusCode::UNAUTHORIZED, &HeaderMap::new()),
        ResponseClass::Success
            | ResponseClass::Redirect
            | ResponseClass::PartialContent
            | ResponseClass::RangeNotSatisfiable
    ));

    // Server error (500) is not in the allowlist
    assert!(!matches!(
        ResponseClass::classify(StatusCode::INTERNAL_SERVER_ERROR, &HeaderMap::new()),
        ResponseClass::Success
            | ResponseClass::Redirect
            | ResponseClass::PartialContent
            | ResponseClass::RangeNotSatisfiable
    ));

    // 3xx without Location is Other — not admitted
    assert!(!matches!(
        ResponseClass::classify(StatusCode::MOVED_PERMANENTLY, &HeaderMap::new()),
        ResponseClass::Success
            | ResponseClass::Redirect
            | ResponseClass::PartialContent
            | ResponseClass::RangeNotSatisfiable
    ));
}

/// Verifies `run_plan_specs` returns `Vec<(ResponseClass, HeaderMap, Bytes)>` and that
/// an empty plan yields an empty exchange vec with no stop decision.
#[tokio::test]
async fn run_plan_specs_returns_exchanges_on_empty_plan() {
    use bytes::Bytes;
    use parlov_core::ResponseClass;
    let mut state = ScanPipelineState::new(0);
    let stop_rule = StopRule::new();
    let probe = HttpProbe::new();
    let exchanges: Vec<(ResponseClass, http::HeaderMap, Bytes)> = run_plan_specs(
        &[],
        "https://example.com/{id}",
        &mut state,
        &stop_rule,
        &probe,
        crate::scan_exec::RunOpts {
            exhaustive: false,
            confirm_threshold: CONFIRM_LOG_ODDS_THRESHOLD,
            repro: false,
            verbose: false,
        },
    )
    .await;
    assert!(exchanges.is_empty());
    assert!(state.findings.is_empty());
    assert!(state.stop_decision.is_none());
}

// Issue #2: strategies_total must be updatable to account for phase-2 chained specs.
// This test validates the arithmetic path: after constructing with 5 and adding 3,
// strategies_total is 8.
#[test]
fn pipeline_state_strategies_total_accumulates_phase2() {
    let mut state = ScanPipelineState::new(5);
    assert_eq!(state.strategies_total, 5);
    state.strategies_total += 3;
    assert_eq!(
        state.strategies_total, 8,
        "strategies_total must accumulate phase-2 chained spec count"
    );
}