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 {
92 SessionToken::generate().expect("OS RNG").hash()
93}
94
95pub fn sample_session(now: DateTime<Utc>) -> SessionRecord {
97 sample_session_for(&format!("\"conformance-{}\"", Uuid::new_v4()), now)
98}
99
100pub 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
147pub 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
167pub async fn unknown_token_is_none<St: SessionStore>(store: St) {
169 assert!(store.find_by_token(&random_hash()).await.unwrap().is_none());
170}
171
172pub 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
224pub 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
244pub 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
283pub 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
315pub 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
348pub 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
380pub 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
408pub 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
429pub 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
453pub 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
504pub 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 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
548pub 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
570pub 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
600pub 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
619pub async fn delete_expired_absolute_and_idle<St: SessionStore>(store: St) {
621 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
649pub 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
684pub 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
714pub 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
758pub 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
765fn 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
772async 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 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}