parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! Unit and property tests for `ScdAuthChallengeProducer` and `ScdAuthChallengeConsumer`.

use super::{ScdAuthChallengeConsumer, ScdAuthChallengeProducer};
use crate::chain::{Consumer, Producer, ProducerOutput, ProducerOutputKind};
use crate::test_utils::minimal_ctx;
use crate::types::ProbeSpec;
use http::{HeaderMap, HeaderValue};
use parlov_core::ResponseClass;

// --- ScdAuthChallengeProducer unit tests ---

#[test]
fn auth_challenge_producer_admits_auth_challenge() {
    assert!(ScdAuthChallengeProducer.admits(ResponseClass::AuthChallenge));
}

#[test]
fn auth_challenge_producer_does_not_admit_success() {
    assert!(!ScdAuthChallengeProducer.admits(ResponseClass::Success));
}

#[test]
fn auth_challenge_producer_extracts_bearer_with_realm_and_scope() {
    let mut headers = HeaderMap::new();
    headers.insert(
        http::header::WWW_AUTHENTICATE,
        HeaderValue::from_static("Bearer realm=\"api\", scope=\"read:items\""),
    );
    let out = ScdAuthChallengeProducer.extract(ResponseClass::AuthChallenge, &headers);
    assert_eq!(
        out,
        Some(ProducerOutput::AuthChallenge {
            scheme: "Bearer".to_owned(),
            realm: Some("api".to_owned()),
            scope: Some("read:items".to_owned()),
        }),
    );
}

#[test]
fn auth_challenge_producer_extracts_bearer_without_params() {
    let mut headers = HeaderMap::new();
    headers.insert(
        http::header::WWW_AUTHENTICATE,
        HeaderValue::from_static("Bearer"),
    );
    let out = ScdAuthChallengeProducer.extract(ResponseClass::AuthChallenge, &headers);
    assert_eq!(
        out,
        Some(ProducerOutput::AuthChallenge {
            scheme: "Bearer".to_owned(),
            realm: None,
            scope: None,
        }),
    );
}

#[test]
fn auth_challenge_producer_extracts_basic_scheme() {
    let mut headers = HeaderMap::new();
    headers.insert(
        http::header::WWW_AUTHENTICATE,
        HeaderValue::from_static("Basic realm=\"Secure Area\""),
    );
    let out = ScdAuthChallengeProducer.extract(ResponseClass::AuthChallenge, &headers);
    let Some(ProducerOutput::AuthChallenge { scheme, realm, .. }) = out else {
        panic!("expected Some(AuthChallenge)");
    };
    assert_eq!(scheme, "Basic");
    assert_eq!(realm, Some("Secure Area".to_owned()));
}

#[test]
fn auth_challenge_producer_returns_none_when_no_header() {
    let out = ScdAuthChallengeProducer.extract(ResponseClass::AuthChallenge, &HeaderMap::new());
    assert!(out.is_none(), "absent WWW-Authenticate must yield None");
}

#[test]
fn auth_challenge_producer_parses_realm_unquoted() {
    let mut headers = HeaderMap::new();
    headers.insert(
        http::header::WWW_AUTHENTICATE,
        HeaderValue::from_static("Bearer realm=api"),
    );
    let out = ScdAuthChallengeProducer.extract(ResponseClass::AuthChallenge, &headers);
    let Some(ProducerOutput::AuthChallenge { realm, .. }) = out else {
        panic!("expected Some(AuthChallenge)");
    };
    assert_eq!(realm, Some("api".to_owned()));
}

// --- ScdAuthChallengeConsumer unit tests ---

#[test]
fn auth_challenge_consumer_needs_auth_challenge() {
    assert_eq!(
        ScdAuthChallengeConsumer.needs(),
        ProducerOutputKind::AuthChallenge
    );
}

#[test]
fn auth_challenge_consumer_generates_one_spec() {
    let output = ProducerOutput::AuthChallenge {
        scheme: "Bearer".to_owned(),
        realm: Some("api".to_owned()),
        scope: None,
    };
    let specs = ScdAuthChallengeConsumer.generate(&minimal_ctx(), &output);
    assert_eq!(specs.len(), 1, "must generate exactly 1 spec");
}

