use super::*;
use bytes::Bytes;
use http::{HeaderMap, HeaderValue};
use crate::test_utils::minimal_ctx;
struct LocationProducer;
impl Producer for LocationProducer {
fn admits(&self, class: ResponseClass) -> bool {
matches!(class, ResponseClass::Redirect)
}
fn extract(&self, _class: ResponseClass, headers: &HeaderMap) -> Option<ProducerOutput> {
let loc = headers.get(http::header::LOCATION)?.to_str().ok()?;
Some(ProducerOutput::Location(loc.to_owned()))
}
}
struct LocationConsumer;
impl Consumer for LocationConsumer {
fn needs(&self) -> ProducerOutputKind {
ProducerOutputKind::Location
}
fn generate(&self, _ctx: &ScanContext, _output: &ProducerOutput) -> Vec<ProbeSpec> {
vec![]
}
}
struct NullProducer;
impl Producer for NullProducer {
fn admits(&self, _class: ResponseClass) -> bool {
true
}
fn extract(&self, _class: ResponseClass, _headers: &HeaderMap) -> Option<ProducerOutput> {
None
}
}
fn redirect_exchange() -> (ResponseClass, HeaderMap, Bytes) {
let mut headers = HeaderMap::new();
headers.insert(
http::header::LOCATION,
HeaderValue::from_static("https://example.com/new"),
);
(ResponseClass::Redirect, headers, Bytes::new())
}
#[test]
fn registry_extend_merges_edges() {
let mut reg_a = ChainRegistry::new();
reg_a.register(Arc::new(LocationProducer), Arc::new(LocationConsumer));
let mut reg_b = ChainRegistry::new();
reg_b.register(Arc::new(LocationProducer), Arc::new(LocationConsumer));
reg_a.extend(reg_b);
assert_eq!(reg_a.len(), 2, "extend must merge both registries");
}
#[test]
fn producer_is_object_safe() {
let _: Arc<dyn Producer> = Arc::new(LocationProducer);
}
#[test]
fn consumer_is_object_safe() {
let _: Arc<dyn Consumer> = Arc::new(LocationConsumer);
}
#[test]
fn registry_starts_empty() {
assert!(ChainRegistry::new().is_empty());
assert_eq!(ChainRegistry::new().len(), 0);
}
#[test]
fn registry_register_increments_len() {
let mut reg = ChainRegistry::new();
reg.register(Arc::new(LocationProducer), Arc::new(LocationConsumer));
assert_eq!(reg.len(), 1);
assert!(!reg.is_empty());
}
#[test]
fn dag_plan_empty_when_no_exchanges() {
let mut reg = ChainRegistry::new();
reg.register(Arc::new(LocationProducer), Arc::new(LocationConsumer));
let specs = generate_dag_chained_plan(&minimal_ctx(), &[], ®);
assert!(specs.is_empty());
}
#[test]
fn dag_plan_empty_when_registry_is_empty() {
let reg = ChainRegistry::new();
let specs = generate_dag_chained_plan(&minimal_ctx(), &[redirect_exchange()], ®);
assert!(specs.is_empty());
}
#[test]
fn dag_plan_empty_when_producer_does_not_admit_exchange_class() {
let mut reg = ChainRegistry::new();
reg.register(Arc::new(LocationProducer), Arc::new(LocationConsumer));
let exchanges = vec![(ResponseClass::Success, HeaderMap::new(), Bytes::new())];
let specs = generate_dag_chained_plan(&minimal_ctx(), &exchanges, ®);
assert!(specs.is_empty());
}
#[test]
fn dag_plan_empty_when_producer_admits_but_extract_returns_none() {
let mut reg = ChainRegistry::new();
reg.register(Arc::new(NullProducer), Arc::new(LocationConsumer));
let specs = generate_dag_chained_plan(&minimal_ctx(), &[redirect_exchange()], ®);
assert!(specs.is_empty());
}
use crate::types::{ProbePair, ProbeSpec, RiskLevel, StrategyMetadata};
use parlov_core::{
always_applicable, NormativeStrength, OracleClass, ProbeDefinition, SignalSurface, Technique,
Vector,
};
fn make_pair_spec() -> ProbeSpec {
let def = ProbeDefinition {
url: "https://example.com/x".to_owned(),
method: http::Method::GET,
headers: HeaderMap::new(),
body: None,
};
ProbeSpec::Pair(ProbePair {
baseline: def.clone(),
probe: def,
canonical_baseline: None,
metadata: StrategyMetadata {
strategy_id: "stub",
strategy_name: "stub",
risk: RiskLevel::Safe,
},
technique: Technique {
id: "stub",
name: "stub",
oracle_class: OracleClass::Existence,
vector: Vector::StatusCodeDiff,
strength: NormativeStrength::Should,
normalization_weight: None,
inverted_signal_weight: None,
method_relevant: false,
parser_relevant: false,
applicability: always_applicable,
contradiction_surface: SignalSurface::Status,
},
chain_provenance: None,
})
}
struct PairConsumer;
impl Consumer for PairConsumer {
fn needs(&self) -> ProducerOutputKind {
ProducerOutputKind::Location
}
fn generate(&self, _ctx: &ScanContext, _output: &ProducerOutput) -> Vec<ProbeSpec> {
vec![make_pair_spec()]
}
}
#[test]
fn dag_attaches_chain_provenance_to_generated_specs() {
let mut reg = ChainRegistry::new();
reg.register(Arc::new(LocationProducer), Arc::new(PairConsumer));
let specs = generate_dag_chained_plan(&minimal_ctx(), &[redirect_exchange()], ®);
assert_eq!(specs.len(), 1, "expected one spec from PairConsumer");
let prov = specs[0]
.chain_provenance()
.expect("chain_provenance must be attached");
assert_eq!(prov.producer_kind, "Location");
assert_eq!(prov.producer_value, "https://example.com/new");
}
#[test]
fn phase1_specs_have_no_chain_provenance() {
let spec = make_pair_spec();
assert!(spec.chain_provenance().is_none());
}
#[test]
fn kind_string_for_etag_returns_etag() {
let out = ProducerOutput::Etag("W/\"abc\"".to_owned(), EtagStrength::Weak);
assert_eq!(out.kind_string(), "Etag");
}
#[test]
fn kind_string_for_problem_details_returns_problem_details() {
let out = ProducerOutput::ProblemDetails {
required_fields: vec!["email".to_owned()],
error_type: Some("about:blank".to_owned()),
};
assert_eq!(out.kind_string(), "ProblemDetails");
}
#[test]
fn kind_string_for_auth_challenge_returns_auth_challenge() {
let out = ProducerOutput::AuthChallenge {
scheme: "Bearer".to_owned(),
realm: None,
scope: None,
};
assert_eq!(out.kind_string(), "AuthChallenge");
}
#[test]
fn value_string_for_etag_returns_quoted_value() {
let out = ProducerOutput::Etag("W/\"abc\"".to_owned(), EtagStrength::Weak);
assert_eq!(out.value_string(), "W/\"abc\"");
}
#[test]
fn value_string_for_last_modified_returns_date_string() {
let out = ProducerOutput::LastModified("Tue, 15 Nov 1994 12:45:26 GMT".to_owned());
assert_eq!(out.value_string(), "Tue, 15 Nov 1994 12:45:26 GMT");
}
#[test]
fn value_string_for_location_returns_url_string() {
let out = ProducerOutput::Location("https://example.com/new".to_owned());
assert_eq!(out.value_string(), "https://example.com/new");
}
#[test]
fn value_string_for_content_range_size_returns_decimal_string() {
let out = ProducerOutput::ContentRangeSize(12345);
assert_eq!(out.value_string(), "12345");
}
#[test]
fn value_string_for_accept_ranges_returns_string() {
let out = ProducerOutput::AcceptRanges("bytes".to_owned());
assert_eq!(out.value_string(), "bytes");
}
#[test]
fn value_string_for_resource_id_returns_string() {
let out = ProducerOutput::ResourceId("42".to_owned());
assert_eq!(out.value_string(), "42");
}
#[test]
fn value_string_for_content_type_returns_mime() {
let out = ProducerOutput::ContentType("application/json".to_owned());
assert_eq!(out.value_string(), "application/json");
}
#[test]
fn value_string_for_problem_details_returns_summary() {
let out = ProducerOutput::ProblemDetails {
required_fields: vec!["email".to_owned(), "name".to_owned()],
error_type: Some("https://example.com/probs/req".to_owned()),
};
let v = out.value_string();
assert!(v.contains("required_fields="));
assert!(v.contains("email"));
assert!(v.contains("type=https://example.com/probs/req"));
}
#[test]
fn value_string_for_problem_details_renders_dash_when_type_none() {
let out = ProducerOutput::ProblemDetails {
required_fields: vec![],
error_type: None,
};
assert!(out.value_string().contains("type=-"));
}
#[test]
fn value_string_for_auth_challenge_includes_scheme_realm_scope() {
let out = ProducerOutput::AuthChallenge {
scheme: "Bearer".to_owned(),
realm: Some("api".to_owned()),
scope: Some("read:items".to_owned()),
};
let v = out.value_string();
assert!(v.contains("scheme=Bearer"), "{v}");
assert!(v.contains("realm=api"), "{v}");
assert!(v.contains("scope=read:items"), "{v}");
}
#[test]
fn value_string_for_auth_challenge_renders_dashes_when_optional_absent() {
let out = ProducerOutput::AuthChallenge {
scheme: "Basic".to_owned(),
realm: None,
scope: None,
};
let v = out.value_string();
assert!(v.contains("realm=-"));
assert!(v.contains("scope=-"));
}
#[test]
fn producer_output_kind_matches_variant() {
assert_eq!(
ProducerOutput::Location("x".to_owned()).kind(),
ProducerOutputKind::Location
);
assert_eq!(
ProducerOutput::Etag("x".to_owned(), EtagStrength::Strong).kind(),
ProducerOutputKind::Etag
);
assert_eq!(
ProducerOutput::LastModified("x".to_owned()).kind(),
ProducerOutputKind::LastModified
);
assert_eq!(
ProducerOutput::ContentRangeSize(42).kind(),
ProducerOutputKind::ContentRangeSize
);
assert_eq!(
ProducerOutput::AcceptRanges("bytes".to_owned()).kind(),
ProducerOutputKind::AcceptRanges
);
assert_eq!(
ProducerOutput::ResourceId("42".to_owned()).kind(),
ProducerOutputKind::ResourceId
);
assert_eq!(
ProducerOutput::ProblemDetails {
required_fields: vec![],
error_type: None
}
.kind(),
ProducerOutputKind::ProblemDetails
);
assert_eq!(
ProducerOutput::ContentType("application/json".to_owned()).kind(),
ProducerOutputKind::ContentType
);
assert_eq!(
ProducerOutput::AuthChallenge {
scheme: "Bearer".to_owned(),
realm: None,
scope: None,
}
.kind(),
ProducerOutputKind::AuthChallenge
);
}