use bytes::Bytes;
use http::{HeaderMap, HeaderValue, StatusCode};
use parlov_core::{
always_applicable, Applicability, DifferentialSet, NormativeStrength, OracleClass,
ProbeDefinition, ProbeExchange, ResponseSurface, SignalSurface, Technique, Vector,
};
use proptest::prelude::*;
use crate::aggregation::auth_types::CredentialBlockKind;
use crate::aggregation::precondition::{
precondition_confidence, AuthBlockLayer, PreconditionBlock, PreconditionDecision,
};
fn base_technique(id: &'static str) -> Technique {
Technique {
id,
name: "Test technique",
oracle_class: OracleClass::Existence,
vector: Vector::StatusCodeDiff,
strength: NormativeStrength::Must,
normalization_weight: Some(0.2),
inverted_signal_weight: None,
method_relevant: false,
parser_relevant: false,
applicability: always_applicable,
contradiction_surface: SignalSurface::Status,
}
}
fn make_exchange(status: u16, headers: HeaderMap, body: &'static [u8]) -> ProbeExchange {
ProbeExchange {
request: ProbeDefinition {
url: "https://example.com/r/1".into(),
method: http::Method::GET,
headers: HeaderMap::new(),
body: None,
},
response: ResponseSurface {
status: StatusCode::from_u16(status).expect("valid status"),
headers,
body: Bytes::from_static(body),
timing_ns: 0,
},
}
}
fn make_exchange_with_request_headers(
status: u16,
request_headers: HeaderMap,
response_headers: HeaderMap,
body: &'static [u8],
) -> ProbeExchange {
ProbeExchange {
request: ProbeDefinition {
url: "https://example.com/r/1".into(),
method: http::Method::GET,
headers: request_headers,
body: None,
},
response: ResponseSurface {
status: StatusCode::from_u16(status).expect("valid status"),
headers: response_headers,
body: Bytes::from_static(body),
timing_ns: 0,
},
}
}
fn diff_set(
technique: Technique,
baseline: ProbeExchange,
probe: ProbeExchange,
) -> DifferentialSet {
DifferentialSet {
baseline: vec![baseline],
probe: vec![probe],
canonical: None,
technique,
}
}
fn no_credential_origin() -> PreconditionBlock {
PreconditionBlock::AuthGateBeforeTechnique {
credential_state: CredentialBlockKind::NoCredential,
layer: AuthBlockLayer::Origin,
}
}
#[test]
fn uniform_401_no_auth_blocks_with_auth_gate_reason() {
let t = base_technique("if-none-match");
let ds = diff_set(
t,
make_exchange(401, HeaderMap::new(), b"unauth"),
make_exchange(401, HeaderMap::new(), b"unauth"),
);
let decision = precondition_confidence(&t, &ds);
assert_eq!(
decision,
PreconditionDecision::Blocked(no_credential_origin()),
);
}
#[test]
fn uniform_401_with_auth_header_credential_rejected_blocks() {
let t = base_technique("if-none-match");
let mut req_headers = HeaderMap::new();
req_headers.insert(
http::header::AUTHORIZATION,
HeaderValue::from_static("Bearer x"),
);
let mut resp_headers = HeaderMap::new();
resp_headers.insert(
http::header::WWW_AUTHENTICATE,
HeaderValue::from_static(r#"Bearer error="invalid_token""#),
);
let ds = diff_set(
t,
make_exchange_with_request_headers(401, req_headers.clone(), resp_headers.clone(), b"x"),
make_exchange_with_request_headers(401, req_headers, resp_headers, b"x"),
);
let decision = precondition_confidence(&t, &ds);
let PreconditionDecision::Blocked(PreconditionBlock::AuthGateBeforeTechnique {
credential_state,
layer,
}) = decision
else {
panic!("expected Blocked(AuthGateBeforeTechnique), got {decision:?}");
};
assert_eq!(credential_state, CredentialBlockKind::CredentialRejected);
assert_eq!(layer, AuthBlockLayer::Origin);
}
#[test]
fn uniform_401_different_bodies_does_not_fire_auth_gate() {
let t = base_technique("if-none-match");
let ds = diff_set(
t,
make_exchange(401, HeaderMap::new(), b"baseline body"),
make_exchange(401, HeaderMap::new(), b"probe body"),
);
let decision = precondition_confidence(&t, &ds);
assert!(matches!(decision, PreconditionDecision::Reached(_)));
}
#[test]
fn auth_gate_uses_no_credential_for_low_privilege_when_unauthenticated() {
let t = base_technique("low-privilege");
let ds = diff_set(
t,
make_exchange(401, HeaderMap::new(), b"x"),
make_exchange(401, HeaderMap::new(), b"x"),
);
let decision = precondition_confidence(&t, &ds);
assert_eq!(
decision,
PreconditionDecision::Blocked(no_credential_origin())
);
}
#[test]
fn status_differential_401_vs_404_is_preserved() {
let t = base_technique("if-none-match");
let ds = diff_set(
t,
make_exchange(401, HeaderMap::new(), b"x"),
make_exchange(404, HeaderMap::new(), b"y"),
);
let decision = precondition_confidence(&t, &ds);
assert!(matches!(decision, PreconditionDecision::Reached(_)));
}
#[test]
fn uniform_405_blocks_with_method_gate_reason() {
let t = base_technique("if-none-match");
let ds = diff_set(
t,
make_exchange(405, HeaderMap::new(), b"not allowed"),
make_exchange(405, HeaderMap::new(), b"not allowed"),
);
let decision = precondition_confidence(&t, &ds);
assert_eq!(
decision,
PreconditionDecision::Blocked(PreconditionBlock::MethodGateBeforeResource)
);
}
#[test]
fn uniform_405_with_method_relevant_does_not_block() {
let mut t = base_technique("method-probe");
t.method_relevant = true;
let ds = diff_set(
t,
make_exchange(405, HeaderMap::new(), b"x"),
make_exchange(405, HeaderMap::new(), b"x"),
);
let decision = precondition_confidence(&t, &ds);
assert!(matches!(decision, PreconditionDecision::Reached(_)));
}
#[test]
fn mixed_405_404_does_not_fire_method_gate() {
let t = base_technique("if-none-match");
let ds = diff_set(
t,
make_exchange(405, HeaderMap::new(), b"x"),
make_exchange(404, HeaderMap::new(), b"y"),
);
let decision = precondition_confidence(&t, &ds);
assert!(matches!(decision, PreconditionDecision::Reached(_)));
}
#[test]
fn uniform_400_blocks_for_non_parser_relevant_technique() {
let t = base_technique("if-none-match");
let ds = diff_set(
t,
make_exchange(400, HeaderMap::new(), b"bad"),
make_exchange(400, HeaderMap::new(), b"bad"),
);
let decision = precondition_confidence(&t, &ds);
assert_eq!(
decision,
PreconditionDecision::Blocked(PreconditionBlock::BlockedByParser)
);
}
#[test]
fn uniform_422_blocks_for_non_parser_relevant_technique() {
let t = base_technique("if-none-match");
let ds = diff_set(
t,
make_exchange(422, HeaderMap::new(), b"unprocessable"),
make_exchange(422, HeaderMap::new(), b"unprocessable"),
);
let decision = precondition_confidence(&t, &ds);
assert_eq!(
decision,
PreconditionDecision::Blocked(PreconditionBlock::BlockedByParser)
);
}
#[test]
fn uniform_400_does_not_block_parser_relevant_technique() {
let mut t = base_technique("uniqueness");
t.parser_relevant = true;
let ds = diff_set(
t,
make_exchange(400, HeaderMap::new(), b"x"),
make_exchange(400, HeaderMap::new(), b"x"),
);
let decision = precondition_confidence(&t, &ds);
assert!(matches!(decision, PreconditionDecision::Reached(_)));
}
#[test]
fn mixed_400_422_does_not_fire_parser_gate() {
let t = base_technique("if-none-match");
let ds = diff_set(
t,
make_exchange(400, HeaderMap::new(), b"x"),
make_exchange(422, HeaderMap::new(), b"y"),
);
let decision = precondition_confidence(&t, &ds);
assert!(matches!(decision, PreconditionDecision::Reached(_)));
}
#[test]
fn missing_applicability_marker_blocks() {
let mut t = base_technique("if-none-match");
t.applicability = |_, _| Applicability::Missing;
let ds = diff_set(
t,
make_exchange(200, HeaderMap::new(), b"x"),
make_exchange(404, HeaderMap::new(), b"y"),
);
let decision = precondition_confidence(&t, &ds);
assert_eq!(
decision,
PreconditionDecision::Blocked(PreconditionBlock::ApplicabilityMarkerMissing)
);
}
#[test]
fn weak_applicability_marker_returns_three_tenths() {
let mut t = base_technique("range");
t.applicability = |_, _| Applicability::Weak;
let ds = diff_set(
t,
make_exchange(200, HeaderMap::new(), b"x"),
make_exchange(404, HeaderMap::new(), b"y"),
);
let decision = precondition_confidence(&t, &ds);
let PreconditionDecision::Reached(c) = decision else {
panic!("expected Reached, got {decision:?}");
};
assert!((c - 0.3).abs() < f64::EPSILON);
}
#[test]
fn strong_applicability_marker_returns_one() {
let t = base_technique("if-none-match");
let ds = diff_set(
t,
make_exchange(200, HeaderMap::new(), b"x"),
make_exchange(404, HeaderMap::new(), b"y"),
);
let decision = precondition_confidence(&t, &ds);
let PreconditionDecision::Reached(c) = decision else {
panic!("expected Reached, got {decision:?}");
};
assert!((c - 1.0).abs() < f64::EPSILON);
}
#[test]
fn block_strings_are_distinct() {
let strings: Vec<&str> = [
no_credential_origin(),
PreconditionBlock::AuthGateBeforeTechnique {
credential_state: CredentialBlockKind::CredentialRejected,
layer: AuthBlockLayer::Origin,
},
PreconditionBlock::AuthGateBeforeTechnique {
credential_state: CredentialBlockKind::InsufficientScope,
layer: AuthBlockLayer::Origin,
},
PreconditionBlock::AuthGateBeforeTechnique {
credential_state: CredentialBlockKind::NotApplicable,
layer: AuthBlockLayer::Proxy,
},
PreconditionBlock::AuthGateBeforeTechnique {
credential_state: CredentialBlockKind::NotApplicable,
layer: AuthBlockLayer::Network,
},
PreconditionBlock::AuthGateBeforeTechnique {
credential_state: CredentialBlockKind::NoCredential,
layer: AuthBlockLayer::LoginRedirect,
},
PreconditionBlock::MethodGateBeforeResource,
PreconditionBlock::BlockedByParser,
PreconditionBlock::ApplicabilityMarkerMissing,
PreconditionBlock::SurfaceMismatch,
PreconditionBlock::MutationDestroyedControl,
]
.iter()
.map(|b| b.as_str())
.collect();
let mut sorted = strings.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.len(),
strings.len(),
"all block strings must be distinct"
);
}
#[test]
fn confidence_of_reached_returns_inner() {
assert!((PreconditionDecision::Reached(0.7).confidence() - 0.7).abs() < f64::EPSILON);
}
#[test]
fn confidence_of_blocked_is_zero() {
let d = PreconditionDecision::Blocked(no_credential_origin());
assert!(d.confidence().abs() < f64::EPSILON);
}
#[test]
fn block_reason_of_reached_is_none() {
assert!(PreconditionDecision::Reached(1.0).block_reason().is_none());
}
#[test]
fn block_reason_of_blocked_is_some() {
let d = PreconditionDecision::Blocked(no_credential_origin());
assert!(d.block_reason().is_some());
}
proptest! {
#[test]
fn precondition_confidence_referentially_transparent(
b_status in 200u16..=599,
p_status in 200u16..=599,
) {
let b_status = if http::StatusCode::from_u16(b_status).is_err() { 404 } else { b_status };
let p_status = if http::StatusCode::from_u16(p_status).is_err() { 404 } else { p_status };
let t = base_technique("if-none-match");
let ds = diff_set(
t,
make_exchange(b_status, HeaderMap::new(), b""),
make_exchange(p_status, HeaderMap::new(), b""),
);
let d1 = precondition_confidence(&t, &ds);
let d2 = precondition_confidence(&t, &ds);
prop_assert_eq!(d1, d2);
}
#[test]
fn non_parser_relevant_blocked_by_parser(status in prop::sample::select(&[400u16, 422u16][..])) {
let t = base_technique("if-none-match");
prop_assert!(!t.parser_relevant);
let ds = diff_set(
t,
make_exchange(status, HeaderMap::new(), b""),
make_exchange(status, HeaderMap::new(), b""),
);
let decision = precondition_confidence(&t, &ds);
prop_assert_eq!(
decision,
PreconditionDecision::Blocked(PreconditionBlock::BlockedByParser)
);
}
#[test]
fn non_auth_technique_blocked_by_auth_gate(
id in prop::sample::select(&[
"if-none-match",
"if-match",
"trailing-slash",
"case-normalize",
"accept",
"rate-limit-headers",
][..])
) {
let t = base_technique(id);
let ds = diff_set(
t,
make_exchange(401, HeaderMap::new(), b"unauth"),
make_exchange(401, HeaderMap::new(), b"unauth"),
);
let decision = precondition_confidence(&t, &ds);
prop_assert_eq!(decision, PreconditionDecision::Blocked(no_credential_origin()));
}
}