use bytes::Bytes;
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use parlov_core::ProbeDefinition;
use crate::context::ScanContext;
use crate::types::{ProbePair, StrategyMetadata};
#[must_use]
pub fn substitute_url(template: &str, id: &str) -> String {
template.replacen("{id}", id, 1)
}
#[must_use]
pub fn substitute_body(template: Option<&str>, id: &str) -> Option<Bytes> {
template.map(|t| Bytes::from(t.replace("{id}", id)))
}
#[must_use]
pub fn build_pair(
ctx: &ScanContext,
method: Method,
baseline_headers: HeaderMap,
probe_headers: HeaderMap,
body: Option<Bytes>,
metadata: StrategyMetadata,
) -> ProbePair {
let baseline_url = substitute_url(&ctx.target, &ctx.baseline_id);
let probe_url = substitute_url(&ctx.target, &ctx.probe_id);
let baseline_body = body
.clone()
.or_else(|| substitute_body(ctx.body_template.as_deref(), &ctx.baseline_id));
let probe_body = body
.or_else(|| substitute_body(ctx.body_template.as_deref(), &ctx.probe_id));
ProbePair {
baseline: ProbeDefinition {
url: baseline_url,
method: method.clone(),
headers: baseline_headers,
body: baseline_body,
},
probe: ProbeDefinition {
url: probe_url,
method,
headers: probe_headers,
body: probe_body,
},
metadata,
}
}
#[must_use]
pub fn clone_headers_with(base: &HeaderMap, key: &str, value: &str) -> HeaderMap {
let mut map = base.clone();
let name = HeaderName::from_bytes(key.as_bytes())
.expect("caller guarantees valid header name");
let val = HeaderValue::from_str(value)
.expect("caller guarantees valid header value");
map.insert(name, val);
map
}
#[must_use]
pub fn clone_headers_without(base: &HeaderMap, key: &str) -> HeaderMap {
let mut map = base.clone();
let name = HeaderName::from_bytes(key.as_bytes())
.expect("caller guarantees valid header name");
map.remove(name);
map
}
#[must_use]
pub fn json_body(fields: &[(&str, &str)]) -> Bytes {
let map: serde_json::Map<String, serde_json::Value> = fields
.iter()
.map(|(k, v)| ((*k).to_owned(), serde_json::Value::String((*v).to_owned())))
.collect();
let vec = serde_json::to_vec(&map).expect("serializing string-keyed map is infallible");
Bytes::from(vec)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ScanContext;
use crate::types::RiskLevel;
fn make_metadata() -> StrategyMetadata {
StrategyMetadata {
strategy_id: "test",
strategy_name: "Test",
risk: RiskLevel::Safe,
}
}
fn make_ctx() -> ScanContext {
ScanContext {
target: "https://api.example.com/users/{id}".to_owned(),
baseline_id: "1001".to_owned(),
probe_id: "9999".to_owned(),
headers: HeaderMap::new(),
max_risk: RiskLevel::Safe,
known_duplicate: None,
state_field: None,
alt_credential: None,
body_template: None,
}
}
#[test]
fn substitute_url_replaces_placeholder() {
let result = substitute_url("https://api.example.com/users/{id}", "1001");
assert_eq!(result, "https://api.example.com/users/1001");
}
#[test]
fn substitute_url_replaces_only_first_occurrence() {
let result = substitute_url("https://api.example.com/{id}/things/{id}", "42");
assert_eq!(result, "https://api.example.com/42/things/{id}");
}
#[test]
fn substitute_url_no_placeholder_returns_unchanged() {
let template = "https://api.example.com/users/all";
let result = substitute_url(template, "99");
assert_eq!(result, template);
}
#[test]
fn build_pair_baseline_url_uses_baseline_id() {
let ctx = make_ctx();
let pair = build_pair(&ctx, Method::GET, HeaderMap::new(), HeaderMap::new(), None, make_metadata());
assert_eq!(pair.baseline.url, "https://api.example.com/users/1001");
}
#[test]
fn build_pair_probe_url_uses_probe_id() {
let ctx = make_ctx();
let pair = build_pair(&ctx, Method::GET, HeaderMap::new(), HeaderMap::new(), None, make_metadata());
assert_eq!(pair.probe.url, "https://api.example.com/users/9999");
}
#[test]
fn build_pair_method_set_on_both_definitions() {
let ctx = make_ctx();
let pair = build_pair(&ctx, Method::POST, HeaderMap::new(), HeaderMap::new(), None, make_metadata());
assert_eq!(pair.baseline.method, Method::POST);
assert_eq!(pair.probe.method, Method::POST);
}
#[test]
fn build_pair_headers_assigned_to_correct_side() {
let ctx = make_ctx();
let baseline_hdrs = clone_headers_with(&HeaderMap::new(), "x-role", "admin");
let probe_hdrs = clone_headers_with(&HeaderMap::new(), "x-role", "guest");
let pair = build_pair(&ctx, Method::GET, baseline_hdrs, probe_hdrs, None, make_metadata());
assert_eq!(pair.baseline.headers.get("x-role").unwrap(), "admin");
assert_eq!(pair.probe.headers.get("x-role").unwrap(), "guest");
}
#[test]
fn build_pair_body_none_when_none() {
let ctx = make_ctx();
let pair = build_pair(&ctx, Method::GET, HeaderMap::new(), HeaderMap::new(), None, make_metadata());
assert!(pair.baseline.body.is_none());
assert!(pair.probe.body.is_none());
}
#[test]
fn build_pair_body_present_when_some() {
let ctx = make_ctx();
let body = Bytes::from_static(b"{\"x\":1}");
let pair = build_pair(&ctx, Method::POST, HeaderMap::new(), HeaderMap::new(), Some(body.clone()), make_metadata());
assert_eq!(pair.baseline.body.as_ref().unwrap(), &body);
assert_eq!(pair.probe.body.as_ref().unwrap(), &body);
}
#[test]
fn build_pair_body_template_substituted_when_no_explicit_body() {
let ctx = ScanContext {
body_template: Some(r#"{"id":"{id}"}"#.to_owned()),
..make_ctx()
};
let pair = build_pair(&ctx, Method::POST, HeaderMap::new(), HeaderMap::new(), None, make_metadata());
assert_eq!(pair.baseline.body.unwrap(), Bytes::from(r#"{"id":"1001"}"#));
assert_eq!(pair.probe.body.unwrap(), Bytes::from(r#"{"id":"9999"}"#));
}
#[test]
fn build_pair_explicit_body_overrides_template() {
let ctx = ScanContext {
body_template: Some(r#"{"id":"{id}"}"#.to_owned()),
..make_ctx()
};
let explicit = Bytes::from_static(b"explicit");
let pair = build_pair(&ctx, Method::POST, HeaderMap::new(), HeaderMap::new(), Some(explicit.clone()), make_metadata());
assert_eq!(pair.baseline.body.unwrap(), explicit);
assert_eq!(pair.probe.body.unwrap(), explicit);
}
#[test]
fn substitute_body_none_template_returns_none() {
assert!(substitute_body(None, "42").is_none());
}
#[test]
fn substitute_body_replaces_placeholder() {
let result = substitute_body(Some(r#"{"id":"{id}"}"#), "42").unwrap();
assert_eq!(result, Bytes::from(r#"{"id":"42"}"#));
}
#[test]
fn substitute_body_no_placeholder_returns_template_unchanged() {
let result = substitute_body(Some(r#"{"static":true}"#), "99").unwrap();
assert_eq!(result, Bytes::from(r#"{"static":true}"#));
}
#[test]
fn clone_headers_with_new_header_present() {
let result = clone_headers_with(&HeaderMap::new(), "x-custom", "hello");
assert_eq!(result.get("x-custom").unwrap(), "hello");
}
#[test]
fn clone_headers_with_preserves_existing_headers() {
let base = clone_headers_with(&HeaderMap::new(), "authorization", "Bearer tok");
let result = clone_headers_with(&base, "x-custom", "hello");
assert_eq!(result.get("authorization").unwrap(), "Bearer tok");
assert_eq!(result.get("x-custom").unwrap(), "hello");
}
#[test]
fn clone_headers_with_overwrites_existing_key() {
let base = clone_headers_with(&HeaderMap::new(), "x-role", "user");
let result = clone_headers_with(&base, "x-role", "admin");
assert_eq!(result.get("x-role").unwrap(), "admin");
assert_eq!(result.get_all("x-role").iter().count(), 1);
}
#[test]
fn clone_headers_without_removes_named_header() {
let base = clone_headers_with(&HeaderMap::new(), "x-remove-me", "gone");
let result = clone_headers_without(&base, "x-remove-me");
assert!(result.get("x-remove-me").is_none());
}
#[test]
fn clone_headers_without_preserves_other_headers() {
let base = clone_headers_with(&HeaderMap::new(), "authorization", "Bearer tok");
let base = clone_headers_with(&base, "x-remove-me", "gone");
let result = clone_headers_without(&base, "x-remove-me");
assert_eq!(result.get("authorization").unwrap(), "Bearer tok");
}
#[test]
fn clone_headers_without_noop_when_key_absent() {
let base = clone_headers_with(&HeaderMap::new(), "authorization", "Bearer tok");
let result = clone_headers_without(&base, "x-nonexistent");
assert_eq!(result.get("authorization").unwrap(), "Bearer tok");
assert!(result.get("x-nonexistent").is_none());
}
#[test]
fn json_body_single_field() {
let body = json_body(&[("status", "active")]);
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["status"], "active");
}
#[test]
fn json_body_multiple_fields() {
let body = json_body(&[("email", "alice@example.com"), ("role", "admin")]);
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["email"], "alice@example.com");
assert_eq!(parsed["role"], "admin");
}
#[test]
fn json_body_empty_slice_produces_empty_object() {
let body = json_body(&[]);
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(parsed.as_object().unwrap().is_empty());
}
#[test]
fn json_body_roundtrips_through_serde() {
let body = json_body(&[("k", "v")]);
let result = serde_json::from_slice::<serde_json::Value>(&body);
assert!(result.is_ok());
}
}