Skip to main content

session_guard/
testing.rs

1//! Conformance suite for [`SessionStore`] implementations.
2//!
3//! Every built-in store runs this suite. Run it against your own store with
4//! [`store_conformance!`](crate::store_conformance), which needs `tokio` (with the `macros` and
5//! `rt-multi-thread` features) as a dev-dependency:
6//!
7//! ```no_run
8//! # #[cfg(feature = "valkey")]
9//! # mod suite {
10//! use session_guard::ValkeyStore;
11//!
12//! session_guard::store_conformance!(|| async {
13//!     ValkeyStore::connect("redis://localhost:6379").await.unwrap()
14//! });
15//! # }
16//! ```
17//!
18//! The tests use random ids and subjects and whole-second timestamps, so they can share one
19//! database with each other and with other data.
20//!
21//! Every check panics when the store breaks the contract.
22#![allow(
23    clippy::missing_panics_doc,
24    reason = "each check panics on a contract violation, documented above"
25)]
26
27use std::net::{IpAddr, Ipv4Addr};
28
29use chrono::{DateTime, TimeDelta, Utc};
30use uuid::Uuid;
31
32use crate::{
33    checks::Check,
34    event::{ClientSummary, EventKind, SessionEvent, SessionOrigin},
35    fingerprint::{Baseline, Fingerprint},
36    intel::Anonymity,
37    store::{ClaimScope, Rotation, SessionRecord, SessionStore, SessionUpdate, Supersede},
38    token::{SessionToken, TokenHash},
39};
40
41/// Generates one `#[tokio::test]` per conformance check. `$make` is a closure returning a
42/// future that resolves to a fresh store handle.
43#[macro_export]
44macro_rules! store_conformance {
45    ($make:expr) => {
46        $crate::store_conformance!(@tests $make;
47            insert_and_find,
48            unknown_token_is_none,
49            records_round_trip,
50            session_without_origin,
51            exact_rotation_happens_once,
52            concurrent_rotation_has_one_winner,
53            rotation_if_issued_before,
54            current_rotation,
55            newer_token_used_is_tracked,
56            mark_used_sets_once,
57            mark_used_reaches_the_rotated_token,
58            none_fields_are_preserved,
59            prune_superseded_tokens,
60            delete_session_removes_tokens,
61            delete_subject_sessions,
62            list_subject_sessions,
63            delete_expired_absolute_and_idle,
64            events_record_and_purge,
65            event_claims_dedupe_within_window,
66            subject_claims_dedupe_within_window,
67            apply_to_missing_session,
68        );
69    };
70    (@tests $make:expr; $($name:ident),* $(,)?) => {
71        $(
72            #[::tokio::test(flavor = "multi_thread")]
73            async fn $name() {
74                $crate::testing::$name(($make)().await).await;
75            }
76        )*
77    };
78}
79
80/// The current time truncated to whole seconds.
81pub fn now_secs() -> DateTime<Utc> {
82    let now = Utc::now();
83    DateTime::from_timestamp(now.timestamp(), 0).unwrap_or(now)
84}
85
86/// A fresh random token hash in bucket 0, so it pairs with the sessions [`sample_session_for`]
87/// builds on [sharded](crate::SessionStore::sharded) stores.
88///
89/// # Panics
90///
91/// If the OS random number generator is unavailable.
92pub fn random_hash() -> TokenHash {
93    let mut bytes = *SessionToken::generate().expect("OS RNG").hash().as_bytes();
94    bytes[0] = 0;
95    TokenHash::from_bytes(bytes)
96}
97
98/// A random session id in bucket 0.
99fn aligned_session_id() -> Uuid {
100    let mut bytes = *Uuid::new_v4().as_bytes();
101    bytes[15] = 0;
102    Uuid::from_bytes(bytes)
103}
104
105/// A session for a random subject, created at `now`, expiring in a day.
106pub fn sample_session(now: DateTime<Utc>) -> SessionRecord {
107    sample_session_for(&format!("\"conformance-{}\"", Uuid::new_v4()), now)
108}
109
110/// A session for `subject` (canonical JSON), created at `now`, expiring in a day.
111pub fn sample_session_for(subject: &str, now: DateTime<Utc>) -> SessionRecord {
112    SessionRecord {
113        id: aligned_session_id(),
114        subject: subject.to_owned(),
115        created_at: now,
116        authenticated_at: now,
117        last_seen_at: now,
118        expires_at: now + TimeDelta::days(1),
119        step_up_required: false,
120        baseline: Baseline::new(Fingerprint {
121            ip_prefix: "203.0.113.0/24".into(),
122            ..Fingerprint::default()
123        }),
124        origin: Some(SessionOrigin {
125            at: now,
126            ip_prefix: IpAddr::V4(Ipv4Addr::new(203, 0, 113, 0)),
127            ip: None,
128            user_agent: None,
129            client: ClientSummary {
130                country: Some("BE".into()),
131                browser: Some("Firefox".into()),
132                ..ClientSummary::default()
133            },
134        }),
135    }
136}
137
138fn secs(n: i64) -> TimeDelta {
139    TimeDelta::seconds(n)
140}
141
142fn rotation(new_token: TokenHash, supersede: Supersede, prune_before: DateTime<Utc>) -> Rotation {
143    Rotation {
144        new_token,
145        supersede,
146        prune_superseded_before: prune_before,
147    }
148}
149
150fn rotate(id: Uuid, now: DateTime<Utc>, rotation: Rotation) -> SessionUpdate {
151    SessionUpdate {
152        rotation: Some(rotation),
153        ..SessionUpdate::touch(id, now)
154    }
155}
156
157/// An inserted session is found by its token, which starts current and unused.
158pub async fn insert_and_find<St: SessionStore>(store: St) {
159    let now = now_secs();
160    let session = sample_session(now);
161    let token = random_hash();
162    store.insert_session(&session, &token).await.unwrap();
163
164    let found = store
165        .find_by_token(&token)
166        .await
167        .unwrap()
168        .expect("inserted token is found");
169    assert_eq!(found.session, session);
170    assert_eq!(found.token.hash, token);
171    assert_eq!(found.token.issued_at, now);
172    assert_eq!(found.token.first_used_at, None);
173    assert_eq!(found.token.superseded_at, None);
174    assert!(!found.newer_token_used);
175}
176
177/// An unknown token finds nothing.
178pub async fn unknown_token_is_none<St: SessionStore>(store: St) {
179    assert!(store.find_by_token(&random_hash()).await.unwrap().is_none());
180}
181
182/// Unicode subjects and a fully populated baseline survive storage unchanged.
183pub async fn records_round_trip<St: SessionStore>(store: St) {
184    let now = now_secs();
185    let mut session = sample_session_for(
186        &format!("{{\"tenant\":\"zürich-🦀\",\"user\":\"{}\"}}", Uuid::new_v4()),
187        now,
188    );
189    session.step_up_required = true;
190    session.authenticated_at = now - secs(30);
191    session.last_seen_at = now - secs(5);
192    session.baseline = Baseline {
193        fingerprint: Fingerprint {
194            ip_prefix: "2001:db8:1::/48".into(),
195            country: Some("BE".into()),
196            asn: Some(64_496),
197            browser: Some("Firefox".into()),
198            browser_major: Some(131),
199            os: Some("Linux".into()),
200            ch_platform: Some("Linux".into()),
201            ch_mobile: Some(false),
202            accept_language: Some("nl-be,nl,en".into()),
203            tls: Some("t13d1516h2_8daaf6152771_02713d6af862".into()),
204            anonymity: Some(Anonymity::Vpn),
205        },
206        previous_ip_prefix: Some("198.51.100.0/24".into()),
207        ip_changed_at: Some(now - secs(60)),
208    };
209    session.origin = Some(SessionOrigin {
210        at: now - secs(30),
211        ip_prefix: "2001:db8:1::".parse().unwrap(),
212        ip: Some("2001:db8:1::7".parse().unwrap()),
213        user_agent: Some("Mozilla/5.0 🦀".into()),
214        client: ClientSummary {
215            country: Some("BE".into()),
216            city: Some("Zürich".into()),
217            asn: Some(64_496),
218            network: Some("Example Net".into()),
219            anonymity: Some(Anonymity::Vpn),
220            browser: Some("Firefox".into()),
221            os: Some("Linux".into()),
222        },
223    });
224    let token = random_hash();
225    store.insert_session(&session, &token).await.unwrap();
226    let found = store.find_by_token(&token).await.unwrap().unwrap();
227    assert_eq!(found.session, session);
228    assert_eq!(
229        store.list_subject_sessions(&session.subject).await.unwrap(),
230        vec![session]
231    );
232}
233
234/// A session without an origin (created before 0.2) is stored and read back as such.
235pub async fn session_without_origin<St: SessionStore>(store: St) {
236    let now = now_secs();
237    let session = SessionRecord {
238        origin: None,
239        ..sample_session(now)
240    };
241    let token = random_hash();
242    store.insert_session(&session, &token).await.unwrap();
243    let found = store.find_by_token(&token).await.unwrap().unwrap();
244    assert_eq!(found.session, session);
245    store
246        .apply(&SessionUpdate::touch(session.id, now + secs(1)))
247        .await
248        .unwrap();
249    let listed = store.list_subject_sessions(&session.subject).await.unwrap();
250    assert_eq!(listed.len(), 1);
251    assert_eq!(listed[0].origin, None);
252}
253
254/// `Supersede::Exact` rotates only while that token is current.
255pub async fn exact_rotation_happens_once<St: SessionStore>(store: St) {
256    let now = now_secs();
257    let session = sample_session(now);
258    let first = random_hash();
259    store.insert_session(&session, &first).await.unwrap();
260
261    let later = now + secs(60);
262    let second = random_hash();
263    let rotated = store
264        .apply(&rotate(
265            session.id,
266            later,
267            rotation(second, Supersede::Exact(first), now),
268        ))
269        .await
270        .unwrap();
271    assert!(rotated);
272
273    let third = random_hash();
274    let again = store
275        .apply(&rotate(
276            session.id,
277            later,
278            rotation(third, Supersede::Exact(first), now),
279        ))
280        .await
281        .unwrap();
282    assert!(!again, "a superseded token must not rotate again");
283    assert!(store.find_by_token(&third).await.unwrap().is_none());
284
285    let old = store.find_by_token(&first).await.unwrap().unwrap();
286    assert_eq!(old.token.superseded_at, Some(later));
287    let new = store.find_by_token(&second).await.unwrap().unwrap();
288    assert_eq!(new.token.issued_at, later);
289    assert_eq!(new.token.superseded_at, None);
290    assert_eq!(new.session.last_seen_at, later);
291}
292
293/// Of many concurrent `Exact` rotations of one token, exactly one wins and the losers' tokens
294/// are never stored.
295pub async fn concurrent_rotation_has_one_winner<St: SessionStore>(store: St) {
296    let now = now_secs();
297    let session = sample_session(now);
298    let first = random_hash();
299    store.insert_session(&session, &first).await.unwrap();
300
301    let candidates: Vec<TokenHash> = (0..16).map(|_| random_hash()).collect();
302    let attempts = candidates.iter().map(|&candidate| {
303        let update = rotate(
304            session.id,
305            now + secs(1),
306            rotation(candidate, Supersede::Exact(first), now),
307        );
308        let store = &store;
309        async move { store.apply(&update).await.unwrap() }
310    });
311    let results = join_all(attempts).await;
312    assert_eq!(results.iter().filter(|r| **r).count(), 1, "exactly one rotation wins");
313
314    let mut current = 0;
315    for (candidate, won) in candidates.iter().zip(&results) {
316        let found = store.find_by_token(candidate).await.unwrap();
317        assert_eq!(found.is_some(), *won);
318        if found.is_some_and(|f| f.token.superseded_at.is_none()) {
319            current += 1;
320        }
321    }
322    assert_eq!(current, 1, "exactly one current token");
323}
324
325/// `Supersede::CurrentIssuedBefore` only rotates a current token older than the instant.
326pub async fn rotation_if_issued_before<St: SessionStore>(store: St) {
327    let now = now_secs();
328    let session = sample_session(now);
329    let first = random_hash();
330    store.insert_session(&session, &first).await.unwrap();
331
332    let too_new = random_hash();
333    let rotated = store
334        .apply(&rotate(
335            session.id,
336            now + secs(5),
337            rotation(too_new, Supersede::CurrentIssuedBefore(now), now),
338        ))
339        .await
340        .unwrap();
341    assert!(!rotated, "issued_at == cutoff is not strictly before");
342    assert!(store.find_by_token(&too_new).await.unwrap().is_none());
343
344    let second = random_hash();
345    let rotated = store
346        .apply(&rotate(
347            session.id,
348            now + secs(5),
349            rotation(second, Supersede::CurrentIssuedBefore(now + secs(1)), now),
350        ))
351        .await
352        .unwrap();
353    assert!(rotated);
354    let old = store.find_by_token(&first).await.unwrap().unwrap();
355    assert_eq!(old.token.superseded_at, Some(now + secs(5)));
356}
357
358/// `Supersede::Current` rotates whatever token is current.
359pub async fn current_rotation<St: SessionStore>(store: St) {
360    let now = now_secs();
361    let session = sample_session(now);
362    let first = random_hash();
363    store.insert_session(&session, &first).await.unwrap();
364    let second = random_hash();
365    store
366        .apply(&rotate(
367            session.id,
368            now + secs(1),
369            rotation(second, Supersede::Exact(first), now),
370        ))
371        .await
372        .unwrap();
373
374    let third = random_hash();
375    let rotated = store
376        .apply(&rotate(
377            session.id,
378            now + secs(2),
379            rotation(third, Supersede::Current, now),
380        ))
381        .await
382        .unwrap();
383    assert!(rotated);
384    let second_found = store.find_by_token(&second).await.unwrap().unwrap();
385    assert_eq!(second_found.token.superseded_at, Some(now + secs(2)));
386    let third_found = store.find_by_token(&third).await.unwrap().unwrap();
387    assert_eq!(third_found.token.superseded_at, None);
388}
389
390/// `newer_token_used` reports whether a later token of the session has been presented.
391pub async fn newer_token_used_is_tracked<St: SessionStore>(store: St) {
392    let now = now_secs();
393    let session = sample_session(now);
394    let first = random_hash();
395    store.insert_session(&session, &first).await.unwrap();
396    let second = random_hash();
397    store
398        .apply(&rotate(
399            session.id,
400            now + secs(1),
401            rotation(second, Supersede::Exact(first), now),
402        ))
403        .await
404        .unwrap();
405    assert!(!store.find_by_token(&first).await.unwrap().unwrap().newer_token_used);
406
407    store
408        .apply(&SessionUpdate {
409            mark_used: Some(second),
410            ..SessionUpdate::touch(session.id, now + secs(2))
411        })
412        .await
413        .unwrap();
414    assert!(store.find_by_token(&first).await.unwrap().unwrap().newer_token_used);
415    assert!(!store.find_by_token(&second).await.unwrap().unwrap().newer_token_used);
416}
417
418/// `mark_used` sets `first_used_at` once and never moves it.
419pub async fn mark_used_sets_once<St: SessionStore>(store: St) {
420    let now = now_secs();
421    let session = sample_session(now);
422    let token = random_hash();
423    store.insert_session(&session, &token).await.unwrap();
424
425    for offset in [3, 9] {
426        store
427            .apply(&SessionUpdate {
428                mark_used: Some(token),
429                ..SessionUpdate::touch(session.id, now + secs(offset))
430            })
431            .await
432            .unwrap();
433    }
434    let found = store.find_by_token(&token).await.unwrap().unwrap();
435    assert_eq!(found.token.first_used_at, Some(now + secs(3)));
436    assert_eq!(found.session.last_seen_at, now + secs(9));
437}
438
439/// `mark_used` may name the token the same update's rotation inserts.
440pub async fn mark_used_reaches_the_rotated_token<St: SessionStore>(store: St) {
441    let now = now_secs();
442    let session = sample_session(now);
443    let first = random_hash();
444    store.insert_session(&session, &first).await.unwrap();
445    let second = random_hash();
446    let rotated = store
447        .apply(&SessionUpdate {
448            mark_used: Some(second),
449            ..rotate(
450                session.id,
451                now + secs(1),
452                rotation(second, Supersede::Exact(first), now),
453            )
454        })
455        .await
456        .unwrap();
457    assert!(rotated);
458    let found = store.find_by_token(&second).await.unwrap().unwrap();
459    assert_eq!(found.token.first_used_at, Some(now + secs(1)));
460    assert!(store.find_by_token(&first).await.unwrap().unwrap().newer_token_used);
461}
462
463/// `None` fields leave stored values alone; `Some` fields replace them.
464pub async fn none_fields_are_preserved<St: SessionStore>(store: St) {
465    let now = now_secs();
466    let session = sample_session(now);
467    let token = random_hash();
468    store.insert_session(&session, &token).await.unwrap();
469
470    let mut baseline = session.baseline.clone();
471    baseline.fingerprint.country = Some("NL".into());
472    store
473        .apply(&SessionUpdate {
474            baseline: Some(baseline.clone()),
475            step_up_required: Some(true),
476            authenticated_at: Some(now + secs(4)),
477            ..SessionUpdate::touch(session.id, now + secs(4))
478        })
479        .await
480        .unwrap();
481    store
482        .apply(&SessionUpdate::touch(session.id, now + secs(8)))
483        .await
484        .unwrap();
485
486    let found = store.find_by_token(&token).await.unwrap().unwrap().session;
487    assert_eq!(found.baseline, baseline);
488    assert!(found.step_up_required);
489    assert_eq!(found.authenticated_at, now + secs(4));
490    assert_eq!(found.last_seen_at, now + secs(8));
491    assert_eq!(found.created_at, session.created_at);
492    assert_eq!(found.expires_at, session.expires_at);
493    assert_eq!(found.subject, session.subject);
494    assert_eq!(found.origin, session.origin);
495
496    store
497        .apply(&SessionUpdate {
498            step_up_required: Some(false),
499            ..SessionUpdate::touch(session.id, now + secs(9))
500        })
501        .await
502        .unwrap();
503    assert!(
504        !store
505            .find_by_token(&token)
506            .await
507            .unwrap()
508            .unwrap()
509            .session
510            .step_up_required
511    );
512}
513
514/// A rotation prunes the session's tokens superseded before its cutoff, and only those.
515pub async fn prune_superseded_tokens<St: SessionStore>(store: St) {
516    let now = now_secs();
517    let session = sample_session(now);
518    let t1 = random_hash();
519    store.insert_session(&session, &t1).await.unwrap();
520    let t2 = random_hash();
521    store
522        .apply(&rotate(
523            session.id,
524            now + secs(10),
525            rotation(t2, Supersede::Exact(t1), now),
526        ))
527        .await
528        .unwrap();
529    let t3 = random_hash();
530    store
531        .apply(&rotate(
532            session.id,
533            now + secs(20),
534            rotation(t3, Supersede::Exact(t2), now),
535        ))
536        .await
537        .unwrap();
538
539    // t1 was superseded at +10, t2 at +20 (the new rotation supersedes t3 at +30).
540    let t4 = random_hash();
541    store
542        .apply(&rotate(
543            session.id,
544            now + secs(30),
545            rotation(t4, Supersede::Exact(t3), now + secs(15)),
546        ))
547        .await
548        .unwrap();
549    assert!(
550        store.find_by_token(&t1).await.unwrap().is_none(),
551        "superseded before cutoff is pruned"
552    );
553    assert!(store.find_by_token(&t2).await.unwrap().is_some());
554    assert!(store.find_by_token(&t3).await.unwrap().is_some());
555    assert!(store.find_by_token(&t4).await.unwrap().is_some());
556}
557
558/// Deleting a session removes its tokens and reports whether it existed.
559pub async fn delete_session_removes_tokens<St: SessionStore>(store: St) {
560    let now = now_secs();
561    let session = sample_session(now);
562    let t1 = random_hash();
563    store.insert_session(&session, &t1).await.unwrap();
564    let t2 = random_hash();
565    store
566        .apply(&rotate(
567            session.id,
568            now + secs(1),
569            rotation(t2, Supersede::Exact(t1), now),
570        ))
571        .await
572        .unwrap();
573
574    assert!(store.delete_session(session.id).await.unwrap());
575    assert!(!store.delete_session(session.id).await.unwrap());
576    assert!(store.find_by_token(&t1).await.unwrap().is_none());
577    assert!(store.find_by_token(&t2).await.unwrap().is_none());
578}
579
580/// Deleting a subject's sessions returns their ids and leaves other subjects alone.
581pub async fn delete_subject_sessions<St: SessionStore>(store: St) {
582    let now = now_secs();
583    let subject = format!("\"conformance-{}\"", Uuid::new_v4());
584    let mut tokens = Vec::new();
585    let mut ids = Vec::new();
586    for _ in 0..3 {
587        let token = random_hash();
588        let session = sample_session_for(&subject, now);
589        store.insert_session(&session, &token).await.unwrap();
590        tokens.push(token);
591        ids.push(session.id);
592    }
593    let other = random_hash();
594    store.insert_session(&sample_session(now), &other).await.unwrap();
595
596    let mut deleted = store.delete_subject_sessions(&subject).await.unwrap();
597    deleted.sort();
598    ids.sort();
599    assert_eq!(deleted, ids);
600    assert_eq!(
601        store.delete_subject_sessions(&subject).await.unwrap(),
602        Vec::<Uuid>::new()
603    );
604    for token in &tokens {
605        assert!(store.find_by_token(token).await.unwrap().is_none());
606    }
607    assert!(store.find_by_token(&other).await.unwrap().is_some());
608}
609
610/// Listing returns exactly the subject's sessions.
611pub async fn list_subject_sessions<St: SessionStore>(store: St) {
612    let now = now_secs();
613    let subject = format!("\"conformance-{}\"", Uuid::new_v4());
614    let a = sample_session_for(&subject, now);
615    let b = sample_session_for(&subject, now - secs(60));
616    store.insert_session(&a, &random_hash()).await.unwrap();
617    store.insert_session(&b, &random_hash()).await.unwrap();
618    store
619        .insert_session(&sample_session(now), &random_hash())
620        .await
621        .unwrap();
622
623    let mut listed = store.list_subject_sessions(&subject).await.unwrap();
624    listed.sort_by_key(|s| s.created_at);
625    assert_eq!(listed, vec![b, a]);
626    assert_eq!(store.list_subject_sessions("\"nobody\"").await.unwrap(), vec![]);
627}
628
629/// Expired (absolute) and idle sessions are purged; live ones stay.
630pub async fn delete_expired_absolute_and_idle<St: SessionStore>(store: St) {
631    // Far in the past, so these purges only reach rows at least that old: never the live rows
632    // of other tests or apps sharing the database. Stores whose keys expire on the wall clock
633    // drop `expired` on their own, so only the idle session is sure to be counted; the others
634    // expire in real time.
635    let base = far_past();
636    let later = Utc::now() + TimeDelta::days(1);
637    let mut expired = sample_session(base);
638    expired.expires_at = base;
639    let mut idle = sample_session(base);
640    idle.last_seen_at = base - secs(100);
641    idle.expires_at = later;
642    let mut live = sample_session(base);
643    live.expires_at = later;
644    live.last_seen_at = base - secs(99);
645
646    let (te, ti, tl) = (random_hash(), random_hash(), random_hash());
647    store.insert_session(&expired, &te).await.unwrap();
648    store.insert_session(&idle, &ti).await.unwrap();
649    store.insert_session(&live, &tl).await.unwrap();
650
651    let deleted = store.delete_expired(base, base - secs(100)).await.unwrap();
652    assert!(deleted >= 1);
653    assert!(store.find_by_token(&te).await.unwrap().is_none());
654    assert!(store.find_by_token(&ti).await.unwrap().is_none());
655    assert!(store.find_by_token(&tl).await.unwrap().is_some());
656    store.delete_session(live.id).await.unwrap();
657}
658
659/// Events are recorded and purged strictly before the cutoff.
660pub async fn events_record_and_purge<St: SessionStore>(store: St) {
661    let at = far_past();
662    let event = SessionEvent {
663        session_id: Uuid::new_v4(),
664        subject: "\"conformance\"".into(),
665        kind: EventKind::RiskRevoked,
666        enforced: false,
667        score: Some(95),
668        signals: vec![Check::OsChanged, Check::CountryChanged],
669        ip: IpAddr::V4(Ipv4Addr::new(203, 0, 113, 0)),
670        user_agent: Some("Mozilla/5.0 🦀".into()),
671        client: ClientSummary {
672            country: Some("BE".into()),
673            asn: Some(64500),
674            browser: Some("Firefox".into()),
675            ..ClientSummary::default()
676        },
677        at,
678    };
679    store.record_event(&event).await.unwrap();
680    store
681        .record_event(&SessionEvent {
682            user_agent: None,
683            score: None,
684            signals: vec![],
685            client: ClientSummary::default(),
686            ..event.clone()
687        })
688        .await
689        .unwrap();
690    store.delete_events_before(at).await.unwrap();
691    assert!(store.delete_events_before(at + secs(1)).await.unwrap() >= 2);
692}
693
694/// A claim succeeds once per session and kind per window, across store handles.
695pub async fn event_claims_dedupe_within_window<St: SessionStore>(store: St) {
696    let now = now_secs();
697    let window = secs(600);
698    let session = Uuid::new_v4();
699    let claim = |kind, at| store.claim_event(ClaimScope::Session(session), kind, at, window);
700    assert!(claim(EventKind::RiskRevoked, now).await.unwrap());
701    assert!(!claim(EventKind::RiskRevoked, now + secs(1)).await.unwrap());
702    assert!(!claim(EventKind::RiskRevoked, now + secs(599)).await.unwrap());
703    assert!(
704        claim(EventKind::StepUpRequired, now).await.unwrap(),
705        "kinds are independent"
706    );
707    assert!(
708        store
709            .claim_event(ClaimScope::Session(Uuid::new_v4()), EventKind::RiskRevoked, now, window)
710            .await
711            .unwrap(),
712        "sessions are independent"
713    );
714    assert!(
715        claim(EventKind::RiskRevoked, now + secs(600)).await.unwrap(),
716        "the window elapsed"
717    );
718    assert!(
719        !claim(EventKind::RiskRevoked, now + secs(601)).await.unwrap(),
720        "the new claim holds"
721    );
722}
723
724/// A claim succeeds once per subject and kind per window, independent of session claims, and
725/// purging events removes old subject claims.
726pub async fn subject_claims_dedupe_within_window<St: SessionStore>(store: St) {
727    let now = now_secs();
728    let window = secs(600);
729    let subject = format!("\"conformance-{}\"", Uuid::new_v4());
730    let claim = |kind, at| store.claim_event(ClaimScope::Subject(&subject), kind, at, window);
731    assert!(claim(EventKind::RiskRevoked, now).await.unwrap());
732    assert!(!claim(EventKind::RiskRevoked, now + secs(1)).await.unwrap());
733    assert!(!claim(EventKind::RiskRevoked, now + secs(599)).await.unwrap());
734    assert!(
735        claim(EventKind::TokenReuse, now).await.unwrap(),
736        "kinds are independent"
737    );
738    let other = format!("\"conformance-{}\"", Uuid::new_v4());
739    assert!(
740        store
741            .claim_event(ClaimScope::Subject(&other), EventKind::RiskRevoked, now, window)
742            .await
743            .unwrap(),
744        "subjects are independent"
745    );
746    let session = Uuid::new_v4();
747    let session_claim = |at| store.claim_event(ClaimScope::Session(session), EventKind::RiskRevoked, at, window);
748    assert!(session_claim(now).await.unwrap(), "session claims are independent");
749    assert!(!session_claim(now + secs(1)).await.unwrap());
750    assert!(
751        claim(EventKind::RiskRevoked, now + secs(600)).await.unwrap(),
752        "the window elapsed"
753    );
754    assert!(
755        !claim(EventKind::RiskRevoked, now + secs(601)).await.unwrap(),
756        "the new claim holds"
757    );
758
759    let at = far_past();
760    let old = format!("\"conformance-{}\"", Uuid::new_v4());
761    let claim_old = |at| store.claim_event(ClaimScope::Subject(&old), EventKind::RiskRevoked, at, window);
762    assert!(claim_old(at).await.unwrap());
763    assert!(!claim_old(at + secs(2)).await.unwrap());
764    store.delete_events_before(at + secs(1)).await.unwrap();
765    assert!(claim_old(at + secs(2)).await.unwrap(), "the purge removed the claim");
766}
767
768/// Updating a session that does not exist is not an error.
769pub async fn apply_to_missing_session<St: SessionStore>(store: St) {
770    let now = now_secs();
771    let update = rotate(
772        aligned_session_id(),
773        now,
774        rotation(random_hash(), Supersede::Current, now),
775    );
776    assert!(!store.apply(&update).await.unwrap());
777}
778
779/// A random whole-minute instant between 1900 and 1931.
780fn far_past() -> DateTime<Utc> {
781    let bytes = Uuid::new_v4().into_bytes();
782    let minutes = i64::from(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], 0]));
783    DateTime::from_timestamp(-2_208_988_800 + minutes * 60, 0).unwrap_or_default()
784}
785
786/// Polls every future to completion concurrently, without a runtime dependency.
787async fn join_all<F: std::future::Future>(futures: impl IntoIterator<Item = F>) -> Vec<F::Output> {
788    use std::{
789        pin::Pin,
790        task::{Context, Poll},
791    };
792
793    struct JoinAll<F: std::future::Future> {
794        futures: Vec<Option<Pin<Box<F>>>>,
795        outputs: Vec<Option<F::Output>>,
796    }
797
798    // Every future is boxed and outputs are never pinned.
799    impl<F: std::future::Future> Unpin for JoinAll<F> {}
800
801    impl<F: std::future::Future> std::future::Future for JoinAll<F> {
802        type Output = Vec<F::Output>;
803
804        fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
805            let this = self.get_mut();
806            let mut pending = false;
807            for (slot, out) in this.futures.iter_mut().zip(this.outputs.iter_mut()) {
808                if let Some(fut) = slot {
809                    match fut.as_mut().poll(cx) {
810                        Poll::Ready(v) => {
811                            *out = Some(v);
812                            *slot = None;
813                        }
814                        Poll::Pending => pending = true,
815                    }
816                }
817            }
818            if pending {
819                Poll::Pending
820            } else {
821                Poll::Ready(this.outputs.iter_mut().filter_map(Option::take).collect())
822            }
823        }
824    }
825
826    let futures: Vec<_> = futures.into_iter().map(|f| Some(Box::pin(f))).collect();
827    let outputs = futures.iter().map(|_| None).collect();
828    JoinAll { futures, outputs }.await
829}