1#![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#[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
80pub fn now_secs() -> DateTime<Utc> {
82 let now = Utc::now();
83 DateTime::from_timestamp(now.timestamp(), 0).unwrap_or(now)
84}
85
86pub 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
98fn aligned_session_id() -> Uuid {
100 let mut bytes = *Uuid::new_v4().as_bytes();
101 bytes[15] = 0;
102 Uuid::from_bytes(bytes)
103}
104
105pub fn sample_session(now: DateTime<Utc>) -> SessionRecord {
107 sample_session_for(&format!("\"conformance-{}\"", Uuid::new_v4()), now)
108}
109
110pub 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
157pub 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
177pub async fn unknown_token_is_none<St: SessionStore>(store: St) {
179 assert!(store.find_by_token(&random_hash()).await.unwrap().is_none());
180}
181
182pub 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
234pub 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
254pub 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
293pub 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
325pub 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
358pub 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
390pub 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
418pub 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
439pub 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
463pub 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
514pub 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 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
558pub 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
580pub 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
610pub 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
629pub async fn delete_expired_absolute_and_idle<St: SessionStore>(store: St) {
631 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
659pub 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
694pub 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
724pub 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
768pub 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
779fn 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
786async 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 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}