#![allow(missing_docs)]
use entropy_auth::webauthn::{
InMemoryPasskeyStore, PRF_SALT_LEN, PasskeyCredentialStore, PasskeyUser,
PrfAuthenticationRequest, PrfClientResult, PrfRegistrationRequest, PrfSalt,
RegistrationCeremony, RelyingParty, Uuid, WebAuthnError, prf,
};
#[test]
fn rp_builder_happy_path() {
let rp = RelyingParty::builder("example.com", "Example Corp")
.origin("https://example.com")
.build()
.expect("valid configuration should build");
assert_eq!(rp.rp_id(), "example.com");
assert_eq!(rp.rp_name(), "Example Corp");
assert_eq!(rp.origins(), &["https://example.com".to_owned()]);
}
#[test]
fn rp_builder_accepts_multiple_origins() {
let rp = RelyingParty::builder("example.com", "Example")
.origin("https://example.com")
.origin("https://app.example.com")
.build()
.expect("multi-origin should build");
assert_eq!(rp.origins().len(), 2);
}
#[test]
fn rp_builder_rejects_empty_rp_id() {
let err = RelyingParty::builder("", "Example")
.origin("https://example.com")
.build()
.expect_err("empty rp_id must be rejected");
assert!(err.is_invalid_configuration());
assert!(
!err.is_origin_mismatch() && !err.is_rp_id_mismatch(),
"config errors must not masquerade as runtime taxonomies",
);
}
#[test]
fn rp_builder_rejects_no_origins() {
let err = RelyingParty::builder("example.com", "Example")
.build()
.expect_err("missing origins must be rejected");
assert!(err.is_invalid_configuration());
}
#[test]
fn rp_builder_rejects_malformed_origin() {
let err = RelyingParty::builder("example.com", "Example")
.origin("not a valid url")
.build()
.expect_err("malformed origin must be rejected");
assert!(err.is_invalid_configuration());
}
#[test]
fn rp_debug_does_not_leak_internals() {
let rp = RelyingParty::builder("example.com", "Example")
.origin("https://example.com")
.build()
.unwrap();
let dbg = format!("{rp:?}");
assert!(dbg.contains("example.com"));
assert!(!dbg.contains("webauthn-rs"));
}
#[test]
fn prf_salt_generate_is_correct_length() {
let salt = PrfSalt::generate().expect("CSPRNG should produce 32 bytes");
assert_eq!(salt.as_bytes().len(), PRF_SALT_LEN);
}
#[test]
fn prf_salt_two_generations_differ() {
let a = PrfSalt::generate().unwrap();
let b = PrfSalt::generate().unwrap();
assert_ne!(a.as_bytes(), b.as_bytes(), "random salts must differ");
}
#[test]
fn prf_salt_from_bytes_round_trip() {
let bytes: Vec<u8> = (0u8..32).collect();
let salt = PrfSalt::from_bytes(bytes.clone()).unwrap();
assert_eq!(salt.as_bytes(), bytes.as_slice());
}
#[test]
fn prf_salt_rejects_wrong_length() {
let too_short = PrfSalt::from_bytes(vec![0; 16]).unwrap_err();
assert!(too_short.is_invalid_configuration());
let too_long = PrfSalt::from_bytes(vec![0; 64]).unwrap_err();
assert!(too_long.is_invalid_configuration());
}
#[test]
fn prf_salt_determinism_same_input_same_wire_form() {
let bytes: Vec<u8> = (0u8..32).map(|i| i.wrapping_mul(7)).collect();
let a = PrfSalt::from_bytes(bytes.clone()).unwrap();
let b = PrfSalt::from_bytes(bytes).unwrap();
assert_eq!(a.to_base64url(), b.to_base64url());
assert_eq!(a.as_bytes(), b.as_bytes());
}
#[test]
fn prf_salt_wire_form_is_url_safe_unpadded() {
let salt = PrfSalt::from_bytes(vec![0xFFu8; 32]).unwrap();
let wire = salt.to_base64url();
assert!(!wire.contains('='), "no padding");
assert!(!wire.contains('+'), "no standard-base64 plus");
assert!(!wire.contains('/'), "no standard-base64 slash");
}
#[test]
fn prf_salt_debug_redacts_bytes() {
let salt = PrfSalt::from_bytes(vec![0xAB; PRF_SALT_LEN]).unwrap();
let dbg = format!("{salt:?}");
assert!(dbg.contains("PrfSalt"));
assert!(!dbg.contains("AB"), "redacted form must not expose bytes");
assert!(!dbg.contains("ab"));
}
#[test]
fn registration_request_payload_shape() {
let salt = PrfSalt::from_bytes(vec![1u8; PRF_SALT_LEN]).unwrap();
let req = PrfRegistrationRequest::from_salt(&salt);
let v = req.to_json_value();
assert_eq!(v["eval"]["first"], salt.to_base64url());
}
#[test]
fn authentication_request_payload_shape() {
let salt = PrfSalt::from_bytes(vec![2u8; PRF_SALT_LEN]).unwrap();
let req = PrfAuthenticationRequest::from_salt(&salt);
let v = req.to_json_value();
assert_eq!(v["eval"]["first"], salt.to_base64url());
}
#[test]
fn registration_request_round_trip_serde() {
let salt = PrfSalt::from_bytes(vec![3u8; PRF_SALT_LEN]).unwrap();
let req = PrfRegistrationRequest::from_salt(&salt);
let s = serde_json::to_string(&req).unwrap();
let back: PrfRegistrationRequest = serde_json::from_str(&s).unwrap();
assert_eq!(back.eval_first_b64url, salt.to_base64url());
}
#[test]
fn inspect_registration_response_enabled_true_means_honored() {
let v = serde_json::json!({"prf": {"enabled": true}});
assert_eq!(
prf::inspect_registration_response(&v),
PrfClientResult::Honored
);
}
#[test]
fn inspect_registration_response_results_present_means_honored() {
let v = serde_json::json!({"prf": {"results": {"first": "AAAA"}}});
assert_eq!(
prf::inspect_registration_response(&v),
PrfClientResult::Honored
);
}
#[test]
fn inspect_registration_response_enabled_false_means_unsupported() {
let v = serde_json::json!({"prf": {"enabled": false}});
assert_eq!(
prf::inspect_registration_response(&v),
PrfClientResult::Unsupported
);
}
#[test]
fn inspect_registration_response_absent_block_means_absent() {
let v = serde_json::json!({});
assert_eq!(
prf::inspect_registration_response(&v),
PrfClientResult::Absent
);
}
#[test]
fn inspect_authentication_response_results_first_present_means_honored() {
let v = serde_json::json!({"prf": {"results": {"first": "AAAA"}}});
assert_eq!(
prf::inspect_authentication_response(&v),
PrfClientResult::Honored
);
}
#[test]
fn inspect_authentication_response_no_results_means_unsupported() {
let v = serde_json::json!({"prf": {}});
assert_eq!(
prf::inspect_authentication_response(&v),
PrfClientResult::Unsupported
);
}
#[test]
fn inspect_authentication_response_absent_block_means_absent() {
let v = serde_json::json!({});
assert_eq!(
prf::inspect_authentication_response(&v),
PrfClientResult::Absent
);
}
#[test]
fn prf_client_result_was_honored_only_for_honored_variant() {
assert!(PrfClientResult::Honored.was_honored());
assert!(!PrfClientResult::Unsupported.was_honored());
assert!(!PrfClientResult::Absent.was_honored());
}
#[test]
fn unknown_credential_lookup_returns_none() {
let store = InMemoryPasskeyStore::new();
let result = store.lookup_by_credential_id(b"does-not-exist").unwrap();
assert!(result.is_none(), "unknown credential must return None");
}
#[test]
fn unknown_user_lookup_returns_empty_vec() {
let store = InMemoryPasskeyStore::new();
let result = store.lookup_by_user(b"nobody").unwrap();
assert!(result.is_empty());
}
#[test]
fn remove_unknown_credential_returns_false() {
let mut store = InMemoryPasskeyStore::new();
assert!(
!store.remove(b"never-stored").unwrap(),
"remove on empty store returns false",
);
}
#[test]
fn update_unknown_credential_is_noop() {
let mut store = InMemoryPasskeyStore::new();
store.update_sign_count(b"never-stored", 42).unwrap();
let after = store.lookup_by_credential_id(b"never-stored").unwrap();
assert!(after.is_none());
}
#[test]
fn webauthn_error_public_query_surface_is_present() {
let _: fn(&WebAuthnError) -> bool = WebAuthnError::is_invalid_configuration;
let _: fn(&WebAuthnError) -> bool = WebAuthnError::is_challenge_mismatch;
let _: fn(&WebAuthnError) -> bool = WebAuthnError::is_attestation_failed;
let _: fn(&WebAuthnError) -> bool = WebAuthnError::is_origin_mismatch;
let _: fn(&WebAuthnError) -> bool = WebAuthnError::is_rp_id_mismatch;
let _: fn(&WebAuthnError) -> bool = WebAuthnError::is_credential_not_found;
let _: fn(&WebAuthnError) -> bool = WebAuthnError::is_sign_count_rollback;
let _: fn(&WebAuthnError) -> bool = WebAuthnError::is_user_verification_failed;
let _: fn(&WebAuthnError) -> bool = WebAuthnError::is_internal;
let _: fn(&WebAuthnError) -> Option<&str> = WebAuthnError::detail;
}
fn build_rp() -> RelyingParty {
RelyingParty::builder("example.com", "Example")
.origin("https://example.com")
.build()
.expect("rp should build")
}
fn begin_registration(request_prf: bool) -> entropy_auth::webauthn::RegistrationChallenge {
let rp = build_rp();
RegistrationCeremony::begin(
&rp,
PasskeyUser {
id: Uuid::new_v4(),
name: "alice@example.com".to_owned(),
display_name: "Alice".to_owned(),
},
None,
request_prf,
)
.expect("ceremony should begin")
}
#[test]
fn registration_merged_challenge_json_includes_prf_under_extensions() {
let challenge = begin_registration(true);
let merged = challenge.merged_challenge_json();
let prf_eval_first = merged
.pointer("/publicKey/extensions/prf/eval/first")
.expect("merged JSON must expose publicKey.extensions.prf.eval.first when PRF requested");
let prf_first_str = prf_eval_first.as_str().expect("eval.first is a string");
assert_eq!(
prf_first_str,
challenge
.prf_salt()
.expect("PRF was requested")
.to_base64url(),
"PRF salt under merged JSON must match the salt returned via prf_salt()",
);
}
#[test]
fn registration_merged_challenge_json_omits_prf_when_not_requested() {
let challenge = begin_registration(false);
let merged = challenge.merged_challenge_json();
assert!(
merged.pointer("/publicKey/extensions/prf").is_none(),
"merged JSON must NOT contain publicKey.extensions.prf when PRF was not requested",
);
assert!(challenge.prf_salt().is_none());
assert!(challenge.prf_extension_json().is_none());
}
#[test]
fn registration_merged_challenge_json_preserves_other_keys() {
let challenge = begin_registration(true);
let merged = challenge.merged_challenge_json();
let raw = challenge.challenge_json();
let public_key = merged
.get("publicKey")
.and_then(|v| v.as_object())
.expect("publicKey is an object");
for (key, value) in public_key {
if key == "extensions" {
continue;
}
assert_eq!(
Some(value),
raw.get("publicKey").and_then(|v| v.get(key)),
"merge must preserve publicKey.{key} verbatim",
);
}
if let Some(raw_exts) = raw
.get("publicKey")
.and_then(|pk| pk.get("extensions"))
.and_then(|e| e.as_object())
{
let merged_exts = merged
.get("publicKey")
.and_then(|pk| pk.get("extensions"))
.and_then(|e| e.as_object())
.expect("merged extensions object present");
for (key, value) in raw_exts {
assert_eq!(
Some(value),
merged_exts.get(key),
"merge must preserve publicKey.extensions.{key} verbatim",
);
}
}
}
#[test]
fn registration_merged_challenge_json_is_idempotent() {
let challenge = begin_registration(true);
let once = challenge.merged_challenge_json();
let twice = {
let mut v = once.clone();
if let (Some(prf_ext), Some(extensions)) = (
challenge.prf_extension_json(),
v.get_mut("publicKey")
.and_then(|pk| pk.get_mut("extensions"))
.and_then(|e| e.as_object_mut()),
) {
extensions.insert("prf".to_owned(), prf_ext.clone());
}
v
};
assert_eq!(once, twice, "merged_challenge_json must be idempotent");
}
#[test]
fn registration_into_state_with_salt_returns_same_bytes_as_borrowed_accessor() {
let challenge = begin_registration(true);
let borrowed = challenge
.prf_salt()
.expect("PRF was requested")
.as_bytes()
.to_vec();
let (_state, owned_salt) = challenge.into_state_with_salt();
let owned = owned_salt
.expect("PRF salt must round-trip")
.as_bytes()
.to_vec();
assert_eq!(
owned, borrowed,
"into_state_with_salt must return the exact bytes prf_salt() borrowed",
);
}
#[test]
fn registration_into_state_with_salt_none_when_prf_not_requested() {
let challenge = begin_registration(false);
let (_state, salt) = challenge.into_state_with_salt();
assert!(
salt.is_none(),
"salt slot must be None when PRF was not requested at begin()",
);
}
#[test]
fn registration_into_state_with_salt_some_when_prf_requested() {
let challenge = begin_registration(true);
let expected = challenge
.prf_salt()
.expect("PRF was requested")
.as_bytes()
.to_vec();
let (_state, salt) = challenge.into_state_with_salt();
let actual = salt.expect("salt slot must be Some when PRF was requested");
assert_eq!(
actual.as_bytes(),
expected.as_slice(),
"consumed salt bytes must equal the bytes prf_salt() returned before consumption",
);
assert_eq!(actual.as_bytes().len(), PRF_SALT_LEN);
}
#[test]
fn authentication_merged_challenge_json_omits_prf_when_no_credentials() {
let rp = build_rp();
let challenge = entropy_auth::webauthn::AuthenticationCeremony::begin(&rp, &[])
.expect("authentication ceremony should begin");
let merged = challenge.merged_challenge_json();
assert!(
merged.pointer("/publicKey/extensions/prf").is_none(),
"authentication merged JSON must NOT contain prf when no PRF-capable cred is provided",
);
assert!(challenge.prf_extension_json().is_none());
}
#[test]
fn webauthn_error_has_no_prf_predicate() {
#[allow(clippy::no_effect_underscore_binding)]
let _surface = (
WebAuthnError::is_invalid_configuration as fn(&WebAuthnError) -> bool,
WebAuthnError::is_challenge_mismatch as fn(&WebAuthnError) -> bool,
WebAuthnError::is_attestation_failed as fn(&WebAuthnError) -> bool,
WebAuthnError::is_origin_mismatch as fn(&WebAuthnError) -> bool,
WebAuthnError::is_rp_id_mismatch as fn(&WebAuthnError) -> bool,
WebAuthnError::is_credential_not_found as fn(&WebAuthnError) -> bool,
WebAuthnError::is_sign_count_rollback as fn(&WebAuthnError) -> bool,
WebAuthnError::is_user_verification_failed as fn(&WebAuthnError) -> bool,
WebAuthnError::is_internal as fn(&WebAuthnError) -> bool,
);
}