Skip to main content

axess_core/device/
cache.rs

1//! [`CachedDeviceStore`]: wraps any [`DeviceStore`] in an in-process
2//! [`ClockTtlCache`] so per-request
3//! `load(device_id)` lookups skip the backing store on the hot path.
4//!
5//! # Why a decorator (not built into each backend)
6//!
7//! The same shape as `EntityCache<P: RequestEntityProvider>` for Cedar
8//! authz: composition over inheritance. Backend impls
9//! ([`MemoryDeviceStore`](super::MemoryDeviceStore), and the future
10//! `SqliteDeviceStore` / `PostgresDeviceStore` / `ValkeyDeviceStore`)
11//! stay focused on storage; the cache is a separate, opt-in layer that
12//! every backend benefits from for free.
13//!
14//! # What's cached, what isn't
15//!
16//! - **Cached**: [`load(tenant, id)`](DeviceStore::load): per-request
17//!   hot path. Every authenticated request that needs a device record
18//!   for trust-level evaluation hits this. Cache hit → no storage I/O.
19//! - **Not cached**:
20//!   - [`find_by_fingerprint`](DeviceStore::find_by_fingerprint):
21//!     cold path during first sighting before a `device_id` cookie
22//!     exists. Calling it primes the by-id cache on success so the
23//!     next `load` for the same device hits warm.
24//!   - [`find_for_user`](DeviceStore::find_for_user): list with
25//!     varying cardinality; harder to invalidate correctly when one
26//!     of the user's devices changes. Pass-through.
27//!   - [`save`](DeviceStore::save) /
28//!     [`set_trust_level`](DeviceStore::set_trust_level) /
29//!     [`delete`](DeviceStore::delete): invalidate the key, then
30//!     pass through. Trust transitions and revocations MUST be
31//!     immediately visible.
32//!   - [`record_sighting`](DeviceStore::record_sighting): the only
33//!     mutation we deliberately do **not** invalidate on. It moves
34//!     `last_seen_at` forward by milliseconds; the cache showing a
35//!     slightly-older timestamp until TTL expiry is acceptable, and
36//!     invalidating here would defeat the entire cache because
37//!     `record_sighting` runs on every request the device appears in.
38//!     Read [`CachedDeviceStore::record_sighting`] for the full
39//!     reasoning.
40//!   - [`sweep`](DeviceStore::sweep): operates on storage; the cache
41//!     catches up via TTL or explicit invalidation by callers that
42//!     also call `set_trust_level` / `delete` on the swept rows.
43//!
44//! # DST guarantees
45//!
46//! The underlying [`ClockTtlCache`] routes
47//! every TTL decision through an injected
48//! [`Clock`]. Tests can drive the cache with
49//! [`MockClock`](axess_clock::testing::MockClock) and observe
50//! deterministic eviction.
51//!
52//! # When to use (and when not)
53//!
54//! Use when the backing store is `Sqlite` / `Postgres` / `Valkey` and
55//! request volume is high enough that per-request DB hits show up in
56//! flame graphs. Skip for `MemoryDeviceStore`; the underlying
57//! `DashMap::get` is already faster than the cache wrapper's atomics.
58
59use std::future::Future;
60use std::num::NonZeroUsize;
61use std::sync::Arc;
62use std::time::Duration;
63
64use chrono::{DateTime, Utc};
65
66use axess_cache::ClockTtlCache;
67use axess_clock::{Clock, SystemClock};
68
69use crate::authn::ids::{DeviceId, TenantId, UserId};
70use crate::device::store::{DeviceStore, SweepCounts};
71use crate::device::types::{Device, DeviceTrustLevel, FingerprintHash};
72
73/// Default cache capacity: same shape as [`EntityCache`](super::super::cache::EntityCache).
74const DEFAULT_CAPACITY: usize = 10_000;
75
76/// Default TTL: same shape as [`EntityCache`](super::super::cache::EntityCache).
77///
78/// Any pending trust-level change races a 60s upper bound until the
79/// next load forces a re-fetch. Within a single pod, mutations
80/// invalidate explicitly so this only matters for cross-pod
81/// propagation (no invalidation bus exists today; see the
82/// `valkey-bridge` work tracked separately for that).
83const DEFAULT_TTL_SECS: u64 = 60;
84
85/// Cache key: `(tenant_id, device_id)`. Tenant scoping is mandatory
86/// even though `device_id` should already be globally unique, because
87/// it pins the security boundary explicitly and cheaply.
88type CacheKey = (TenantId, DeviceId);
89
90/// In-process cache decorator wrapping any [`DeviceStore`].
91///
92/// Construct with [`CachedDeviceStore::new`] for default settings, or
93/// build via [`with_capacity`](Self::with_capacity) /
94/// [`with_ttl`](Self::with_ttl) / [`with_clock`](Self::with_clock).
95pub struct CachedDeviceStore<S>
96where
97    S: DeviceStore,
98{
99    inner: S,
100    cache: Arc<ClockTtlCache<CacheKey, Device>>,
101}
102
103impl<S> Clone for CachedDeviceStore<S>
104where
105    S: DeviceStore,
106{
107    fn clone(&self) -> Self {
108        Self {
109            inner: self.inner.clone(),
110            cache: self.cache.clone(),
111        }
112    }
113}
114
115impl<S> CachedDeviceStore<S>
116where
117    S: DeviceStore,
118{
119    /// Wrap `inner` with default cache settings (10k entries, 60 s TTL,
120    /// [`SystemClock`]).
121    pub fn new(inner: S) -> Self {
122        Self::with_options(
123            inner,
124            DEFAULT_CAPACITY,
125            Duration::from_secs(DEFAULT_TTL_SECS),
126            Arc::new(SystemClock),
127        )
128    }
129
130    /// Construct with explicit cache parameters.
131    pub fn with_options(inner: S, capacity: usize, ttl: Duration, clock: Arc<dyn Clock>) -> Self {
132        let capacity = NonZeroUsize::new(capacity.max(1)).expect("capacity >= 1");
133        let cache = Arc::new(ClockTtlCache::new(capacity, ttl, clock));
134        Self { inner, cache }
135    }
136
137    /// Builder: override the cache capacity (default 10,000).
138    pub fn with_capacity(mut self, capacity: usize) -> Self {
139        let cap = NonZeroUsize::new(capacity.max(1)).expect("capacity >= 1");
140        // Rebuild the cache with the new capacity. Anything currently
141        // cached is dropped; acceptable on the construction path
142        // (callers should set capacity before serving traffic).
143        let ttl = Duration::from_secs(DEFAULT_TTL_SECS);
144        self.cache = Arc::new(ClockTtlCache::new(
145            cap,
146            ttl,
147            Arc::new(SystemClock) as Arc<dyn Clock>,
148        ));
149        self
150    }
151
152    /// Builder: override the cache TTL (default 60 s).
153    pub fn with_ttl(self, ttl: Duration) -> Self {
154        let cap = self.cache.capacity();
155        let cache = Arc::new(ClockTtlCache::new(
156            cap,
157            ttl,
158            Arc::new(SystemClock) as Arc<dyn Clock>,
159        ));
160        Self {
161            inner: self.inner,
162            cache,
163        }
164    }
165
166    /// Builder: inject a [`Clock`] for deterministic-simulation testing.
167    pub fn with_clock(self, clock: Arc<dyn Clock>) -> Self {
168        let cap = self.cache.capacity();
169        // Preserve TTL when only swapping the clock.
170        let ttl = Duration::from_secs(DEFAULT_TTL_SECS);
171        let cache = Arc::new(ClockTtlCache::new(cap, ttl, clock));
172        Self {
173            inner: self.inner,
174            cache,
175        }
176    }
177
178    /// Snapshot of the underlying cache counters
179    /// ([`axess_cache::CacheStats`]). Useful for ops dashboards.
180    pub fn stats(&self) -> axess_cache::CacheStats {
181        self.cache.stats()
182    }
183
184    /// Drop every cached entry. Use after bulk operations that the
185    /// cache wasn't notified about (e.g. an offline migration).
186    pub fn invalidate_all(&self) {
187        self.cache.invalidate_all();
188    }
189
190    /// Drop every cached entry for a given tenant. Useful after a
191    /// tenant-wide trust-policy change.
192    pub fn invalidate_tenant(&self, tenant_id: &TenantId) {
193        let target = *tenant_id;
194        self.cache.invalidate_by(|k| k.0 == target);
195    }
196}
197
198impl<S> DeviceStore for CachedDeviceStore<S>
199where
200    S: DeviceStore,
201{
202    type Error = S::Error;
203
204    fn load(
205        &self,
206        tenant_id: &TenantId,
207        id: &DeviceId,
208    ) -> impl Future<Output = Result<Option<Device>, Self::Error>> + Send {
209        let key = (*tenant_id, *id);
210        let cache = self.cache.clone();
211        let inner = self.inner.clone();
212        let tenant = *tenant_id;
213        let device = *id;
214        async move {
215            // Cache hit; return immediately, never touch storage.
216            if let Some(d) = cache.get(&key) {
217                return Ok(Some(d));
218            }
219            // Cache miss; fall through to inner, populate on Some.
220            // (None results are not cached: a deleted device should
221            // immediately reflect any subsequent re-creation, and the
222            // miss rate of "asking for a non-existent device" is
223            // expected to be vanishingly low in practice.)
224            let result = inner.load(&tenant, &device).await?;
225            if let Some(ref d) = result {
226                cache.insert(key, d.clone());
227            }
228            Ok(result)
229        }
230    }
231
232    fn find_by_fingerprint(
233        &self,
234        tenant_id: &TenantId,
235        hash: &FingerprintHash,
236    ) -> impl Future<Output = Result<Option<Device>, Self::Error>> + Send {
237        let cache = self.cache.clone();
238        let inner = self.inner.clone();
239        let tenant = *tenant_id;
240        let hash = *hash;
241        async move {
242            let result = inner.find_by_fingerprint(&tenant, &hash).await?;
243            // Prime the by-id cache so the next per-request `load`
244            // for the same device is warm.
245            if let Some(ref d) = result {
246                cache.insert((tenant, d.id), d.clone());
247            }
248            Ok(result)
249        }
250    }
251
252    fn find_for_user(
253        &self,
254        tenant_id: &TenantId,
255        user_id: &UserId,
256        limit: usize,
257    ) -> impl Future<Output = Result<Vec<Device>, Self::Error>> + Send {
258        // List queries don't cache; too easy to leave stale entries
259        // when one of the user's devices changes via a path that
260        // doesn't know to invalidate the list. The per-element
261        // `load` cache is the right granularity.
262        self.inner.find_for_user(tenant_id, user_id, limit)
263    }
264
265    fn find_by_refresh_family(
266        &self,
267        tenant_id: &TenantId,
268        family_id: &str,
269    ) -> impl Future<Output = Result<Vec<Device>, Self::Error>> + Send {
270        // Cold path used only by refresh-cascade revocation. Pass
271        // through; same caching argument as `find_for_user`.
272        self.inner.find_by_refresh_family(tenant_id, family_id)
273    }
274
275    fn save(&self, device: &Device) -> impl Future<Output = Result<(), Self::Error>> + Send {
276        let key = (device.tenant_id, device.id);
277        let cache = self.cache.clone();
278        let inner = self.inner.clone();
279        let device = device.clone();
280        async move {
281            // Invalidate first so a concurrent `load` after this
282            // returns either the new value (via inner) or nothing;
283            // never the stale cached row. ClockTtlCache's
284            // invalidate-wins-during-load semantics close the rest
285            // of the race.
286            cache.invalidate(&key);
287            inner.save(&device).await?;
288            // Re-prime with the just-saved value so the next load is
289            // warm. This is an optimisation, not correctness; the
290            // load-on-miss path would also populate.
291            cache.insert(key, device);
292            Ok(())
293        }
294    }
295
296    fn record_sighting(
297        &self,
298        tenant_id: &TenantId,
299        id: &DeviceId,
300        now: DateTime<Utc>,
301    ) -> impl Future<Output = Result<(), Self::Error>> + Send {
302        // Deliberately NOT invalidating on this path. `record_sighting`
303        // bumps `last_seen_at` and runs on every authenticated request
304        //; invalidating would force a re-load on the very next
305        // request, defeating the cache entirely.
306        //
307        // Trade-off: cached `last_seen_at` will lag the true storage
308        // value by up to TTL_SECS. Trust-level decisions don't depend
309        // on `last_seen_at` precisely (it's a lifecycle-sweep input,
310        // not a hot-path predicate), so the lag is tolerable.
311        // Callers that DO care about precise last_seen can call
312        // `invalidate_all` or read directly from storage.
313        self.inner.record_sighting(tenant_id, id, now)
314    }
315
316    fn set_trust_level(
317        &self,
318        tenant_id: &TenantId,
319        id: &DeviceId,
320        level: DeviceTrustLevel,
321        now: DateTime<Utc>,
322    ) -> impl Future<Output = Result<(), Self::Error>> + Send {
323        let key = (*tenant_id, *id);
324        let cache = self.cache.clone();
325        let inner = self.inner.clone();
326        let tenant = *tenant_id;
327        let device = *id;
328        async move {
329            cache.invalidate(&key);
330            inner.set_trust_level(&tenant, &device, level, now).await
331        }
332    }
333
334    fn delete(
335        &self,
336        tenant_id: &TenantId,
337        id: &DeviceId,
338    ) -> impl Future<Output = Result<(), Self::Error>> + Send {
339        let key = (*tenant_id, *id);
340        let cache = self.cache.clone();
341        let inner = self.inner.clone();
342        let tenant = *tenant_id;
343        let device = *id;
344        async move {
345            cache.invalidate(&key);
346            inner.delete(&tenant, &device).await
347        }
348    }
349
350    fn sweep(
351        &self,
352        tenant_id: &TenantId,
353        now: DateTime<Utc>,
354    ) -> impl Future<Output = Result<SweepCounts, Self::Error>> + Send {
355        // Sweep operates on storage. The cache catches up via TTL,
356        // and any caller that explicitly drives a `delete` /
357        // `set_trust_level` after observing a sweep result will
358        // correctly invalidate via those paths.
359        self.inner.sweep(tenant_id, now)
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::device::store::MemoryDeviceStore;
367    use crate::device::types::{Device, FingerprintHash};
368    use axess_clock::testing::MockClock;
369    use chrono::TimeZone;
370
371    fn fixed_clock() -> Arc<MockClock> {
372        Arc::new(MockClock::at(
373            Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
374        ))
375    }
376
377    fn ids() -> (TenantId, UserId, DeviceId) {
378        (
379            crate::authn::ids::testing::tenant("tenant-1"),
380            crate::authn::ids::testing::user("user-1"),
381            crate::authn::ids::testing::device("device-1"),
382        )
383    }
384
385    fn build_device(t: &TenantId, u: &UserId, d: &DeviceId) -> Device {
386        Device {
387            id: *d,
388            tenant_id: *t,
389            user_id: Some(*u),
390            trust_level: DeviceTrustLevel::Seen,
391            fingerprint_hash: FingerprintHash::from_bytes([0u8; 32]),
392            first_seen_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
393            last_seen_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
394            revoked_at: None,
395            bindings: Vec::new(),
396        }
397    }
398
399    #[tokio::test]
400    async fn load_caches_after_first_hit() {
401        let inner = MemoryDeviceStore::new();
402        let (t, u, d) = ids();
403        inner.save(&build_device(&t, &u, &d)).await.unwrap();
404
405        let cached = CachedDeviceStore::new(inner.clone()).with_clock(fixed_clock() as _);
406        // First load; cache miss → populates.
407        drop(cached.load(&t, &d).await.unwrap().expect("first load"));
408        let stats_after_miss = cached.stats();
409        assert_eq!(stats_after_miss.misses, 1);
410        assert_eq!(stats_after_miss.hits, 0);
411
412        // Second load; cache hit, no storage call.
413        drop(cached.load(&t, &d).await.unwrap().expect("second load"));
414        let stats_after_hit = cached.stats();
415        assert_eq!(stats_after_hit.hits, 1, "second load must hit cache");
416    }
417
418    #[tokio::test]
419    async fn load_does_not_cache_none_results() {
420        let inner = MemoryDeviceStore::new();
421        let (t, _u, d) = ids();
422        let cached = CachedDeviceStore::new(inner).with_clock(fixed_clock() as _);
423
424        // No device saved; load returns None.
425        assert!(cached.load(&t, &d).await.unwrap().is_none());
426        // Cache size stays zero; Nones are not cached.
427        let stats = cached.stats();
428        assert_eq!(stats.inserts, 0, "None results must not be cached");
429    }
430
431    #[tokio::test]
432    async fn save_invalidates_and_repopulates() {
433        let inner = MemoryDeviceStore::new();
434        let (t, u, d) = ids();
435        inner.save(&build_device(&t, &u, &d)).await.unwrap();
436
437        let cached = CachedDeviceStore::new(inner.clone()).with_clock(fixed_clock() as _);
438        // Warm cache.
439        drop(cached.load(&t, &d).await.unwrap());
440
441        // Mutate via cached store: trust-level promotion.
442        let mut updated = build_device(&t, &u, &d);
443        updated.trust_level = DeviceTrustLevel::Trusted;
444        cached.save(&updated).await.unwrap();
445
446        // Next load returns the new value (no stale cached version).
447        let loaded = cached.load(&t, &d).await.unwrap().unwrap();
448        assert_eq!(
449            loaded.trust_level,
450            DeviceTrustLevel::Trusted,
451            "save must invalidate the cached row so load sees the update"
452        );
453    }
454
455    #[tokio::test]
456    async fn set_trust_level_invalidates_cached_row() {
457        let inner = MemoryDeviceStore::new();
458        let (t, u, d) = ids();
459        inner.save(&build_device(&t, &u, &d)).await.unwrap();
460
461        let cached = CachedDeviceStore::new(inner.clone()).with_clock(fixed_clock() as _);
462        drop(cached.load(&t, &d).await.unwrap()); // warm
463
464        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 5, 0).unwrap();
465        cached
466            .set_trust_level(&t, &d, DeviceTrustLevel::Revoked, now)
467            .await
468            .unwrap();
469
470        let loaded = cached.load(&t, &d).await.unwrap().unwrap();
471        assert_eq!(
472            loaded.trust_level,
473            DeviceTrustLevel::Revoked,
474            "set_trust_level must invalidate the cached row"
475        );
476    }
477
478    #[tokio::test]
479    async fn delete_invalidates_and_subsequent_load_is_none() {
480        let inner = MemoryDeviceStore::new();
481        let (t, u, d) = ids();
482        inner.save(&build_device(&t, &u, &d)).await.unwrap();
483
484        let cached = CachedDeviceStore::new(inner.clone()).with_clock(fixed_clock() as _);
485        drop(cached.load(&t, &d).await.unwrap()); // warm
486
487        cached.delete(&t, &d).await.unwrap();
488        assert!(
489            cached.load(&t, &d).await.unwrap().is_none(),
490            "delete must invalidate so the next load reflects absence"
491        );
492    }
493
494    #[tokio::test]
495    async fn record_sighting_does_not_invalidate() {
496        // Documented behaviour: record_sighting is intentionally NOT a
497        // cache-invalidating path, because it runs on every request
498        // and would defeat the cache. This test pins that contract.
499        let inner = MemoryDeviceStore::new();
500        let (t, u, d) = ids();
501        inner.save(&build_device(&t, &u, &d)).await.unwrap();
502
503        let cached = CachedDeviceStore::new(inner.clone()).with_clock(fixed_clock() as _);
504        drop(cached.load(&t, &d).await.unwrap()); // warm
505        let stats_before = cached.stats();
506
507        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 5, 0).unwrap();
508        cached.record_sighting(&t, &d, now).await.unwrap();
509        drop(cached.load(&t, &d).await.unwrap()); // should hit cache
510
511        let stats_after = cached.stats();
512        assert_eq!(
513            stats_after.hits,
514            stats_before.hits + 1,
515            "record_sighting must not invalidate the cache"
516        );
517    }
518
519    #[tokio::test]
520    async fn find_by_fingerprint_primes_by_id_cache() {
521        let inner = MemoryDeviceStore::new();
522        let (t, u, d) = ids();
523        let device = build_device(&t, &u, &d);
524        let fp = device.fingerprint_hash;
525        inner.save(&device).await.unwrap();
526
527        let cached = CachedDeviceStore::new(inner.clone()).with_clock(fixed_clock() as _);
528        // Cold fingerprint lookup; pass-through to inner, then primes
529        // the by-id cache.
530        drop(
531            cached
532                .find_by_fingerprint(&t, &fp)
533                .await
534                .unwrap()
535                .expect("device found by fingerprint"),
536        );
537
538        // Subsequent load hits cache (no second storage round-trip).
539        drop(cached.load(&t, &d).await.unwrap());
540        let stats = cached.stats();
541        assert_eq!(
542            stats.hits, 1,
543            "find_by_fingerprint must prime the by-id cache so load is warm"
544        );
545    }
546
547    /// Pin: refresh-family cascade revocation propagates through the
548    /// cache. When `cascade_revoke_by_refresh_family` walks N devices
549    /// and calls `set_trust_level(Revoked)` on each, every cached row
550    /// must be invalidated so subsequent loads see `Revoked`. Without
551    /// per-call invalidation we'd serve stale `Trusted` from cache for
552    /// up to TTL_SECS, a critical security regression after a refresh
553    /// token theft signal.
554    #[tokio::test]
555    async fn refresh_cascade_revocation_propagates_through_cache() {
556        use crate::device::cascade::cascade_revoke_by_refresh_family;
557        use crate::device::types::DeviceBinding;
558
559        let inner = MemoryDeviceStore::new();
560        let tenant = crate::authn::ids::testing::tenant("tenant-1");
561        let user = crate::authn::ids::testing::user("user-1");
562        let dev_a = crate::authn::ids::testing::device("dev-a");
563        let dev_b = crate::authn::ids::testing::device("dev-b");
564        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
565
566        for (id, fp_byte) in [(&dev_a, 0xa1u8), (&dev_b, 0xb2u8)] {
567            let device = Device {
568                id: *id,
569                tenant_id: tenant,
570                user_id: Some(user),
571                trust_level: DeviceTrustLevel::Trusted,
572                fingerprint_hash: FingerprintHash::from_bytes([fp_byte; 32]),
573                first_seen_at: now,
574                last_seen_at: now,
575                revoked_at: None,
576                bindings: vec![DeviceBinding::Refresh {
577                    family_id: "fam-stolen".to_string(),
578                    issued_at: now,
579                    last_used_at: now,
580                }],
581            };
582            inner.save(&device).await.unwrap();
583        }
584
585        let cached = CachedDeviceStore::new(inner.clone()).with_clock(fixed_clock() as _);
586
587        // Warm the cache for both devices; both Trusted now.
588        let warm_a = cached.load(&tenant, &dev_a).await.unwrap().unwrap();
589        let warm_b = cached.load(&tenant, &dev_b).await.unwrap().unwrap();
590        assert_eq!(warm_a.trust_level, DeviceTrustLevel::Trusted);
591        assert_eq!(warm_b.trust_level, DeviceTrustLevel::Trusted);
592
593        // Refresh-family compromise → cascade revocation through the
594        // cached store. Every device bound to `fam-stolen` must end up
595        // Revoked, and the cache must reflect that on the next load.
596        let revoked_at = Utc.with_ymd_and_hms(2026, 1, 1, 0, 5, 0).unwrap();
597        let count = cascade_revoke_by_refresh_family(&cached, &tenant, "fam-stolen", revoked_at)
598            .await
599            .unwrap();
600        assert_eq!(count, 2, "both refresh-bound devices must be revoked");
601
602        // Critical: subsequent loads MUST see Revoked, not stale Trusted.
603        let after_a = cached.load(&tenant, &dev_a).await.unwrap().unwrap();
604        let after_b = cached.load(&tenant, &dev_b).await.unwrap().unwrap();
605        assert_eq!(
606            after_a.trust_level,
607            DeviceTrustLevel::Revoked,
608            "cache must not serve stale Trusted after cascade revocation"
609        );
610        assert_eq!(
611            after_b.trust_level,
612            DeviceTrustLevel::Revoked,
613            "cache must not serve stale Trusted after cascade revocation"
614        );
615    }
616
617    /// mutant kill: pin `invalidate_all` against a no-op
618    /// replacement. After warming the cache, calling `invalidate_all`
619    /// must drop every entry so the next load is a miss for both
620    /// rows.
621    #[tokio::test]
622    async fn invalidate_all_drops_every_entry() {
623        let inner = MemoryDeviceStore::new();
624        let t1 = crate::authn::ids::testing::tenant("t1");
625        let t2 = crate::authn::ids::testing::tenant("t2");
626        let u = crate::authn::ids::testing::user("u1");
627        let d1 = crate::authn::ids::testing::device("d1");
628        let d2 = crate::authn::ids::testing::device("d2");
629        inner.save(&build_device(&t1, &u, &d1)).await.unwrap();
630        inner.save(&build_device(&t2, &u, &d2)).await.unwrap();
631
632        let cached = CachedDeviceStore::new(inner.clone()).with_clock(fixed_clock() as _);
633        drop(cached.load(&t1, &d1).await.unwrap());
634        drop(cached.load(&t2, &d2).await.unwrap());
635        let warm = cached.stats();
636        assert_eq!(warm.misses, 2, "two cold loads landed two misses");
637
638        cached.invalidate_all();
639
640        // Both rows are gone; the next loads must miss again.
641        drop(cached.load(&t1, &d1).await.unwrap());
642        drop(cached.load(&t2, &d2).await.unwrap());
643        let after = cached.stats();
644        assert_eq!(
645            after.misses,
646            warm.misses + 2,
647            "invalidate_all must drop every entry; a no-op mutant would \
648             let the second pair of loads hit cache"
649        );
650    }
651
652    #[tokio::test]
653    async fn invalidate_tenant_drops_only_matching_entries() {
654        let inner = MemoryDeviceStore::new();
655        let t1 = crate::authn::ids::testing::tenant("t1");
656        let t2 = crate::authn::ids::testing::tenant("t2");
657        let u = crate::authn::ids::testing::user("u1");
658        let d1 = crate::authn::ids::testing::device("d1");
659        let d2 = crate::authn::ids::testing::device("d2");
660        inner.save(&build_device(&t1, &u, &d1)).await.unwrap();
661        inner.save(&build_device(&t2, &u, &d2)).await.unwrap();
662
663        let cached = CachedDeviceStore::new(inner.clone()).with_clock(fixed_clock() as _);
664        drop(cached.load(&t1, &d1).await.unwrap());
665        drop(cached.load(&t2, &d2).await.unwrap());
666
667        cached.invalidate_tenant(&t1);
668
669        // t1 entry is gone → next load is a miss.
670        let stats_before = cached.stats();
671        drop(cached.load(&t1, &d1).await.unwrap());
672        let stats_after = cached.stats();
673        assert_eq!(
674            stats_after.misses,
675            stats_before.misses + 1,
676            "t1 entry should have been invalidated"
677        );
678
679        // t2 entry survives → next load is a hit.
680        let stats_before2 = cached.stats();
681        drop(cached.load(&t2, &d2).await.unwrap());
682        let stats_after2 = cached.stats();
683        assert_eq!(
684            stats_after2.hits,
685            stats_before2.hits + 1,
686            "t2 entry must survive invalidate_tenant(t1)"
687        );
688    }
689}