use http::Method;
use parlov_core::{
Applicability, NormativeStrength, OracleClass, ResponseSurface, SignalSurface, Technique,
Vector,
};
use crate::strategy::Strategy;
use crate::types::{ProbeSpec, RiskLevel, StrategyMetadata};
use crate::util::{build_pair, clone_headers_static, json_body};
use crate::ScanContext;
static METADATA: StrategyMetadata = StrategyMetadata {
strategy_id: "state-transition-elicit",
strategy_name: "State Transition Elicitation",
risk: RiskLevel::MethodDestructive,
};
fn state_transition_applicable(
baseline: &ResponseSurface,
probe: &ResponseSurface,
) -> Applicability {
let is_strong = |s: u16| matches!(s, 409 | 422);
if is_strong(baseline.status.as_u16()) || is_strong(probe.status.as_u16()) {
return Applicability::Strong;
}
Applicability::Weak
}
static TECHNIQUE: Technique = Technique {
id: "state-transition",
name: "Invalid state transition probe",
oracle_class: OracleClass::Existence,
vector: Vector::StatusCodeDiff,
strength: NormativeStrength::May,
normalization_weight: Some(0.02),
inverted_signal_weight: None,
method_relevant: false,
parser_relevant: true,
applicability: state_transition_applicable,
contradiction_surface: SignalSurface::Status,
};
pub struct StateTransitionElicitation;
impl Strategy for StateTransitionElicitation {
fn metadata(&self) -> &'static StrategyMetadata {
&METADATA
}
fn technique_def(&self) -> &'static Technique {
&TECHNIQUE
}
fn methods(&self) -> &[Method] {
&[Method::PATCH, Method::PUT]
}
fn is_applicable(&self, ctx: &ScanContext) -> bool {
ctx.state_field.is_some()
}
fn generate(&self, ctx: &ScanContext) -> Vec<ProbeSpec> {
let Some(sf) = &ctx.state_field else {
return vec![];
};
let body = Some(json_body(&[(&sf.field, &sf.value)]));
let mut specs = Vec::with_capacity(2);
for method in [Method::PATCH, Method::PUT] {
let headers = clone_headers_static(&ctx.headers, "content-type", "application/json");
let pair = build_pair(
ctx,
method,
headers.clone(),
headers,
body.clone(),
METADATA.clone(),
TECHNIQUE,
);
specs.push(ProbeSpec::Pair(pair));
}
specs
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{ctx_method_destructive, ctx_with_state_field};
#[test]
fn risk_is_method_destructive() {
assert_eq!(
StateTransitionElicitation.risk(),
RiskLevel::MethodDestructive
);
}
#[test]
fn is_applicable_false_when_state_field_none() {
assert!(!StateTransitionElicitation.is_applicable(&ctx_method_destructive()));
}
#[test]
fn is_applicable_true_when_state_field_some() {
assert!(StateTransitionElicitation.is_applicable(&ctx_with_state_field()));
}
#[test]
fn generate_returns_two_items_when_applicable() {
assert_eq!(
StateTransitionElicitation
.generate(&ctx_with_state_field())
.len(),
2
);
}
#[test]
fn probe_body_contains_state_field_as_valid_json() {
let specs = StateTransitionElicitation.generate(&ctx_with_state_field());
let ProbeSpec::Pair(pair) = &specs[0] else {
panic!("expected Pair")
};
let body = pair.probe.body.as_deref().expect("body must be present");
let parsed: serde_json::Value = serde_json::from_slice(body).expect("valid JSON");
assert_eq!(parsed["status"], "invalid_state");
}
#[test]
fn technique_strength_is_may() {
let specs = StateTransitionElicitation.generate(&ctx_with_state_field());
assert_eq!(specs[0].technique().strength, NormativeStrength::May);
}
#[test]
fn normalization_weight_is_0_02() {
assert_eq!(TECHNIQUE.normalization_weight, Some(0.02));
}
#[test]
fn inverted_signal_weight_is_none() {
assert_eq!(TECHNIQUE.inverted_signal_weight, None);
}
}