ppoppo_sdk_core/audit/sink.rs
1//! `AuditSink` port + `AuditEvent` value type + ship-with adapters
2//! (`NoopAuditSink`, `MemoryAuditSink`).
3//!
4//! See module-level docs in `audit/mod.rs` for the deep-module rationale
5//! (β1 + composition + non-blocking failure-mode contract).
6
7use std::collections::BTreeMap;
8#[cfg(any(test, feature = "test-support"))]
9use std::sync::{Arc, Mutex};
10
11use async_trait::async_trait;
12use jiff::Timestamp;
13use serde::{Deserialize, Serialize};
14
15use super::RateLimitKey;
16
17/// Audit emission port for verify-failure events (M48).
18///
19/// **One method, no `Result`**: M48 is observability, NOT auth-flow
20/// critical. Adapters that fail to persist MUST log internally via
21/// `tracing::error!` and continue — the verify hot path NEVER bubbles
22/// audit failures into the auth contract. The trait surface enforces
23/// this by giving the caller no error to propagate in the first place.
24///
25/// **`&self` not `&mut self`**: a single verifier emits concurrently
26/// across many request handlers; interior mutability lives inside each
27/// adapter (e.g. [`MemoryAuditSink`] uses `Mutex`).
28///
29/// **`Send + Sync` bounds**: required for `Arc<dyn AuditSink>` use in
30/// Axum handlers and tokio tasks.
31#[async_trait]
32pub trait AuditSink: std::fmt::Debug + Send + Sync {
33 async fn record_failure(&self, event: AuditEvent);
34}
35
36/// Single typed event emitted on every `BearerVerifier::verify` rejection.
37///
38/// `kind` drives audit-pivot grouping; `source_id` drives rate-limiting
39/// and per-source dashboards; `metadata` carries free-form context
40/// (engine M-row identifier for `Other`, claim names, etc.).
41///
42/// **Best-effort hint decoding**: `client_id_hint` and `kid_hint` come
43/// from the rejected token's payload/header via defensive base64+JSON
44/// parse. Either may be `None` if the token was malformed; callers MUST
45/// NOT treat absence as a security signal — by definition the token
46/// was rejected, so its claims are untrusted. The hints exist for
47/// grouping, not authentication.
48///
49/// **`source_id` derivation**: per Phase 9 design call (e), source_id
50/// is the compound `client_id_hint ‖ kid_hint` key. Anonymous /
51/// kid-less rejections collapse into a canonical `"anon::nokid"`
52/// bucket so attacker-controlled token mangling can't explode the
53/// bucket count. See [`compose_source_id`] and [`AuditEvent::from_hints`].
54///
55/// All fields are `pub` so adapters can serialize them or pivot on
56/// arbitrary subsets. The canonical construction path is
57/// [`AuditEvent::from_hints`], which guarantees `source_id` matches
58/// the hints. Hand-constructing with mismatched values is technically
59/// possible (and useful for fault-injection in tests) but a code
60/// review concern in production.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct AuditEvent {
63 /// Failure classification — drives audit-pivot grouping.
64 pub kind: VerifyErrorKind,
65 /// Wall-clock at engine reject (UTC, RFC 3339 wire format).
66 pub occurred_at: Timestamp,
67 /// Compound `client_id_hint ‖ kid_hint` key for rate-limiting +
68 /// per-source pivot.
69 pub source_id: String,
70 /// Best-effort `client_id` claim from the rejected token's payload.
71 pub client_id_hint: Option<String>,
72 /// Best-effort `kid` from the rejected token's header.
73 pub kid_hint: Option<String>,
74 /// Free-form structured context — engine M-row identifier for
75 /// `Other`, claim names for telemetry, etc. `BTreeMap` (not
76 /// `HashMap`) for deterministic ordering in snapshot tests.
77 pub metadata: BTreeMap<String, serde_json::Value>,
78}
79
80impl AuditEvent {
81 /// Canonical constructor — composes `source_id` from the hints so
82 /// the two never disagree. Production callers (Phase 9.D
83 /// `PasJwtVerifier::verify`) use this.
84 #[must_use]
85 pub fn from_hints(
86 kind: VerifyErrorKind,
87 occurred_at: Timestamp,
88 client_id_hint: Option<String>,
89 kid_hint: Option<String>,
90 metadata: BTreeMap<String, serde_json::Value>,
91 ) -> Self {
92 let source_id = compose_source_id(client_id_hint.as_deref(), kid_hint.as_deref());
93 Self {
94 kind,
95 occurred_at,
96 source_id,
97 client_id_hint,
98 kid_hint,
99 metadata,
100 }
101 }
102
103 /// id_token-specific canonical constructor (Phase 10.11.D, δ2).
104 ///
105 /// Composes the 3-tuple `azp ‖ aud ‖ kid` source key — strongest
106 /// per-source discrimination for log-flood DoS prevention on the
107 /// RP side. azp (when present) is the canonical "authorized party";
108 /// aud may be array (the engine surfaces only the first element to
109 /// the hint pipeline); kid identifies the signing key.
110 ///
111 /// **Field repurpose**: stores `azp_hint` in
112 /// [`Self::client_id_hint`] (the SDK-shaped "authorized party"
113 /// shares semantic with access-token's `client_id`); pushes
114 /// `aud_hint` into `metadata` under the key `"aud_hint"`. Dashboard
115 /// pivots on the access-token side use `client_id_hint` directly;
116 /// id_token pivots use the same field plus the `aud_hint` metadata
117 /// entry.
118 ///
119 /// Production caller: [`crate::oidc::PasIdTokenVerifier::emit_failure`].
120 #[must_use]
121 pub fn from_id_token_hints(
122 kind: VerifyErrorKind,
123 occurred_at: Timestamp,
124 azp_hint: Option<String>,
125 aud_hint: Option<String>,
126 kid_hint: Option<String>,
127 mut metadata: BTreeMap<String, serde_json::Value>,
128 ) -> Self {
129 let source_id = compose_id_token_source_id(
130 azp_hint.as_deref(),
131 aud_hint.as_deref(),
132 kid_hint.as_deref(),
133 );
134 if let Some(aud) = &aud_hint {
135 metadata.insert(
136 "aud_hint".to_owned(),
137 serde_json::Value::String(aud.clone()),
138 );
139 }
140 Self {
141 kind,
142 occurred_at,
143 source_id,
144 client_id_hint: azp_hint,
145 kid_hint,
146 metadata,
147 }
148 }
149
150 /// Per-bucket rate-limit key. By default 1:1 with `source_id`.
151 /// Composing once at construction keeps this O(1).
152 #[must_use]
153 pub fn rate_limit_key(&self) -> RateLimitKey {
154 RateLimitKey::new(self.source_id.clone())
155 }
156}
157
158/// Compose a Phase 9 (e) compound source key from optional hints.
159///
160/// Free function (not a method) because it is referentially transparent:
161/// same inputs → same output, no Self needed. `PasJwtVerifier::verify`
162/// (Phase 9.D) calls this to derive the `source_id` field from a
163/// best-effort token decode; tests use it to construct expected keys
164/// without building an `AuditEvent` first.
165///
166/// Sentinels `"anon"` (missing client_id) and `"nokid"` (missing kid)
167/// collapse anonymous rejections into a single bucket so attacker-
168/// controlled token mangling can't explode bucket count. The `"::"`
169/// separator is a deliberate choice for grep-friendliness ("all
170/// anon::*" surfaces every anonymous bucket as a glob).
171#[must_use]
172pub fn compose_source_id(client_id_hint: Option<&str>, kid_hint: Option<&str>) -> String {
173 let cid = client_id_hint.unwrap_or("anon");
174 let kid = kid_hint.unwrap_or("nokid");
175 format!("{cid}::{kid}")
176}
177
178/// Phase 10.11.D δ2 — id_token compound source key from `azp ‖ aud ‖ kid`.
179///
180/// Sibling of [`compose_source_id`] for the RP-side id_token verify
181/// pipeline. Three components give strongest discrimination for
182/// log-flood DoS prevention: the canonical authorized party (`azp`),
183/// the audience (`aud`, first element when array), and the signing key
184/// (`kid`). Sentinels `"anon"` / `"noaud"` / `"nokid"` collapse
185/// anonymous rejections into canonical buckets.
186///
187/// Used by [`AuditEvent::from_id_token_hints`] to derive `source_id`;
188/// boundary tests reference it to construct expected keys without
189/// building a full event first.
190#[must_use]
191pub fn compose_id_token_source_id(
192 azp_hint: Option<&str>,
193 aud_hint: Option<&str>,
194 kid_hint: Option<&str>,
195) -> String {
196 let azp = azp_hint.unwrap_or("anon");
197 let aud = aud_hint.unwrap_or("noaud");
198 let kid = kid_hint.unwrap_or("nokid");
199 format!("{azp}::{aud}::{kid}")
200}
201
202/// Failure classification — mirrors the
203/// [`VerifyError`](crate::token::VerifyError) and
204/// [`IdVerifyError`](crate::oidc::IdVerifyError) surfaces but lives at
205/// the audit layer.
206///
207/// Stable, low-cardinality enum suitable for audit-dashboard pivots.
208/// `MissingClaim` carries the claim name as `String` (~6 well-known
209/// claims in production: `"aud"`, `"iat"`, `"jti"`, `"sub"`, `"exp"`,
210/// `"client_id"`) — still low-cardinality at the dashboard layer.
211/// The String allocation (vs `&'static str` in
212/// [`VerifyError::MissingClaim`](crate::token::VerifyError::MissingClaim))
213/// is the cost of full serde round-trip support; per-event overhead
214/// is negligible on the rare-failure emission path.
215///
216/// `Other` is a flat catch-all — engine M-row identifier goes into
217/// [`AuditEvent::metadata`] under the key `"engine_msg"` to keep this
218/// enum bounded.
219///
220/// ── id_token nesting (Phase 10.11.B) ───────────────────────────────────
221///
222/// OIDC-specific failure rows (M66-M73 + M29-mirror) live inside
223/// [`IdTokenFailureKind`] under a single nested variant `IdToken(_)`.
224/// The audit-pivot pattern callers use:
225///
226/// ```ignore
227/// match event.kind {
228/// VerifyErrorKind::IdToken(_) => bucket_for("id_token failures"),
229/// VerifyErrorKind::IdTokenAsBearer => bucket_for("M73 misuse"),
230/// VerifyErrorKind::Expired => bucket_for("token expired"),
231/// // ...
232/// }
233/// ```
234///
235/// A single `IdToken(_)` arm filters the entire OIDC failure family —
236/// dashboards do not need to enumerate 14 names. Profile-agnostic JOSE
237/// failures (`Expired`, `IssuerInvalid`, `AudienceInvalid`,
238/// `MissingClaim`, `SignatureInvalid`, `KeysetUnavailable`,
239/// `InvalidFormat`) reuse the existing flat variants — they are shared
240/// between access_token and id_token verify surfaces, and forcing
241/// consumers to write a 2nd match arm for "id_token expired"
242/// distinct from "access_token expired" would noise the call site
243/// without changing the operator action ("token expired, refresh").
244/// The audit log distinguishes the two via [`AuditEvent::source_id`]
245/// composition (compound `azp ‖ aud ‖ kid` for id_token vs
246/// `client_id ‖ kid` for access).
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(tag = "tag", content = "claim")]
249pub enum VerifyErrorKind {
250 InvalidFormat,
251 SignatureInvalid,
252 Expired,
253 IssuerInvalid,
254 AudienceInvalid,
255 MissingClaim(String),
256 KeysetUnavailable,
257 IdTokenAsBearer,
258 /// Phase 10.11.B — nested OIDC-specific failure family. See
259 /// [`IdTokenFailureKind`].
260 IdToken(IdTokenFailureKind),
261 /// Phase 11.Z — token's `sv` claim below authoritative substrate
262 /// (engine `check_epoch` reject). Distinct from `Expired`: stale
263 /// is revocation-driven, expired is `exp`-bound. Audit dashboards
264 /// pivot on this kind to surface break-glass propagation lag.
265 SessionVersionStale,
266 /// Phase 11.Z — engine `check_epoch` could not reach its substrate
267 /// (cache miss + fetcher transient). Fail-closed surface; audit
268 /// dashboards pivot on this kind to surface substrate health
269 /// problems distinct from cryptographic failure.
270 SessionVersionLookupUnavailable,
271 /// Phase 11.Z 0.10.0 (RFC_2026-05-08 §4.2) — L2 session liveness
272 /// reject. Token's `sid` resolved to a row absent OR with
273 /// `revoked_at` set. Distinct from `SessionVersionStale` (L1):
274 /// L2 = consumer-DB row revocation; L1 = cross-service break-glass
275 /// propagation. Pivots help operators distinguish per-session
276 /// logout traffic from cluster-wide break-glass bumps.
277 SessionRevoked,
278 /// Phase 11.Z 0.10.0 — L2 session liveness substrate unreachable.
279 /// Fail-closed surface; audit dashboards pivot on this kind to
280 /// surface consumer-DB substrate health problems distinct from
281 /// `SessionVersionLookupUnavailable` (L1 substrate health).
282 SessionLivenessLookupUnavailable,
283 Other,
284}
285
286/// id_token-specific failure classification (Phase 10.11.B).
287///
288/// Mirrors the OIDC-specific variants of
289/// [`IdVerifyError`](crate::oidc::IdVerifyError). Nests inside
290/// [`VerifyErrorKind::IdToken`] so dashboard pivots can filter "all
291/// id_token failures" via a single match arm.
292///
293/// Variants in this enum cover M66-M73 + M29-mirror — JOSE-layer
294/// rejections (Expired, SignatureInvalid, etc.) are *shared* with
295/// access_token and remain on the outer [`VerifyErrorKind`] flat
296/// variants. The boundary mapping (`From<&IdVerifyError> for
297/// VerifyErrorKind`, lands in Phase 10.11.D) routes JOSE rejections to
298/// the flat variants and id_token-specific rejections to
299/// `IdToken(IdTokenFailureKind::*)`.
300///
301/// Two payload-carrying variants:
302///
303/// - `UnknownClaim(String)` — engine M72 carries the offending claim
304/// name (e.g. `"backdoor"`, `"email"` at Openid scope). Audit logs
305/// distinguish forgery (random key) from issuer drift (legitimate
306/// OIDC claim outside scope) by reading the inner string.
307/// - `CatMismatch(String)` — engine M29-mirror carries the offending
308/// `cat` value (e.g. `"access"` for an attacker presenting a forged
309/// id_token; `""` for a stripped-claims forgery; arbitrary string
310/// for bespoke forgery).
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub enum IdTokenFailureKind {
313 /// M66 — `nonce` claim absent.
314 NonceMissing,
315 /// M66 — `nonce` claim present but does not match expected.
316 NonceMismatch,
317 /// M67 — `at_hash` claim absent while access_token binding is configured.
318 AtHashMissing,
319 /// M67 — `at_hash` claim present but does not match.
320 AtHashMismatch,
321 /// M68 — `c_hash` claim absent while authorization_code binding is configured.
322 CHashMissing,
323 /// M68 — `c_hash` claim present but does not match.
324 CHashMismatch,
325 /// M69 — `azp` claim absent on multi-aud id_token.
326 AzpMissing,
327 /// M69 — `azp` claim present but does not equal expected client_id.
328 AzpMismatch,
329 /// M70 — `auth_time` claim absent while max_age is configured.
330 AuthTimeMissing,
331 /// M70 — `now - auth_time > max_age`.
332 AuthTimeStale,
333 /// M71 — `acr` claim absent while acr_values is configured.
334 AcrMissing,
335 /// M71 — `acr` claim present but not in allowlist.
336 AcrNotAllowed,
337 /// M72 — claim name outside per-scope allowlist. Carries name.
338 UnknownClaim(String),
339 /// M29-mirror — `cat` claim value is not `"id"`. Carries the
340 /// offending value.
341 CatMismatch(String),
342}
343
344/// Default sink — explicitly does nothing.
345///
346/// Used when a consumer constructs `PasJwtVerifier::from_jwks_url`
347/// without calling `with_audit`. Named "Noop" (not "Empty" / "Null")
348/// so call sites read as an explicit choice. Cheap to clone; carries
349/// no state.
350#[derive(Debug, Default, Clone)]
351pub struct NoopAuditSink;
352
353#[async_trait]
354impl AuditSink for NoopAuditSink {
355 async fn record_failure(&self, _event: AuditEvent) {}
356}
357
358/// Test-support sink — accumulates events in insertion order behind a
359/// `Mutex`.
360///
361/// Test code calls [`MemoryAuditSink::events`] to assert ordering and
362/// contents. The `Arc<Mutex<Vec<...>>>` shape is deliberate:
363/// [`AuditSink::record_failure`] takes `&self`, so interior mutability
364/// is required; `Mutex` (rather than `RwLock`) is right because every
365/// access is a mutation.
366///
367/// Uses `std::sync::Mutex` rather than `tokio::sync::Mutex` so the
368/// adapter compiles unconditionally — no tokio runtime required when
369/// downstream consumers enable just `test-support` without `oauth` /
370/// `well-known-fetch`. This is safe because no `.await` happens while
371/// the lock is held; the future stays `Send`.
372#[cfg(any(test, feature = "test-support"))]
373#[derive(Debug, Default, Clone)]
374pub struct MemoryAuditSink {
375 events: Arc<Mutex<Vec<AuditEvent>>>,
376}
377
378#[cfg(any(test, feature = "test-support"))]
379impl MemoryAuditSink {
380 #[must_use]
381 pub fn new() -> Self {
382 Self::default()
383 }
384
385 /// Snapshot the recorded events. Returns a clone so the caller
386 /// doesn't hold the lock during assertion logic. `AuditEvent` is
387 /// shallow-cloneable; copying the whole vec is cheap for test
388 /// volumes.
389 pub fn events(&self) -> Vec<AuditEvent> {
390 self.events
391 .lock()
392 .unwrap_or_else(|poisoned| poisoned.into_inner())
393 .clone()
394 }
395
396 /// Convenience: count without cloning the vec.
397 pub fn len(&self) -> usize {
398 self.events
399 .lock()
400 .unwrap_or_else(|poisoned| poisoned.into_inner())
401 .len()
402 }
403
404 pub fn is_empty(&self) -> bool {
405 self.len() == 0
406 }
407}
408
409#[cfg(any(test, feature = "test-support"))]
410#[async_trait]
411impl AuditSink for MemoryAuditSink {
412 async fn record_failure(&self, event: AuditEvent) {
413 let mut events = self
414 .events
415 .lock()
416 .unwrap_or_else(|poisoned| poisoned.into_inner());
417 events.push(event);
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 fn fixture(kind: VerifyErrorKind, source_id: &str) -> AuditEvent {
426 AuditEvent {
427 kind,
428 occurred_at: Timestamp::UNIX_EPOCH,
429 source_id: source_id.to_owned(),
430 client_id_hint: None,
431 kid_hint: None,
432 metadata: BTreeMap::new(),
433 }
434 }
435
436 #[test]
437 fn compose_source_id_uses_compound_separator() {
438 assert_eq!(
439 compose_source_id(Some("rcw-client"), Some("k1")),
440 "rcw-client::k1"
441 );
442 }
443
444 #[test]
445 fn compose_source_id_collapses_anonymous_into_canonical_bucket() {
446 // Phase 9 (e): missing client_id and missing kid both collapse
447 // to canonical sentinels so attacker-controlled token mangling
448 // can't explode the bucket count.
449 assert_eq!(compose_source_id(None, None), "anon::nokid");
450 }
451
452 #[test]
453 fn compose_id_token_source_id_uses_three_tuple_separator() {
454 // δ2 — `azp ‖ aud ‖ kid`. Three-component key gives strongest
455 // discrimination for RP-side id_token DoS prevention.
456 assert_eq!(
457 compose_id_token_source_id(Some("rp-id"), Some("rp-aud"), Some("k1")),
458 "rp-id::rp-aud::k1"
459 );
460 }
461
462 #[test]
463 fn compose_id_token_source_id_collapses_anonymous_into_canonical() {
464 assert_eq!(
465 compose_id_token_source_id(None, None, None),
466 "anon::noaud::nokid"
467 );
468 }
469
470 #[test]
471 fn compose_id_token_source_id_partial_anonymity() {
472 // azp absent but aud present — common when the IdP omits azp
473 // on a single-aud id_token (OIDC §2 SHOULD, not MUST).
474 assert_eq!(
475 compose_id_token_source_id(None, Some("rp-aud"), Some("k1")),
476 "anon::rp-aud::k1"
477 );
478 // azp present, aud absent (multi-aud token where the engine
479 // failed to surface either element) — degenerate but possible.
480 assert_eq!(
481 compose_id_token_source_id(Some("rp-id"), None, Some("k1")),
482 "rp-id::noaud::k1"
483 );
484 }
485
486 #[test]
487 fn from_id_token_hints_derives_three_tuple_source_id_and_pushes_aud_into_metadata() {
488 let event = AuditEvent::from_id_token_hints(
489 VerifyErrorKind::IdToken(IdTokenFailureKind::NonceMismatch),
490 Timestamp::UNIX_EPOCH,
491 Some("rp-id".to_owned()),
492 Some("rp-aud".to_owned()),
493 Some("k1".to_owned()),
494 BTreeMap::new(),
495 );
496 assert_eq!(event.source_id, "rp-id::rp-aud::k1");
497 assert_eq!(event.client_id_hint.as_deref(), Some("rp-id"));
498 assert_eq!(event.kid_hint.as_deref(), Some("k1"));
499 assert_eq!(
500 event
501 .metadata
502 .get("aud_hint")
503 .and_then(|v| v.as_str()),
504 Some("rp-aud")
505 );
506 }
507
508 #[test]
509 fn compose_source_id_partial_anonymity() {
510 assert_eq!(compose_source_id(None, Some("k1")), "anon::k1");
511 assert_eq!(compose_source_id(Some("rcw"), None), "rcw::nokid");
512 }
513
514 #[test]
515 fn from_hints_derives_source_id_from_hints() {
516 let event = AuditEvent::from_hints(
517 VerifyErrorKind::SignatureInvalid,
518 Timestamp::UNIX_EPOCH,
519 Some("rcw".to_owned()),
520 Some("k1".to_owned()),
521 BTreeMap::new(),
522 );
523 assert_eq!(event.source_id, "rcw::k1");
524 assert_eq!(event.client_id_hint.as_deref(), Some("rcw"));
525 assert_eq!(event.kid_hint.as_deref(), Some("k1"));
526 }
527
528 #[test]
529 fn rate_limit_key_round_trip() {
530 let event = fixture(VerifyErrorKind::SignatureInvalid, "rcw::k1");
531 assert_eq!(event.rate_limit_key().as_str(), "rcw::k1");
532 }
533
534 #[test]
535 #[allow(clippy::expect_used)]
536 fn verify_error_kind_round_trips_through_serde() {
537 let kind = VerifyErrorKind::MissingClaim("aud".to_owned());
538 let json = serde_json::to_string(&kind).expect("serialize");
539 let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize");
540 assert_eq!(kind, back);
541 }
542
543 #[test]
544 #[allow(clippy::expect_used)]
545 fn verify_error_kind_id_token_variants_round_trip_through_serde() {
546 // Unit nested variant — wire shape:
547 // {"tag": "IdToken", "claim": "NonceMissing"}.
548 let unit = VerifyErrorKind::IdToken(IdTokenFailureKind::NonceMissing);
549 let json = serde_json::to_string(&unit).expect("serialize unit");
550 let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize unit");
551 assert_eq!(unit, back);
552
553 // Payloaded nested variant — wire shape:
554 // {"tag": "IdToken", "claim": {"UnknownClaim": "backdoor"}}.
555 let payload = VerifyErrorKind::IdToken(IdTokenFailureKind::UnknownClaim(
556 "backdoor".to_owned(),
557 ));
558 let json = serde_json::to_string(&payload).expect("serialize payload");
559 let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize payload");
560 assert_eq!(payload, back);
561
562 // CatMismatch with empty value — engine signals stripped-claims
563 // forgery this way; round-trip must preserve the empty string.
564 let cat = VerifyErrorKind::IdToken(IdTokenFailureKind::CatMismatch(String::new()));
565 let json = serde_json::to_string(&cat).expect("serialize cat");
566 let back: VerifyErrorKind = serde_json::from_str(&json).expect("deserialize cat");
567 assert_eq!(cat, back);
568 }
569
570 #[tokio::test]
571 async fn noop_sink_is_a_no_op() {
572 let sink = NoopAuditSink;
573 let event = fixture(VerifyErrorKind::Expired, "x");
574 // Contract: returns without panic. Nothing observable to assert.
575 sink.record_failure(event).await;
576 }
577
578 #[tokio::test]
579 async fn memory_sink_records_events_in_insertion_order() {
580 let sink = MemoryAuditSink::new();
581 sink.record_failure(fixture(VerifyErrorKind::Expired, "a"))
582 .await;
583 sink.record_failure(fixture(VerifyErrorKind::SignatureInvalid, "b"))
584 .await;
585 sink.record_failure(fixture(VerifyErrorKind::IdTokenAsBearer, "c"))
586 .await;
587
588 let events = sink.events();
589 assert_eq!(events.len(), 3);
590 assert_eq!(events[0].kind, VerifyErrorKind::Expired);
591 assert_eq!(events[1].kind, VerifyErrorKind::SignatureInvalid);
592 assert_eq!(events[2].kind, VerifyErrorKind::IdTokenAsBearer);
593 assert_eq!(events[0].source_id, "a");
594 }
595
596 #[tokio::test]
597 async fn memory_sink_is_empty_initially() {
598 let sink = MemoryAuditSink::new();
599 assert!(sink.is_empty());
600 sink.record_failure(fixture(VerifyErrorKind::Other, "x"))
601 .await;
602 assert_eq!(sink.len(), 1);
603 }
604
605 /// Compile-time guard: MemoryAuditSink + NoopAuditSink must be
606 /// usable behind `Arc<dyn AuditSink>`. If a future change adds a
607 /// generic method to `AuditSink`, object-safety regresses and this
608 /// fails to compile.
609 #[allow(dead_code)]
610 fn dyn_object_safety() {
611 let _: Arc<dyn AuditSink> = Arc::new(NoopAuditSink);
612 let _: Arc<dyn AuditSink> = Arc::new(MemoryAuditSink::new());
613 }
614}