use super::*;
use parlov_probe::http::HttpProbe;
fn make_no_signal_finding(strategy_id: &str) -> (ScanFinding, StrategyOutcome) {
use parlov_core::{NormativeStrength, OracleClass, OracleResult, OracleVerdict, Vector};
let result = OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::NotPresent,
severity: None,
confidence: 0,
impact_class: None,
reasons: vec![],
signals: vec![],
technique_id: None,
vector: Some(Vector::StatusCodeDiff),
normative_strength: Some(NormativeStrength::Must),
label: None,
leaks: None,
rfc_basis: None,
};
let finding = ScanFinding {
target_url: "https://example.com/{id}".to_owned(),
strategy_id: strategy_id.to_owned(),
strategy_name: "Test Strategy".to_owned(),
method: "GET".to_owned(),
result: result.clone(),
repro: None,
probe: parlov_output::ProbeContext {
baseline_url: "https://example.com/1".to_owned(),
probe_url: "https://example.com/9999".to_owned(),
method: "GET".to_owned(),
headers: None,
},
exchange: parlov_output::ExchangeContext {
baseline_status: 200,
probe_status: 404,
headers: None,
body_samples: None,
},
chain_provenance: None,
};
(finding, StrategyOutcome::NoSignal(result))
}
fn make_positive_finding(strategy_id: &str, confidence: u8) -> (ScanFinding, StrategyOutcome) {
use parlov_core::{
NormativeStrength, OracleClass, OracleResult, OracleVerdict, Severity, Vector,
};
let result = OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Confirmed,
severity: Some(Severity::High),
confidence,
impact_class: None,
reasons: vec![],
signals: vec![],
technique_id: None,
vector: Some(Vector::StatusCodeDiff),
normative_strength: Some(NormativeStrength::Must),
label: None,
leaks: None,
rfc_basis: None,
};
let finding = ScanFinding {
target_url: "https://example.com/{id}".to_owned(),
strategy_id: strategy_id.to_owned(),
strategy_name: "Test Strategy".to_owned(),
method: "GET".to_owned(),
result: result.clone(),
repro: None,
probe: parlov_output::ProbeContext {
baseline_url: "https://example.com/1".to_owned(),
probe_url: "https://example.com/9999".to_owned(),
method: "GET".to_owned(),
headers: None,
},
exchange: parlov_output::ExchangeContext {
baseline_status: 200,
probe_status: 404,
headers: None,
body_samples: None,
},
chain_provenance: None,
};
(finding, StrategyOutcome::Positive(result))
}
#[test]
fn build_endpoint_verdict_all_no_signal_is_inconclusive() {
use crate::pipeline_state::ScanPipelineState;
use parlov_core::OracleVerdict;
let mut state = ScanPipelineState::new(2);
state.findings.push(make_no_signal_finding("a"));
state.findings.push(make_no_signal_finding("b"));
state.strategies_run = 2;
let verdict = build_endpoint_verdict(&state);
assert_eq!(verdict.verdict, OracleVerdict::Inconclusive);
assert!(
(verdict.posterior_probability - 0.5).abs() < 1e-9,
"expected ~0.5, got {}",
verdict.posterior_probability
);
}
#[test]
fn build_endpoint_verdict_positive_finding_raises_posterior() {
use crate::pipeline_state::ScanPipelineState;
use parlov_core::OracleVerdict;
let mut state = ScanPipelineState::new(1);
let (finding, outcome) = make_positive_finding("a", 85);
state
.accumulator
.ingest(&outcome, parlov_core::Vector::StatusCodeDiff);
state.findings.push((finding, outcome));
state.strategies_run = 1;
let verdict = build_endpoint_verdict(&state);
assert_ne!(verdict.verdict, OracleVerdict::NotPresent);
assert!(verdict.posterior_probability > 0.5);
}
#[test]
fn build_endpoint_verdict_stop_reason_exhausted_when_none() {
use crate::pipeline_state::ScanPipelineState;
use parlov_core::EndpointStopReason;
let state = ScanPipelineState::new(0);
let verdict = build_endpoint_verdict(&state);
assert!(
matches!(verdict.stop_reason, Some(EndpointStopReason::ExhaustedPlan)),
"expected ExhaustedPlan, got {:?}",
verdict.stop_reason
);
}
#[test]
fn build_endpoint_verdict_stop_reason_early_accept() {
use crate::pipeline_state::ScanPipelineState;
use parlov_analysis::StopDecision;
let mut state = ScanPipelineState::new(10);
state.stop_decision = Some(StopDecision::EarlyAccept { posterior: 0.92 });
let verdict = build_endpoint_verdict(&state);
assert!(
matches!(verdict.stop_reason, Some(EndpointStopReason::EarlyAccept)),
"expected EarlyAccept, got {:?}",
verdict.stop_reason
);
}
#[test]
fn exhaustive_flag_defaults_to_false() {
let args = minimal_args("https://api.example.com/users/{id}", "1001");
assert!(!args.exhaustive);
}
#[test]
fn pipeline_state_first_threshold_crossed_by_starts_none() {
let state = ScanPipelineState::new(5);
assert!(state.first_threshold_crossed_by.is_none());
}
#[test]
fn build_endpoint_verdict_first_threshold_crossed_by_none_when_not_set() {
let state = ScanPipelineState::new(2);
let verdict = build_endpoint_verdict(&state);
assert!(verdict.first_threshold_crossed_by.is_none());
}
#[test]
fn build_endpoint_verdict_first_threshold_crossed_by_propagates() {
let mut state = ScanPipelineState::new(2);
state.first_threshold_crossed_by = Some("rd-percent-encoding".to_owned());
let verdict = build_endpoint_verdict(&state);
assert_eq!(
verdict.first_threshold_crossed_by,
Some("rd-percent-encoding".to_owned())
);
}
#[tokio::test]
async fn stop_decision_none_when_exhaustive() {
let mut state = ScanPipelineState::new(0);
let stop_rule = StopRule::new();
let probe = HttpProbe::new();
run_plan_specs(
&[],
"https://example.com/{id}",
&mut state,
&stop_rule,
&probe,
crate::scan_exec::RunOpts {
exhaustive: true,
confirm_threshold: CONFIRM_LOG_ODDS_THRESHOLD,
repro: false,
verbose: false,
},
)
.await;
assert!(state.stop_decision.is_none());
assert!(state.first_threshold_crossed_by.is_none());
}
#[tokio::test]
async fn first_threshold_crossed_by_stays_none_when_not_exhaustive() {
use parlov_core::{NormativeStrength, OracleClass, OracleResult, OracleVerdict, Vector};
let mut state = ScanPipelineState::new(0);
let result = OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Confirmed,
severity: None,
confidence: 99,
impact_class: None,
reasons: vec![],
signals: vec![],
technique_id: None,
vector: Some(Vector::StatusCodeDiff),
normative_strength: Some(NormativeStrength::Must),
label: None,
leaks: None,
rfc_basis: None,
};
let outcome = parlov_core::StrategyOutcome::Positive(result);
state.accumulator.ingest(&outcome, Vector::StatusCodeDiff);
state.accumulator.ingest(&outcome, Vector::CacheProbing);
assert!(
state.accumulator.log_odds_current() >= CONFIRM_LOG_ODDS_THRESHOLD,
"precondition: log_odds must be >= threshold before running empty plan"
);
let stop_rule = StopRule::new();
let probe = HttpProbe::new();
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!(
state.first_threshold_crossed_by.is_none(),
"first_threshold_crossed_by must stay None when exhaustive=false, got {:?}",
state.first_threshold_crossed_by
);
}