parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
use super::*;
use crate::types::RiskLevel;
use crate::{KnownDuplicate, ScanContext, StateField};
use http::{HeaderMap, HeaderValue};
use parlov_core::Vector;

pub(super) fn make_minimal_ctx(max_risk: RiskLevel) -> ScanContext {
    ScanContext {
        target: "https://api.example.com/users/{id}".to_string(),
        baseline_id: "1001".to_string(),
        probe_id: "9999".to_string(),
        headers: HeaderMap::new(),
        max_risk,
        known_duplicate: None,
        state_field: None,
        alt_credential: None,
        body_template: None,
    }
}

pub(super) fn make_full_ctx() -> ScanContext {
    let mut headers = HeaderMap::new();
    headers.insert(
        http::header::AUTHORIZATION,
        HeaderValue::from_static("Bearer token123"),
    );
    let mut alt = HeaderMap::new();
    alt.insert(
        http::header::AUTHORIZATION,
        HeaderValue::from_static("Bearer under-scoped"),
    );
    ScanContext {
        target: "https://api.example.com/users/{id}".to_string(),
        baseline_id: "1001".to_string(),
        probe_id: "9999".to_string(),
        headers,
        max_risk: RiskLevel::OperationDestructive,
        known_duplicate: Some(KnownDuplicate {
            field: "email".to_string(),
            value: "alice@example.com".to_string(),
        }),
        state_field: Some(StateField {
            field: "status".to_string(),
            value: "invalid_state".to_string(),
        }),
        alt_credential: Some(alt),
        body_template: None,
    }
}

pub(super) fn spec_strategy_id(spec: &ProbeSpec) -> &str {
    match spec {
        ProbeSpec::Pair(p) | ProbeSpec::HeaderDiff(p) => p.metadata.strategy_id,
        ProbeSpec::Burst(b) => b.metadata.strategy_id,
    }
}

#[test]
fn all_strategies_returns_exactly_39() {
    assert_eq!(all_strategies().len(), 39);
}

#[test]
fn risk_distribution_is_26_10_3() {
    let s = all_strategies();
    let count = |r: RiskLevel| s.iter().filter(|x| x.risk() == r).count();
    assert_eq!(count(RiskLevel::Safe), 26);
    assert_eq!(count(RiskLevel::MethodDestructive), 10);
    assert_eq!(count(RiskLevel::OperationDestructive), 3);
}

#[test]
fn all_strategy_ids_are_unique() {
    let strategies = all_strategies();
    let mut ids: Vec<&str> = strategies.iter().map(|s| s.id()).collect();
    ids.sort_unstable();
    let before = ids.len();
    ids.dedup();
    assert_eq!(before, ids.len(), "duplicate strategy IDs detected");
}

#[test]
fn safe_filter_excludes_method_and_op_destructive() {
    let ctx = make_minimal_ctx(RiskLevel::Safe);
    let plan = generate_plan(&ctx);
    let safe_ids: std::collections::HashSet<&str> = all_strategies()
        .iter()
        .filter(|s| s.risk() == RiskLevel::Safe)
        .map(|s| s.id())
        .collect();
    for spec in &plan {
        assert!(
            safe_ids.contains(spec_strategy_id(spec)),
            "non-Safe strategy '{}' appeared in Safe plan",
            spec_strategy_id(spec)
        );
    }
}

#[test]
fn method_destructive_ceiling_excludes_op_destructive() {
    let ctx = make_minimal_ctx(RiskLevel::MethodDestructive);
    let plan = generate_plan(&ctx);
    for spec in &plan {
        assert_ne!(
            spec_strategy_id(spec),
            UniquenessElicitation.id(),
            "OperationDestructive strategy appeared in MethodDestructive plan"
        );
        assert_ne!(
            spec_strategy_id(spec),
            DependencyDeleteElicitation.id(),
            "OperationDestructive strategy appeared in MethodDestructive plan"
        );
        assert_ne!(
            spec_strategy_id(spec),
            RateLimitBurstElicitation.id(),
            "OperationDestructive strategy appeared in MethodDestructive plan"
        );
    }
}

#[test]
fn no_auth_excludes_auth_strip_and_low_privilege() {
    let ctx = make_minimal_ctx(RiskLevel::OperationDestructive);
    let plan = generate_plan(&ctx);
    for spec in &plan {
        assert_ne!(spec_strategy_id(spec), AuthStripElicitation.id());
        assert_ne!(spec_strategy_id(spec), LowPrivilegeElicitation.id());
    }
}

