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::{EventKind, SessionEvent},
35 fingerprint::{Baseline, Fingerprint},
36 store::{Rotation, SessionRecord, SessionStore, SessionUpdate, Supersede},
37 token::{SessionToken, TokenHash},
38};
39
40#[macro_export]
43macro_rules! store_conformance {
44 ($make:expr) => {
45 $crate::store_conformance!(@tests $make;
46 insert_and_find,
47 unknown_token_is_none,
48 records_round_trip,
49 exact_rotation_happens_once,
50 concurrent_rotation_has_one_winner,
51 rotation_if_issued_before,
52 current_rotation,
53 newer_token_used_is_tracked,
54 mark_used_sets_once,
55 mark_used_reaches_the_rotated_token,
56 none_fields_are_preserved,
57 prune_superseded_tokens,
58 delete_session_removes_tokens,
59 delete_subject_sessions,
60 list_subject_sessions,
61 delete_expired_absolute_and_idle,
62 events_record_and_purge,
63 event_claims_dedupe_within_window,
64 apply_to_missing_session,
65 );
66 };
67 (@tests $make:expr; $($name:ident),* $(,)?) => {
68 $(
69 #[::tokio::test(flavor = "multi_thread")]
70 async fn $name() {
71 $crate::testing::$name(($make)().await).await;
72 }
73 )*
74 };
75}
76
77pub fn now_secs() -> DateTime<Utc> {
79 let now = Utc::now();
80 DateTime::from_timestamp(now.timestamp(), 0).unwrap_or(now)
81}
82
83pub fn random_hash() -> TokenHash {
89 SessionToken::generate().expect("OS RNG").hash()
90}
91
92pub fn sample_session(now: DateTime<Utc>) -> SessionRecord {
94 sample_session_for(&format!("\"conformance-{}\"", Uuid::new_v4()), now)
95}
96
97pub fn sample_session_for(subject: &str, now: DateTime<Utc>) -> SessionRecord {
99 SessionRecord {
100 id: Uuid::new_v4(),
101 subject: subject.to_owned(),
102 created_at: now,
103 authenticated_at: now,
104 last_seen_at: now,
105 expires_at: now + TimeDelta::days(1),
106 step_up_required: false,
107 baseline: Baseline::new(Fingerprint {
108 ip_prefix: "203.0.113.0/24".into(),
109 ..Fingerprint::default()
110 }),
111 }
112}
113
114fn secs(n: i64) -> TimeDelta {
115 TimeDelta::seconds(n)
116}
117
118fn rotation(new_token: TokenHash, supersede: Supersede, prune_before: DateTime<Utc>) -> Rotation {
119 Rotation {
120 new_token,
121 supersede,
122 prune_superseded_before: prune_before,
123 }
124}
125
126fn rotate(id: Uuid, now: DateTime<Utc>, rotation: Rotation) -> SessionUpdate {
127 SessionUpdate {
128 rotation: Some(rotation),
129 ..SessionUpdate::touch(id, now)
130 }
131}
132
133pub async fn insert_and_find<St: SessionStore>(store: St) {
135 let now = now_secs();
136 let session = sample_session(now);
137 let token = random_hash();
138 store.insert_session(&session, &token).await.unwrap();
139
140 let found = store
141 .find_by_token(&token)
142 .await
143 .unwrap()
144 .expect("inserted token is found");
145 assert_eq!(found.session, session);
146 assert_eq!(found.token.hash, token);
147 assert_eq!(found.token.issued_at, now);
148 assert_eq!(found.token.first_used_at, None);
149 assert_eq!(found.token.superseded_at, None);
150 assert!(!found.newer_token_used);
151}
152
153pub async fn unknown_token_is_none<St: SessionStore>(store: St) {
155 assert!(store.find_by_token(&random_hash()).await.unwrap().is_none());
156}
157
158pub async fn records_round_trip<St: SessionStore>(store: St) {
160 let now = now_secs();
161 let mut session = sample_session_for(
162 &format!("{{\"tenant\":\"zürich-🦀\",\"user\":\"{}\"}}", Uuid::new_v4()),
163 now,
164 );
165 session.step_up_required = true;
166 session.authenticated_at = now - secs(30);
167 session.last_seen_at = now - secs(5);
168 session.baseline = Baseline {
169 fingerprint: Fingerprint {
170 ip_prefix: "2001:db8:1::/48".into(),
171 country: Some("BE".into()),
172 asn: Some(64_496),
173 browser: Some("Firefox".into()),
174 browser_major: Some(131),
175 os: Some("Linux".into()),
176 ch_platform: Some("Linux".into()),
177 ch_mobile: Some(false),
178 accept_language: Some("nl-be,nl,en".into()),
179 tls: Some("t13d1516h2_8daaf6152771_02713d6af862".into()),
180 },
181 previous_ip_prefix: Some("198.51.100.0/24".into()),
182 ip_changed_at: Some(now - secs(60)),
183 };
184 let token = random_hash();
185 store.insert_session(&session, &token).await.unwrap();
186 let found = store.find_by_token(&token).await.unwrap().unwrap();
187 assert_eq!(found.session, session);
188}
189
190pub async fn exact_rotation_happens_once<St: SessionStore>(store: St) {
192 let now = now_secs();
193 let session = sample_session(now);
194 let first = random_hash();
195 store.insert_session(&session, &first).await.unwrap();
196
197 let later = now + secs(60);
198 let second = random_hash();
199 let rotated = store
200 .apply(&rotate(
201 session.id,
202 later,
203 rotation(second, Supersede::Exact(first), now),
204 ))
205 .await
206 .unwrap();
207 assert!(rotated);
208
209 let third = random_hash();
210 let again = store
211 .apply(&rotate(
212 session.id,
213 later,
214 rotation(third, Supersede::Exact(first), now),
215 ))
216 .await
217 .unwrap();
218 assert!(!again, "a superseded token must not rotate again");
219 assert!(store.find_by_token(&third).await.unwrap().is_none());
220
221 let old = store.find_by_token(&first).await.unwrap().unwrap();
222 assert_eq!(old.token.superseded_at, Some(later));
223 let new = store.find_by_token(&second).await.unwrap().unwrap();
224 assert_eq!(new.token.issued_at, later);
225 assert_eq!(new.token.superseded_at, None);
226 assert_eq!(new.session.last_seen_at, later);
227}
228
229pub async fn concurrent_rotation_has_one_winner<St: SessionStore>(store: St) {
232 let now = now_secs();
233 let session = sample_session(now);
234 let first = random_hash();
235 store.insert_session(&session, &first).await.unwrap();
236
237 let candidates: Vec<TokenHash> = (0..16).map(|_| random_hash()).collect();
238 let attempts = candidates.iter().map(|&candidate| {
239 let update = rotate(
240 session.id,
241 now + secs(1),
242 rotation(candidate, Supersede::Exact(first), now),
243 );
244 let store = &store;
245 async move { store.apply(&update).await.unwrap() }
246 });
247 let results = join_all(attempts).await;
248 assert_eq!(results.iter().filter(|r| **r).count(), 1, "exactly one rotation wins");
249
250 let mut current = 0;
251 for (candidate, won) in candidates.iter().zip(&results) {
252 let found = store.find_by_token(candidate).await.unwrap();
253 assert_eq!(found.is_some(), *won);
254 if found.is_some_and(|f| f.token.superseded_at.is_none()) {
255 current += 1;
256 }
257 }
258 assert_eq!(current, 1, "exactly one current token");
259}
260
261pub async fn rotation_if_issued_before<St: SessionStore>(store: St) {
263 let now = now_secs();
264 let session = sample_session(now);
265 let first = random_hash();
266 store.insert_session(&session, &first).await.unwrap();
267
268 let too_new = random_hash();
269 let rotated = store
270 .apply(&rotate(
271 session.id,
272 now + secs(5),
273 rotation(too_new, Supersede::CurrentIssuedBefore(now), now),
274 ))
275 .await
276 .unwrap();
277 assert!(!rotated, "issued_at == cutoff is not strictly before");
278 assert!(store.find_by_token(&too_new).await.unwrap().is_none());
279
280 let second = random_hash();
281 let rotated = store
282 .apply(&rotate(
283 session.id,
284 now + secs(5),
285 rotation(second, Supersede::CurrentIssuedBefore(now + secs(1)), now),
286 ))
287 .await
288 .unwrap();
289 assert!(rotated);
290 let old = store.find_by_token(&first).await.unwrap().unwrap();
291 assert_eq!(old.token.superseded_at, Some(now + secs(5)));
292}
293
294pub async fn current_rotation<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 let second = random_hash();
301 store
302 .apply(&rotate(
303 session.id,
304 now + secs(1),
305 rotation(second, Supersede::Exact(first), now),
306 ))
307 .await
308 .unwrap();
309
310 let third = random_hash();
311 let rotated = store
312 .apply(&rotate(
313 session.id,
314 now + secs(2),
315 rotation(third, Supersede::Current, now),
316 ))
317 .await
318 .unwrap();
319 assert!(rotated);
320 let second_found = store.find_by_token(&second).await.unwrap().unwrap();
321 assert_eq!(second_found.token.superseded_at, Some(now + secs(2)));
322 let third_found = store.find_by_token(&third).await.unwrap().unwrap();
323 assert_eq!(third_found.token.superseded_at, None);
324}
325
326pub async fn newer_token_used_is_tracked<St: SessionStore>(store: St) {
328 let now = now_secs();
329 let session = sample_session(now);
330 let first = random_hash();
331 store.insert_session(&session, &first).await.unwrap();
332 let second = random_hash();
333 store
334 .apply(&rotate(
335 session.id,
336 now + secs(1),
337 rotation(second, Supersede::Exact(first), now),
338 ))
339 .await
340 .unwrap();
341 assert!(!store.find_by_token(&first).await.unwrap().unwrap().newer_token_used);
342
343 store
344 .apply(&SessionUpdate {
345 mark_used: Some(second),
346 ..SessionUpdate::touch(session.id, now + secs(2))
347 })
348 .await
349 .unwrap();
350 assert!(store.find_by_token(&first).await.unwrap().unwrap().newer_token_used);
351 assert!(!store.find_by_token(&second).await.unwrap().unwrap().newer_token_used);
352}
353
354pub async fn mark_used_sets_once<St: SessionStore>(store: St) {
356 let now = now_secs();
357 let session = sample_session(now);
358 let token = random_hash();
359 store.insert_session(&session, &token).await.unwrap();
360
361 for offset in [3, 9] {
362 store
363 .apply(&SessionUpdate {
364 mark_used: Some(token),
365 ..SessionUpdate::touch(session.id, now + secs(offset))
366 })
367 .await
368 .unwrap();
369 }
370 let found = store.find_by_token(&token).await.unwrap().unwrap();
371 assert_eq!(found.token.first_used_at, Some(now + secs(3)));
372 assert_eq!(found.session.last_seen_at, now + secs(9));
373}
374
375pub async fn mark_used_reaches_the_rotated_token<St: SessionStore>(store: St) {
377 let now = now_secs();
378 let session = sample_session(now);
379 let first = random_hash();
380 store.insert_session(&session, &first).await.unwrap();
381 let second = random_hash();
382 let rotated = store
383 .apply(&SessionUpdate {
384 mark_used: Some(second),
385 ..rotate(
386 session.id,
387 now + secs(1),
388 rotation(second, Supersede::Exact(first), now),
389 )
390 })
391 .await
392 .unwrap();
393 assert!(rotated);
394 let found = store.find_by_token(&second).await.unwrap().unwrap();
395 assert_eq!(found.token.first_used_at, Some(now + secs(1)));
396 assert!(store.find_by_token(&first).await.unwrap().unwrap().newer_token_used);
397}
398
399pub async fn none_fields_are_preserved<St: SessionStore>(store: St) {
401 let now = now_secs();
402 let session = sample_session(now);
403 let token = random_hash();
404 store.insert_session(&session, &token).await.unwrap();
405
406 let mut baseline = session.baseline.clone();
407 baseline.fingerprint.country = Some("NL".into());
408 store
409 .apply(&SessionUpdate {
410 baseline: Some(baseline.clone()),
411 step_up_required: Some(true),
412 authenticated_at: Some(now + secs(4)),
413 ..SessionUpdate::touch(session.id, now + secs(4))
414 })
415 .await
416 .unwrap();
417 store
418 .apply(&SessionUpdate::touch(session.id, now + secs(8)))
419 .await
420 .unwrap();
421
422 let found = store.find_by_token(&token).await.unwrap().unwrap().session;
423 assert_eq!(found.baseline, baseline);
424 assert!(found.step_up_required);
425 assert_eq!(found.authenticated_at, now + secs(4));
426 assert_eq!(found.last_seen_at, now + secs(8));
427 assert_eq!(found.created_at, session.created_at);
428 assert_eq!(found.expires_at, session.expires_at);
429 assert_eq!(found.subject, session.subject);
430
431 store
432 .apply(&SessionUpdate {
433 step_up_required: Some(false),
434 ..SessionUpdate::touch(session.id, now + secs(9))
435 })
436 .await
437 .unwrap();
438 assert!(
439 !store
440 .find_by_token(&token)
441 .await
442 .unwrap()
443 .unwrap()
444 .session
445 .step_up_required
446 );
447}
448
449pub async fn prune_superseded_tokens<St: SessionStore>(store: St) {
451 let now = now_secs();
452 let session = sample_session(now);
453 let t1 = random_hash();
454 store.insert_session(&session, &t1).await.unwrap();
455 let t2 = random_hash();
456 store
457 .apply(&rotate(
458 session.id,
459 now + secs(10),
460 rotation(t2, Supersede::Exact(t1), now),
461 ))
462 .await
463 .unwrap();
464 let t3 = random_hash();
465 store
466 .apply(&rotate(
467 session.id,
468 now + secs(20),
469 rotation(t3, Supersede::Exact(t2), now),
470 ))
471 .await
472 .unwrap();
473
474 let t4 = random_hash();
476 store
477 .apply(&rotate(
478 session.id,
479 now + secs(30),
480 rotation(t4, Supersede::Exact(t3), now + secs(15)),
481 ))
482 .await
483 .unwrap();
484 assert!(
485 store.find_by_token(&t1).await.unwrap().is_none(),
486 "superseded before cutoff is pruned"
487 );
488 assert!(store.find_by_token(&t2).await.unwrap().is_some());
489 assert!(store.find_by_token(&t3).await.unwrap().is_some());
490 assert!(store.find_by_token(&t4).await.unwrap().is_some());
491}
492
493pub async fn delete_session_removes_tokens<St: SessionStore>(store: St) {
495 let now = now_secs();
496 let session = sample_session(now);
497 let t1 = random_hash();
498 store.insert_session(&session, &t1).await.unwrap();
499 let t2 = random_hash();
500 store
501 .apply(&rotate(
502 session.id,
503 now + secs(1),
504 rotation(t2, Supersede::Exact(t1), now),
505 ))
506 .await
507 .unwrap();
508
509 assert!(store.delete_session(session.id).await.unwrap());
510 assert!(!store.delete_session(session.id).await.unwrap());
511 assert!(store.find_by_token(&t1).await.unwrap().is_none());
512 assert!(store.find_by_token(&t2).await.unwrap().is_none());
513}
514
515pub async fn delete_subject_sessions<St: SessionStore>(store: St) {
517 let now = now_secs();
518 let subject = format!("\"conformance-{}\"", Uuid::new_v4());
519 let mut tokens = Vec::new();
520 for _ in 0..3 {
521 let token = random_hash();
522 store
523 .insert_session(&sample_session_for(&subject, now), &token)
524 .await
525 .unwrap();
526 tokens.push(token);
527 }
528 let other = random_hash();
529 store.insert_session(&sample_session(now), &other).await.unwrap();
530
531 assert_eq!(store.delete_subject_sessions(&subject).await.unwrap(), 3);
532 assert_eq!(store.delete_subject_sessions(&subject).await.unwrap(), 0);
533 for token in &tokens {
534 assert!(store.find_by_token(token).await.unwrap().is_none());
535 }
536 assert!(store.find_by_token(&other).await.unwrap().is_some());
537}
538
539pub async fn list_subject_sessions<St: SessionStore>(store: St) {
541 let now = now_secs();
542 let subject = format!("\"conformance-{}\"", Uuid::new_v4());
543 let a = sample_session_for(&subject, now);
544 let b = sample_session_for(&subject, now - secs(60));
545 store.insert_session(&a, &random_hash()).await.unwrap();
546 store.insert_session(&b, &random_hash()).await.unwrap();
547 store
548 .insert_session(&sample_session(now), &random_hash())
549 .await
550 .unwrap();
551
552 let mut listed = store.list_subject_sessions(&subject).await.unwrap();
553 listed.sort_by_key(|s| s.created_at);
554 assert_eq!(listed, vec![b, a]);
555 assert_eq!(store.list_subject_sessions("\"nobody\"").await.unwrap(), vec![]);
556}
557
558pub async fn delete_expired_absolute_and_idle<St: SessionStore>(store: St) {
560 let base = far_past();
565 let later = Utc::now() + TimeDelta::days(1);
566 let mut expired = sample_session(base);
567 expired.expires_at = base;
568 let mut idle = sample_session(base);
569 idle.last_seen_at = base - secs(100);
570 idle.expires_at = later;
571 let mut live = sample_session(base);
572 live.expires_at = later;
573 live.last_seen_at = base - secs(99);
574
575 let (te, ti, tl) = (random_hash(), random_hash(), random_hash());
576 store.insert_session(&expired, &te).await.unwrap();
577 store.insert_session(&idle, &ti).await.unwrap();
578 store.insert_session(&live, &tl).await.unwrap();
579
580 let deleted = store.delete_expired(base, base - secs(100)).await.unwrap();
581 assert!(deleted >= 1);
582 assert!(store.find_by_token(&te).await.unwrap().is_none());
583 assert!(store.find_by_token(&ti).await.unwrap().is_none());
584 assert!(store.find_by_token(&tl).await.unwrap().is_some());
585 store.delete_session(live.id).await.unwrap();
586}
587
588pub async fn events_record_and_purge<St: SessionStore>(store: St) {
590 let at = far_past();
591 let event = SessionEvent {
592 session_id: Uuid::new_v4(),
593 subject: "\"conformance\"".into(),
594 kind: EventKind::RiskRevoked,
595 enforced: false,
596 score: Some(95),
597 signals: vec![Check::OsChanged, Check::CountryChanged],
598 ip: IpAddr::V4(Ipv4Addr::new(203, 0, 113, 0)),
599 user_agent: Some("Mozilla/5.0 🦀".into()),
600 at,
601 };
602 store.record_event(&event).await.unwrap();
603 store
604 .record_event(&SessionEvent {
605 user_agent: None,
606 score: None,
607 signals: vec![],
608 ..event.clone()
609 })
610 .await
611 .unwrap();
612 store.delete_events_before(at).await.unwrap();
613 assert!(store.delete_events_before(at + secs(1)).await.unwrap() >= 2);
614}
615
616pub async fn event_claims_dedupe_within_window<St: SessionStore>(store: St) {
618 let now = now_secs();
619 let window = secs(600);
620 let session = Uuid::new_v4();
621 let claim = |kind, at| store.claim_event(session, kind, at, window);
622 assert!(claim(EventKind::RiskRevoked, now).await.unwrap());
623 assert!(!claim(EventKind::RiskRevoked, now + secs(1)).await.unwrap());
624 assert!(!claim(EventKind::RiskRevoked, now + secs(599)).await.unwrap());
625 assert!(
626 claim(EventKind::StepUpRequired, now).await.unwrap(),
627 "kinds are independent"
628 );
629 assert!(
630 store
631 .claim_event(Uuid::new_v4(), EventKind::RiskRevoked, now, window)
632 .await
633 .unwrap(),
634 "sessions are independent"
635 );
636 assert!(
637 claim(EventKind::RiskRevoked, now + secs(600)).await.unwrap(),
638 "the window elapsed"
639 );
640 assert!(
641 !claim(EventKind::RiskRevoked, now + secs(601)).await.unwrap(),
642 "the new claim holds"
643 );
644}
645
646pub async fn apply_to_missing_session<St: SessionStore>(store: St) {
648 let now = now_secs();
649 let update = rotate(Uuid::new_v4(), now, rotation(random_hash(), Supersede::Current, now));
650 assert!(!store.apply(&update).await.unwrap());
651}
652
653fn far_past() -> DateTime<Utc> {
655 let bytes = Uuid::new_v4().into_bytes();
656 let minutes = i64::from(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], 0]));
657 DateTime::from_timestamp(-2_208_988_800 + minutes * 60, 0).unwrap_or_default()
658}
659
660async fn join_all<F: std::future::Future>(futures: impl IntoIterator<Item = F>) -> Vec<F::Output> {
662 use std::{
663 pin::Pin,
664 task::{Context, Poll},
665 };
666
667 struct JoinAll<F: std::future::Future> {
668 futures: Vec<Option<Pin<Box<F>>>>,
669 outputs: Vec<Option<F::Output>>,
670 }
671
672 impl<F: std::future::Future> Unpin for JoinAll<F> {}
674
675 impl<F: std::future::Future> std::future::Future for JoinAll<F> {
676 type Output = Vec<F::Output>;
677
678 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
679 let this = self.get_mut();
680 let mut pending = false;
681 for (slot, out) in this.futures.iter_mut().zip(this.outputs.iter_mut()) {
682 if let Some(fut) = slot {
683 match fut.as_mut().poll(cx) {
684 Poll::Ready(v) => {
685 *out = Some(v);
686 *slot = None;
687 }
688 Poll::Pending => pending = true,
689 }
690 }
691 }
692 if pending {
693 Poll::Pending
694 } else {
695 Poll::Ready(this.outputs.iter_mut().filter_map(Option::take).collect())
696 }
697 }
698 }
699
700 let futures: Vec<_> = futures.into_iter().map(|f| Some(Box::pin(f))).collect();
701 let outputs = futures.iter().map(|_| None).collect();
702 JoinAll { futures, outputs }.await
703}