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