#[test]
fn no_alt_credential_excludes_scope_manipulation() {
    let ctx = make_minimal_ctx(RiskLevel::OperationDestructive);
    let plan = generate_plan(&ctx);
    for spec in &plan {
        assert_ne!(spec_strategy_id(spec), ScopeManipulationElicitation.id());
    }
}

#[test]
fn no_state_field_excludes_state_transition() {
    let ctx = make_minimal_ctx(RiskLevel::MethodDestructive);
    let plan = generate_plan(&ctx);
    for spec in &plan {
        assert_ne!(spec_strategy_id(spec), StateTransitionElicitation.id());
    }
}

#[test]
fn no_known_duplicate_excludes_uniqueness() {
    let ctx = make_minimal_ctx(RiskLevel::OperationDestructive);
    let plan = generate_plan(&ctx);
    for spec in &plan {
        assert_ne!(spec_strategy_id(spec), UniquenessElicitation.id());
    }
}

#[test]
fn full_plan_returns_at_least_37_specs() {
    let ctx = make_full_ctx();
    let plan = generate_plan(&ctx);
    // 39 total strategies; EmgStateConflict requires body_template so is excluded = 38
    // reachable; GET/HEAD strategies produce 2 specs each so actual count is higher
    assert!(plan.len() >= 37, "expected >= 37 specs, got {}", plan.len());
}

// --- applicable_strategies ---

#[test]
fn applicable_strategies_excludes_above_max_risk() {
    let strategies = all_strategies();
    let ctx = make_minimal_ctx(RiskLevel::Safe);
    let applicable: Vec<_> = applicable_strategies(&strategies, &ctx).collect();
    for s in &applicable {
        assert!(
            s.risk() <= RiskLevel::Safe,
            "strategy '{}' with risk {:?} must not appear in Safe-capped result",
            s.id(),
            s.risk(),
        );
    }
    // At least one strategy must have been excluded (MethodDestructive and OperationDestructive exist)
    let all_count = strategies.iter().filter(|s| s.is_applicable(&ctx)).count();
    assert!(
        applicable.len() < all_count || strategies.iter().any(|s| s.risk() > RiskLevel::Safe),
        "expected some strategies to be excluded by risk cap",
    );
}

#[test]
fn applicable_strategies_excludes_inapplicable() {
    let strategies = all_strategies();
    // ctx without auth headers: AuthStripElicitation and LowPrivilegeElicitation are inapplicable
    let ctx = make_minimal_ctx(RiskLevel::OperationDestructive);
    let applicable: Vec<_> = applicable_strategies(&strategies, &ctx).collect();
    let ids: Vec<&str> = applicable.iter().map(|s| s.id()).collect();
    assert!(
        !ids.contains(&AuthStripElicitation.id()),
        "inapplicable AuthStripElicitation must be excluded",
    );
    assert!(
        !ids.contains(&LowPrivilegeElicitation.id()),
        "inapplicable LowPrivilegeElicitation must be excluded",
    );
}

#[test]
fn applicable_strategies_both_filters_applied_simultaneously() {
    let strategies = all_strategies();
    let ctx = make_minimal_ctx(RiskLevel::Safe);
    let applicable: Vec<_> = applicable_strategies(&strategies, &ctx).collect();
    for s in &applicable {
        assert!(
            s.risk() <= ctx.max_risk,
            "risk filter violated for '{}'",
            s.id()
        );
        assert!(
            s.is_applicable(&ctx),
            "applicability filter violated for '{}'",
            s.id()
        );
    }
}

// --- Technique metadata tests ---

#[test]
fn all_specs_have_known_vector() {
    let ctx = make_full_ctx();
    let plan = generate_plan(&ctx);
    for spec in &plan {
        let v = spec.technique().vector;
        assert!(
            v == Vector::StatusCodeDiff
                || v == Vector::CacheProbing
                || v == Vector::ErrorMessageGranularity
                || v == Vector::RedirectDiff,
            "strategy '{}' has unexpected vector: {:?}",
            spec_strategy_id(spec),
            v,
        );
    }
}

#[test]
fn all_specs_have_populated_technique_id() {
    let ctx = make_full_ctx();
    let plan = generate_plan(&ctx);
    for spec in &plan {
        assert!(
            !spec.technique().id.is_empty(),
            "strategy '{}' has empty technique id",
            spec_strategy_id(spec),
        );
    }
}

#[cfg(test)]
#[path = "registry_report_tests.rs"]
mod report;