test(auth): expand provider negative coverage

Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
(cherry picked from commit 12f6d2ec639ece2a669f7ea76d74ea4e72912209)
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
Cea Stapleton Cordasco
2026-08-03 17:03:51 -05:00
parent 33b11db767
commit d82afe9012
+430 -55
View File
@@ -42,26 +42,91 @@ fn capabilities(values: &[AuthorizationCapability]) -> CapabilitySet {
CapabilitySet::new(values.to_vec()).expect("synthetic capabilities are non-empty")
}
fn direct_request_with_expiry(
fn all_capabilities() -> [AuthorizationCapability; 10] {
[
AuthorizationCapability::CommunityRead,
AuthorizationCapability::CommunityWrite,
AuthorizationCapability::Moderate,
AuthorizationCapability::InviteMint,
AuthorizationCapability::InviteClaim,
AuthorizationCapability::MediaRead,
AuthorizationCapability::MediaWrite,
AuthorizationCapability::GitRead,
AuthorizationCapability::GitWrite,
AuthorizationCapability::AudioJoin,
]
}
fn capability_coverage_is_exhaustive(capability: AuthorizationCapability) {
match capability {
AuthorizationCapability::CommunityRead
| AuthorizationCapability::CommunityWrite
| AuthorizationCapability::Moderate
| AuthorizationCapability::InviteMint
| AuthorizationCapability::InviteClaim
| AuthorizationCapability::MediaRead
| AuthorizationCapability::MediaWrite
| AuthorizationCapability::GitRead
| AuthorizationCapability::GitWrite
| AuthorizationCapability::AudioJoin => {}
}
}
fn proof_method_for_transport(transport: AuthTransport) -> AuthMethod {
match transport {
AuthTransport::RelayWebSocket => AuthMethod::Nip42,
AuthTransport::HttpBridge | AuthTransport::Git | AuthTransport::MediaDownload => {
AuthMethod::Nip98
}
AuthTransport::MediaUpload => AuthMethod::Blossom,
AuthTransport::Audio => AuthMethod::Nip42,
}
}
fn all_contract_errors() -> [ProviderContractError; 21] {
[
ProviderContractError::EmptyCapabilitySet,
ProviderContractError::EmptyProfileId,
ProviderContractError::ProfileIdTooLong,
ProviderContractError::EmptyPolicyVersion,
ProviderContractError::PolicyVersionTooLong,
ProviderContractError::InvalidIssuedAt,
ProviderContractError::InvalidFreshnessBound,
ProviderContractError::FreshnessWindowTooLong,
ProviderContractError::InvalidRetryAfter,
ProviderContractError::InvalidProviderTimeout,
ProviderContractError::InvalidCorrelationId,
ProviderContractError::DirectRequestHasOwner,
ProviderContractError::AuthorizationDomainMismatch,
ProviderContractError::TransportMismatch,
ProviderContractError::AssertionNotYetValid,
ProviderContractError::AssertionExpired,
ProviderContractError::KeyAttestationMismatch,
ProviderContractError::MissingKeyAttestation,
ProviderContractError::DelegationRequired,
ProviderContractError::DelegatedOwnerMismatch,
ProviderContractError::DelegationExpired,
]
}
fn direct_request_for_transport(
actor: &Keys,
transport: AuthTransport,
proof_method: AuthMethod,
not_before: Option<u64>,
expiry: u64,
requested: CapabilitySet,
) -> AuthorizationRequest {
let proof = VerifiedNostrProof::new(
domain(1),
AuthTransport::RelayWebSocket,
actor.public_key(),
AuthMethod::Nip42,
None,
)
.expect("synthetic proof is valid");
) -> Result<AuthorizationRequest, ProviderContractError> {
let proof =
VerifiedNostrProof::new(domain(1), transport, actor.public_key(), proof_method, None)
.expect("synthetic proof is valid");
let assertion = VerifiedFederatedAssertion::new(
domain(1),
AuthTransport::RelayWebSocket,
transport,
principal(),
Some(VerifiedKeyAttestation::new(actor.public_key())),
AssertionTransport::TrustedProxy,
None,
not_before.map(AssertionNotBefore::new),
AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"),
);
AuthorizationRequest::direct(
@@ -72,6 +137,21 @@ fn direct_request_with_expiry(
Uuid::from_u128(20),
NOW,
)
}
fn direct_request_with_expiry(
actor: &Keys,
expiry: u64,
requested: CapabilitySet,
) -> AuthorizationRequest {
direct_request_for_transport(
actor,
AuthTransport::RelayWebSocket,
AuthMethod::Nip42,
None,
expiry,
requested,
)
.expect("synthetic direct request is valid")
}
@@ -243,6 +323,48 @@ async fn current_allow_returns_request_scoped_snapshot() {
assert_eq!(snapshot.reason(), ProviderAllowReason::CurrentPolicy);
}
#[tokio::test]
async fn allowed_snapshot_preserves_every_requested_transport_scope() {
let transports = [
AuthTransport::RelayWebSocket,
AuthTransport::HttpBridge,
AuthTransport::Git,
AuthTransport::MediaUpload,
AuthTransport::MediaDownload,
AuthTransport::Audio,
];
for transport in transports {
let proof_method = proof_method_for_transport(transport);
let actor = Keys::generate();
let request = direct_request_for_transport(
&actor,
transport,
proof_method,
None,
200,
capabilities(&[AuthorizationCapability::CommunityRead]),
)
.expect("synthetic direct request is valid");
let provider = FakeProvider::returning(allow_for(
&request,
request.requested_capabilities().clone(),
"version-a",
90,
180,
));
let AuthorizationOutcome::Allow(snapshot) =
resolve_authorization(&provider, &request, NOW, provider_timeout()).await
else {
panic!("current provider policy must allow every transport profile");
};
assert_eq!(snapshot.transport(), transport);
assert_eq!(snapshot.proof_method(), proof_method);
assert_eq!(snapshot.actor_pubkey(), actor.public_key());
}
}
#[tokio::test]
async fn explicit_denial_is_preserved() {
let actor = Keys::generate();
@@ -345,6 +467,46 @@ async fn stale_and_future_provider_decisions_deny() {
);
}
#[tokio::test]
async fn provider_time_boundaries_and_current_assertion_are_exact() {
let actor = Keys::generate();
let request = direct_request_for_transport(
&actor,
AuthTransport::RelayWebSocket,
AuthMethod::Nip42,
Some(NOW),
200,
capabilities(&[AuthorizationCapability::CommunityRead]),
)
.expect("assertion with not-before equal to server time is current");
let issued_now = FakeProvider::returning(allow_for(
&request,
request.requested_capabilities().clone(),
"version-a",
NOW,
180,
));
assert!(matches!(
resolve_authorization(&issued_now, &request, NOW, provider_timeout()).await,
AuthorizationOutcome::Allow(_)
));
let stale_at_now = FakeProvider::returning(allow_for(
&request,
request.requested_capabilities().clone(),
"version-a",
90,
NOW,
));
let AuthorizationOutcome::Deny(denial) =
resolve_authorization(&stale_at_now, &request, NOW, provider_timeout()).await
else {
panic!("freshness ending at server time must deny");
};
assert_eq!(denial.reason(), AuthorizationDenialReason::StaleDecision);
}
#[tokio::test]
async fn domain_principal_and_capability_mismatches_deny() {
let actor = Keys::generate();
@@ -462,6 +624,55 @@ async fn invite_mint_does_not_authorize_invite_claim() {
);
}
#[test]
fn capability_sets_are_normalized_and_deduplicated() {
let normalized = CapabilitySet::new(vec![
AuthorizationCapability::GitWrite,
AuthorizationCapability::CommunityRead,
AuthorizationCapability::GitWrite,
AuthorizationCapability::CommunityRead,
])
.expect("synthetic capabilities are non-empty");
assert_eq!(
normalized.as_slice(),
&[
AuthorizationCapability::CommunityRead,
AuthorizationCapability::GitWrite,
]
);
}
#[tokio::test]
async fn no_distinct_capability_authorizes_another_capability() {
let actor = Keys::generate();
for requested in all_capabilities() {
for granted in all_capabilities() {
if requested == granted {
continue;
}
let request = direct_request_with_expiry(&actor, 200, capabilities(&[requested]));
let provider = FakeProvider::returning(allow_for(
&request,
capabilities(&[granted]),
"version-a",
90,
180,
));
let AuthorizationOutcome::Deny(denial) =
resolve_authorization(&provider, &request, NOW, provider_timeout()).await
else {
panic!("a distinct capability must not widen provider authority");
};
assert_eq!(
denial.reason(),
AuthorizationDenialReason::MissingCapability
);
}
}
}
#[tokio::test]
async fn assertion_expiry_bounds_provider_freshness() {
let actor = Keys::generate();
@@ -487,6 +698,52 @@ async fn assertion_expiry_bounds_provider_freshness() {
assert_eq!(snapshot.effective_until(), 120);
}
#[tokio::test]
async fn identity_evidence_expiring_during_provider_resolution_denies() {
let actor = Keys::generate();
let direct = direct_request_with_expiry(
&actor,
120,
capabilities(&[AuthorizationCapability::CommunityRead]),
);
let direct_provider = FakeProvider::returning(allow_for(
&direct,
direct.requested_capabilities().clone(),
"version-a",
110,
180,
));
let AuthorizationOutcome::Deny(direct_denial) =
resolve_authorization(&direct_provider, &direct, 120, provider_timeout()).await
else {
panic!("assertion expiring during provider resolution must deny");
};
assert_eq!(
direct_denial.reason(),
AuthorizationDenialReason::IdentityEvidenceExpired
);
let delegate = Keys::generate();
let owner = Keys::generate();
let delegated = delegated_request(&delegate, &owner, 140);
let delegated_provider = FakeProvider::returning(allow_for(
&delegated,
delegated.requested_capabilities().clone(),
"version-a",
130,
180,
));
let AuthorizationOutcome::Deny(delegated_denial) =
resolve_authorization(&delegated_provider, &delegated, 140, provider_timeout()).await
else {
panic!("delegation expiring during provider resolution must deny");
};
assert_eq!(
delegated_denial.reason(),
AuthorizationDenialReason::IdentityEvidenceExpired
);
}
#[tokio::test]
async fn delegated_owner_admission_does_not_require_owner_assertion() {
let actor = Keys::generate();
@@ -571,10 +828,16 @@ fn provider_contract_rejects_malformed_values() {
AuthorizationProfileId::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)),
Err(ProviderContractError::ProfileIdTooLong)
);
assert!(AuthorizationProfileId::new("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok());
assert_eq!(
PolicyVersion::new(""),
Err(ProviderContractError::EmptyPolicyVersion)
);
assert_eq!(
PolicyVersion::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)),
Err(ProviderContractError::PolicyVersionTooLong)
);
assert!(PolicyVersion::new("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok());
assert_eq!(
RetryAfter::new(0),
Err(ProviderContractError::InvalidRetryAfter)
@@ -583,6 +846,12 @@ fn provider_contract_rejects_malformed_values() {
RetryAfter::new(MAX_RETRY_AFTER_SECONDS + 1),
Err(ProviderContractError::InvalidRetryAfter)
);
assert_eq!(
RetryAfter::new(MAX_RETRY_AFTER_SECONDS)
.expect("maximum retry hint is valid")
.seconds(),
MAX_RETRY_AFTER_SECONDS
);
assert_eq!(
ProviderTimeout::new(Duration::ZERO),
Err(ProviderContractError::InvalidProviderTimeout)
@@ -591,6 +860,12 @@ fn provider_contract_rejects_malformed_values() {
ProviderTimeout::new(MAX_PROVIDER_TIMEOUT + Duration::from_nanos(1)),
Err(ProviderContractError::InvalidProviderTimeout)
);
assert_eq!(
ProviderTimeout::new(MAX_PROVIDER_TIMEOUT)
.expect("maximum provider timeout is valid")
.duration(),
MAX_PROVIDER_TIMEOUT
);
assert_eq!(
ProviderAllow::new(
domain(1),
@@ -615,6 +890,18 @@ fn provider_contract_rejects_malformed_values() {
),
Err(ProviderContractError::InvalidFreshnessBound)
);
assert_eq!(
ProviderAllow::new(
domain(1),
principal(),
profile(),
capabilities(&[AuthorizationCapability::CommunityRead]),
policy_version("version-a"),
100,
99,
),
Err(ProviderContractError::InvalidFreshnessBound)
);
assert_eq!(
ProviderAllow::new(
domain(1),
@@ -627,6 +914,16 @@ fn provider_contract_rejects_malformed_values() {
),
Err(ProviderContractError::FreshnessWindowTooLong)
);
assert!(ProviderAllow::new(
domain(1),
principal(),
profile(),
capabilities(&[AuthorizationCapability::CommunityRead]),
policy_version("version-a"),
100,
100 + MAX_PROVIDER_FRESHNESS_SECONDS,
)
.is_ok());
}
#[test]
@@ -840,17 +1137,38 @@ fn request_construction_rejects_mismatched_verified_evidence() {
async fn request_decision_snapshot_and_errors_are_redaction_safe() {
let actor = Keys::generate();
let request = direct_request(&actor);
let request_debug = concat!(
"AuthorizationRequest { authorization_domain: \"[redacted]\", ",
"transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ",
"proof_method: \"[redacted]\", ",
"authority: \"[redacted]\", principal: \"[redacted]\", ",
"profile_id: \"[redacted]\", requested_capabilities: \"[redacted]\", ",
"correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ",
"evidence_valid_until: \"[redacted]\" }"
);
// Keep this exact-shape assertion deliberately: adding a field must fail until
// the disclosure contract explicitly confirms that the new field is redacted.
assert_eq!(format!("{request:?}"), request_debug);
let delegate = Keys::generate();
let owner = Keys::generate();
let delegated_request = delegated_request(&delegate, &owner, 180);
assert_eq!(format!("{delegated_request:?}"), request_debug);
assert_eq!(
format!("{request:?}"),
concat!(
"AuthorizationRequest { authorization_domain: \"[redacted]\", ",
"transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ",
"proof_method: \"[redacted]\", ",
"authority: \"[redacted]\", principal: \"[redacted]\", ",
"profile_id: \"[redacted]\", requested_capabilities: \"[redacted]\", ",
"correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ",
"evidence_valid_until: \"[redacted]\" }"
)
format!("{:?}", request.authority()),
"AuthorizationAuthority(\"[redacted]\")"
);
assert_eq!(
format!("{:?}", delegated_request.authority()),
"AuthorizationAuthority(\"[redacted]\")"
);
assert_eq!(
format!("{:?}", request.decision_source()),
"DecisionSource(\"[redacted]\")"
);
assert_eq!(
format!("{:?}", delegated_request.decision_source()),
"DecisionSource(\"[redacted]\")"
);
let allow = ProviderAllow::new(
@@ -915,6 +1233,44 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() {
"retry_after: \"[redacted]\" }"
)
);
assert_eq!(
format!(
"{:?}",
ProviderDecision::Deny(AuthorizationDenial::new(
AuthorizationDenialReason::ProviderDenied,
))
),
"ProviderDecision(\"[redacted]\")"
);
assert_eq!(
format!(
"{:?}",
ProviderDecision::Unavailable(ProviderUnavailable::new(
ProviderUnavailableReason::DependencyUnavailable,
None,
))
),
"ProviderDecision(\"[redacted]\")"
);
assert_eq!(
format!(
"{:?}",
AuthorizationOutcome::Deny(AuthorizationDenial::new(
AuthorizationDenialReason::ProviderDenied,
))
),
"AuthorizationOutcome(\"[redacted]\")"
);
assert_eq!(
format!(
"{:?}",
AuthorizationOutcome::Unavailable(ProviderUnavailable::new(
ProviderUnavailableReason::DependencyUnavailable,
None,
))
),
"AuthorizationOutcome(\"[redacted]\")"
);
assert_eq!(
format!("{:?}", provider_timeout()),
"ProviderTimeout(\"[redacted]\")"
@@ -932,17 +1288,58 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() {
"CapabilitySet(\"[redacted]\")"
);
for error in [
ProviderContractError::EmptyCapabilitySet,
ProviderContractError::EmptyProfileId,
ProviderContractError::EmptyPolicyVersion,
ProviderContractError::AuthorizationDomainMismatch,
ProviderContractError::DelegatedOwnerMismatch,
for capability in all_capabilities() {
capability_coverage_is_exhaustive(capability);
assert_eq!(
format!("{capability:?}"),
"AuthorizationCapability(\"[redacted]\")"
);
}
for reason in [
AuthorizationDenialReason::ProviderDenied,
AuthorizationDenialReason::AuthorizationDomainMismatch,
AuthorizationDenialReason::PrincipalMismatch,
AuthorizationDenialReason::AuthorizationProfileMismatch,
AuthorizationDenialReason::MissingCapability,
AuthorizationDenialReason::StaleDecision,
AuthorizationDenialReason::FutureDecision,
AuthorizationDenialReason::IdentityEvidenceExpired,
] {
let rendered = error.to_string();
assert!(!rendered.contains("idp.example"));
assert!(!rendered.contains("subject-123"));
assert!(!rendered.contains("private-policy-version"));
assert_eq!(
format!("{reason:?}"),
"AuthorizationDenialReason(\"[redacted]\")"
);
}
for reason in [
ProviderUnavailableReason::TemporarilyUnavailable,
ProviderUnavailableReason::Timeout,
ProviderUnavailableReason::DependencyUnavailable,
] {
assert_eq!(
format!("{reason:?}"),
"ProviderUnavailableReason(\"[redacted]\")"
);
}
assert_eq!(
format!("{:?}", ProviderAllowReason::CurrentPolicy),
"ProviderAllowReason(\"[redacted]\")"
);
assert_eq!(
format!("{:?}", RetryAfter::new(30).expect("retry hint is valid")),
"RetryAfter(\"[redacted]\")"
);
for error in all_contract_errors() {
for rendered in [error.to_string(), format!("{error:?}")] {
for private_value in [
"idp.example",
"subject-123",
"profile-1",
"private-policy-version",
] {
assert!(!rendered.contains(private_value));
}
}
}
}
@@ -972,29 +1369,7 @@ fn provider_trait_is_object_safe_and_codes_are_unique() {
codes.dedup();
assert_eq!(codes.len(), 12);
let contract_errors = [
ProviderContractError::EmptyCapabilitySet,
ProviderContractError::EmptyProfileId,
ProviderContractError::ProfileIdTooLong,
ProviderContractError::EmptyPolicyVersion,
ProviderContractError::PolicyVersionTooLong,
ProviderContractError::InvalidIssuedAt,
ProviderContractError::InvalidFreshnessBound,
ProviderContractError::FreshnessWindowTooLong,
ProviderContractError::InvalidRetryAfter,
ProviderContractError::InvalidProviderTimeout,
ProviderContractError::InvalidCorrelationId,
ProviderContractError::DirectRequestHasOwner,
ProviderContractError::AuthorizationDomainMismatch,
ProviderContractError::TransportMismatch,
ProviderContractError::AssertionNotYetValid,
ProviderContractError::AssertionExpired,
ProviderContractError::KeyAttestationMismatch,
ProviderContractError::MissingKeyAttestation,
ProviderContractError::DelegationRequired,
ProviderContractError::DelegatedOwnerMismatch,
ProviderContractError::DelegationExpired,
];
let contract_errors = all_contract_errors();
let mut contract_codes = contract_errors
.iter()
.copied()