#[test]
fn auth_challenge_consumer_bearer_header_includes_realm() {
    let output = ProducerOutput::AuthChallenge {
        scheme: "Bearer".to_owned(),
        realm: Some("api".to_owned()),
        scope: None,
    };
    let specs = ScdAuthChallengeConsumer.generate(&minimal_ctx(), &output);
    let ProbeSpec::Pair(pair) = &specs[0] else {
        panic!("expected Pair at index 0");
    };
    let auth = pair
        .baseline
        .headers
        .get("authorization")
        .expect("authorization header must be present");
    let val = auth.to_str().unwrap_or("");
    assert!(
        val.contains("Bearer") && val.contains("api"),
        "Bearer authorization must reference realm; got {val}"
    );
}

#[test]
fn auth_challenge_consumer_basic_uses_empty_credentials() {
    let output = ProducerOutput::AuthChallenge {
        scheme: "Basic".to_owned(),
        realm: Some("Secure Area".to_owned()),
        scope: None,
    };
    let specs = ScdAuthChallengeConsumer.generate(&minimal_ctx(), &output);
    let ProbeSpec::Pair(pair) = &specs[0] else {
        panic!("expected Pair at index 0");
    };
    let auth = pair
        .baseline
        .headers
        .get("authorization")
        .expect("authorization header must be present");
    assert_eq!(
        auth.to_str().unwrap_or(""),
        "Basic Og==",
        "Basic authorization must use empty credentials"
    );
}

#[test]
fn auth_challenge_consumer_unknown_scheme_uses_scheme_only() {
    let output = ProducerOutput::AuthChallenge {
        scheme: "Digest".to_owned(),
        realm: Some("realm".to_owned()),
        scope: None,
    };
    let specs = ScdAuthChallengeConsumer.generate(&minimal_ctx(), &output);
    let ProbeSpec::Pair(pair) = &specs[0] else {
        panic!("expected Pair at index 0");
    };
    let auth = pair
        .baseline
        .headers
        .get("authorization")
        .expect("authorization header must be present");
    assert_eq!(
        auth.to_str().unwrap_or(""),
        "Digest",
        "unknown scheme must produce scheme-only authorization"
    );
}

#[test]
fn auth_challenge_consumer_returns_empty_on_wrong_output_variant() {
    let output = ProducerOutput::Location("https://example.com".to_owned());
    let specs = ScdAuthChallengeConsumer.generate(&minimal_ctx(), &output);
    assert!(
        specs.is_empty(),
        "non-AuthChallenge output must produce no specs"
    );
}

// --- Property tests ---

use proptest::prelude::*;

// Any non-empty WWW-Authenticate header with a scheme → extracted scheme is non-empty.
proptest! {
    #[test]
    fn producer_extracted_scheme_is_non_empty(
        scheme in "[A-Za-z][A-Za-z0-9]{1,15}",
    ) {
        let raw = scheme.clone();
        let Ok(val) = HeaderValue::from_str(&raw) else { return Ok(()); };
        let mut headers = HeaderMap::new();
        headers.insert(http::header::WWW_AUTHENTICATE, val);
        let out = ScdAuthChallengeProducer.extract(ResponseClass::AuthChallenge, &headers);
        if let Some(ProducerOutput::AuthChallenge { scheme: extracted, .. }) = out {
            prop_assert!(!extracted.is_empty(), "extracted scheme must not be empty");
        }
    }
}

// Consumer always generates exactly 1 spec for any valid AuthChallenge output.
proptest! {
    #[test]
    fn consumer_always_generates_one_spec(
        scheme in "[A-Za-z][A-Za-z0-9]{1,15}",
        realm in proptest::option::of("[a-z]{1,20}"),
        scope in proptest::option::of("[a-z:]{1,20}"),
    ) {
        let output = ProducerOutput::AuthChallenge { scheme, realm, scope };
        let specs = ScdAuthChallengeConsumer.generate(&minimal_ctx(), &output);
        prop_assert_eq!(specs.len(), 1);
    }
}

// Both sides of the pair carry the same Authorization header value.
proptest! {
    #[test]
    fn consumer_baseline_and_probe_carry_same_authorization(
        scheme in "[A-Za-z][A-Za-z0-9]{1,15}",
        realm in proptest::option::of("[a-z]{1,20}"),
        scope in proptest::option::of("[a-z:]{1,20}"),
    ) {
        let output = ProducerOutput::AuthChallenge { scheme, realm, scope };
        let specs = ScdAuthChallengeConsumer.generate(&minimal_ctx(), &output);
        let ProbeSpec::Pair(pair) = &specs[0] else { return Ok(()); };
        prop_assert_eq!(
            pair.baseline.headers.get("authorization"),
            pair.probe.headers.get("authorization"),
            "baseline and probe must carry the same Authorization header"
        );
    }
}