Skip to main content

churust_redis/
lib.rs

1//! Redis-backed sessions for the [Churust] web framework.
2//!
3//! [`RedisStore`] implements [`SessionStore`], so it drops into
4//! [`Sessions::with_store`]:
5//!
6//! ```no_run
7//! use churust_core::{Churust, Sessions};
8//! use churust_redis::RedisStore;
9//!
10//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
11//! let store = RedisStore::connect("redis://127.0.0.1/").await?;
12//! let app = Churust::server()
13//!     .install(Sessions::with_store(store.ttl(3600)))
14//!     .build();
15//! # let _ = app;
16//! # Ok(())
17//! # }
18//! ```
19//!
20//! # What this buys over the cookie store
21//!
22//! Revocation, and room. [`CookieStore`](churust_core::CookieStore) keeps the
23//! whole session in the visitor's cookie: it travels on every request, it is
24//! readable by whoever holds it, and a copy taken before logout stays valid
25//! until its signed deadline because nothing server-side records that it was
26//! withdrawn. Here the cookie carries only an opaque identifier, the contents
27//! live in Redis, and logging out deletes the record, so a stolen cookie stops
28//! working the moment its owner signs out.
29//!
30//! That promise is only worth having if a broken one is audible, so a delete
31//! that does not reach Redis fails the request instead of returning a farewell
32//! page over a session that is still alive.
33//!
34//! The cost is an ordinary one: a network round trip per request that touches
35//! the session, and a Redis to keep running.
36//!
37//! # Session identifiers
38//!
39//! 256 bits from the operating system's CSPRNG, base64url encoded. A session id
40//! is a bearer credential for the whole account, so it is generated the same
41//! way a token would be, and identifiers that do not match that shape are
42//! refused on load without a round trip rather than passed through into a key.
43//!
44//! [Churust]: churust_core::Churust
45//! [`Sessions::with_store`]: churust_core::Sessions::with_store
46
47#![deny(missing_docs)]
48
49use async_trait::async_trait;
50use base64::engine::general_purpose::URL_SAFE_NO_PAD;
51use base64::Engine;
52use churust_core::{Error, SessionStore, SESSION_ID_KEY};
53use std::collections::BTreeMap;
54use std::sync::Arc;
55
56/// Bytes of entropy in a session identifier.
57const ID_BYTES: usize = 32;
58/// Encoded length of `ID_BYTES` in base64url without padding.
59const ID_CHARS: usize = 43;
60/// How long a session lives without being written, in seconds.
61const DEFAULT_TTL: u64 = 24 * 60 * 60;
62/// What every key is prefixed with, so a shared Redis stays legible.
63const DEFAULT_PREFIX: &str = "churust:session:";
64
65/// The key/value operations a session store needs.
66///
67/// Private on purpose: it exists so the store's own logic can be tested without
68/// a running server, not as an extension point. A different backing store
69/// should implement [`SessionStore`] directly.
70#[async_trait]
71trait Backend: Send + Sync + 'static {
72    async fn get(&self, key: &str) -> Option<String>;
73    async fn set(&self, key: &str, value: &str, ttl: u64);
74    async fn touch(&self, key: &str, ttl: u64);
75    /// Whether the key is known to be gone. `false` means the command did not
76    /// get through, which the store must not mistake for a revocation.
77    async fn del(&self, key: &str) -> bool;
78}
79
80/// A [`SessionStore`] that keeps session contents in Redis.
81#[derive(Clone)]
82pub struct RedisStore {
83    backend: Arc<dyn Backend>,
84    prefix: String,
85    ttl: u64,
86    sliding: bool,
87}
88
89impl std::fmt::Debug for RedisStore {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.debug_struct("RedisStore")
92            .field("prefix", &self.prefix)
93            .field("ttl", &self.ttl)
94            .field("sliding", &self.sliding)
95            .finish_non_exhaustive()
96    }
97}
98
99impl RedisStore {
100    /// Connect to `url` (for example `redis://127.0.0.1/`).
101    ///
102    /// The connection is established now and then multiplexed: one socket
103    /// carries every session operation concurrently. If it drops, the next
104    /// operation reconnects.
105    ///
106    /// # Errors
107    ///
108    /// If the URL does not parse or the first connection cannot be made.
109    /// Connecting at startup rather than lazily is deliberate: a typo in the
110    /// URL should stop the boot, not surface later as everybody being logged
111    /// out.
112    pub async fn connect(url: &str) -> Result<Self, redis::RedisError> {
113        let client = redis::Client::open(url)?;
114        // Prove the endpoint answers before returning a store that claims to
115        // work, then keep the connection for the first request to use.
116        let connection = client.get_multiplexed_async_connection().await?;
117        Ok(Self::from_backend(RedisBackend {
118            client,
119            connection: tokio::sync::Mutex::new(Some(connection)),
120        }))
121    }
122
123    /// Use an already-configured client.
124    ///
125    /// Reach for this when the connection needs settings `connect` does not
126    /// expose. No connection is made until the first request touches a session.
127    pub fn from_client(client: redis::Client) -> Self {
128        Self::from_backend(RedisBackend {
129            client,
130            connection: tokio::sync::Mutex::new(None),
131        })
132    }
133
134    fn from_backend(backend: impl Backend) -> Self {
135        Self {
136            backend: Arc::new(backend),
137            prefix: DEFAULT_PREFIX.to_string(),
138            ttl: DEFAULT_TTL,
139            sliding: true,
140        }
141    }
142
143    /// Expire a session `secs` after it was last touched (default 24 hours).
144    ///
145    /// # Panics
146    ///
147    /// If `secs` is zero, which would delete every session as it was written.
148    pub fn ttl(mut self, secs: u64) -> Self {
149        assert!(secs > 0, "session ttl must be at least one second");
150        self.ttl = secs;
151        self
152    }
153
154    /// Prefix every Redis key (default `churust:session:`).
155    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
156        self.prefix = prefix.into();
157        self
158    }
159
160    /// Whether reading a session extends its expiry (default `true`).
161    ///
162    /// Sliding keeps an active visitor signed in without writing the session on
163    /// every request, at the cost of one `EXPIRE` per read. Turn it off for an
164    /// absolute lifetime, where a session ends a fixed time after it started no
165    /// matter how busy the visitor is.
166    pub fn sliding(mut self, yes: bool) -> Self {
167        self.sliding = yes;
168        self
169    }
170
171    fn key(&self, id: &str) -> String {
172        format!("{}{}", self.prefix, id)
173    }
174}
175
176/// A fresh session identifier: 256 bits of OS entropy, base64url encoded.
177///
178/// # Panics
179///
180/// If the operating system cannot supply randomness. There is no safe fallback:
181/// a predictable session id is a hijacked account, and continuing with a weak
182/// one would be worse than not starting.
183fn new_id() -> String {
184    let mut bytes = [0u8; ID_BYTES];
185    getrandom::fill(&mut bytes).expect("the OS must be able to supply randomness for a session id");
186    URL_SAFE_NO_PAD.encode(bytes)
187}
188
189/// Whether `raw` has the shape this store issues.
190///
191/// Checked before the key is built so a hostile cookie cannot smuggle a colon,
192/// a newline or a glob into a Redis key, and so junk costs no round trip.
193fn is_well_formed(raw: &str) -> bool {
194    raw.len() == ID_CHARS
195        && raw
196            .bytes()
197            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
198}
199
200#[async_trait]
201impl SessionStore for RedisStore {
202    async fn load(&self, raw: &str) -> Option<BTreeMap<String, String>> {
203        if !is_well_formed(raw) {
204            return None;
205        }
206        let key = self.key(raw);
207        let stored = self.backend.get(&key).await?;
208        let mut data: BTreeMap<String, String> = serde_json::from_str(&stored).ok()?;
209
210        if self.sliding {
211            self.backend.touch(&key, self.ttl).await;
212        }
213
214        // Put the identifier back so `store` knows which record it is
215        // replacing, and so `Session::rotate` has something to remove.
216        data.insert(SESSION_ID_KEY.to_string(), raw.to_string());
217        Some(data)
218    }
219
220    async fn store(
221        &self,
222        data: &BTreeMap<String, String>,
223        previous: Option<&str>,
224    ) -> Result<Option<String>, Error> {
225        // An emptied session is a logout. Delete the record rather than writing
226        // an empty one: the point of a server-side store is that the withdrawal
227        // is real, not advisory.
228        if data.is_empty() {
229            if let Some(old) = previous.filter(|raw| is_well_formed(raw)) {
230                if !self.backend.del(&self.key(old)).await {
231                    return Err(revocation_failed());
232                }
233            }
234            return Ok(None);
235        }
236
237        let carried = data.get(SESSION_ID_KEY).filter(|id| is_well_formed(id));
238        let id = match carried {
239            Some(id) => id.clone(),
240            None => new_id(),
241        };
242
243        // A rotated session (`Session::rotate`, which `Identity::login` calls)
244        // arrives without its identifier, so a new one was just minted above.
245        // The record it came from is withdrawn here, which is what stops a
246        // planted session id from surviving a privilege change.
247        if let Some(old) = previous.filter(|raw| is_well_formed(raw) && *raw != id) {
248            // Same reasoning as the logout above, and the same consequence if
249            // it is ignored: the planted identifier keeps resolving, so the
250            // fixation defence this delete *is* did not happen. Better to fail
251            // the privilege change than to complete it and leave the attacker
252            // holding a session the victim's browser has already moved on from.
253            if !self.backend.del(&self.key(old)).await {
254                return Err(revocation_failed());
255            }
256        }
257
258        // The identifier is the key; storing it in the value as well would be
259        // one more thing that can disagree with itself.
260        let mut payload = data.clone();
261        payload.remove(SESSION_ID_KEY);
262        let Ok(encoded) = serde_json::to_string(&payload) else {
263            return Ok(None);
264        };
265
266        // A failed write is deliberately not an error: the visitor signs in
267        // again, which is the degradation this store promises when Redis is
268        // unwell. Only a failed *withdrawal* is worth a response, because that
269        // one leaves a credential alive that somebody asked to have killed.
270        self.backend.set(&self.key(&id), &encoded, self.ttl).await;
271        Ok(Some(id))
272    }
273}
274
275/// The error a caller sees when a session could not be withdrawn.
276///
277/// Deliberately says nothing about Redis. The message is the response body, and
278/// which backing store an application uses, or that it is having a bad day, is
279/// not the visitor's business; what they need to know is that they are still
280/// signed in and should try again.
281fn revocation_failed() -> Error {
282    Error::internal("the session could not be ended; please try again")
283}
284
285/// The real backend: one multiplexed connection, reconnected on failure.
286struct RedisBackend {
287    client: redis::Client,
288    /// `None` until the first successful connection, and set back to `None`
289    /// whenever an operation fails so the next one redials.
290    connection: tokio::sync::Mutex<Option<redis::aio::MultiplexedConnection>>,
291}
292
293impl RedisBackend {
294    /// Run `cmd`, reconnecting once if the cached connection has gone away.
295    ///
296    /// Redis being down must not take the application with it: a session that
297    /// cannot be read is an anonymous visitor, and a session that cannot be
298    /// written is a visitor who has to sign in again. Both are worse than
299    /// working and much better than a 500 on every route.
300    ///
301    /// A delete is the exception, and it is why [`Backend::del`] reports its
302    /// outcome while the others do not. Degrading a read or a write costs the
303    /// visitor a sign-in; degrading a delete hands an attacker the session the
304    /// visitor was trying to destroy, so that one is raised rather than
305    /// shrugged off.
306    async fn run<T: redis::FromRedisValue>(&self, cmd: &redis::Cmd) -> Option<T> {
307        for attempt in 0..2 {
308            let mut guard = self.connection.lock().await;
309            if guard.is_none() {
310                match self.client.get_multiplexed_async_connection().await {
311                    Ok(fresh) => *guard = Some(fresh),
312                    Err(_) => return None,
313                }
314            }
315            // Cloning the multiplexed connection is how concurrent commands
316            // share the socket, so the lock is released before the round trip.
317            let mut conn = guard.as_ref()?.clone();
318            drop(guard);
319
320            match cmd.query_async::<T>(&mut conn).await {
321                Ok(value) => return Some(value),
322                Err(_) if attempt == 0 => {
323                    // Drop the connection so the retry dials a new one.
324                    *self.connection.lock().await = None;
325                }
326                Err(_) => return None,
327            }
328        }
329        None
330    }
331}
332
333#[async_trait]
334impl Backend for RedisBackend {
335    async fn get(&self, key: &str) -> Option<String> {
336        self.run::<Option<String>>(redis::cmd("GET").arg(key))
337            .await
338            .flatten()
339    }
340
341    async fn set(&self, key: &str, value: &str, ttl: u64) {
342        // SET with EX rather than SET then EXPIRE: one round trip, and no
343        // window in which a session exists without a deadline.
344        let _ = self
345            .run::<()>(redis::cmd("SET").arg(key).arg(value).arg("EX").arg(ttl))
346            .await;
347    }
348
349    async fn touch(&self, key: &str, ttl: u64) {
350        let _ = self.run::<()>(redis::cmd("EXPIRE").arg(key).arg(ttl)).await;
351    }
352
353    async fn del(&self, key: &str) -> bool {
354        // `run` already retried once and redialled in between, so a `None` here
355        // means the key's fate is genuinely unknown, not merely that the socket
356        // blinked. Redis answers `DEL` with the number of keys removed and zero
357        // is a perfectly good answer — the record had already expired — so it
358        // is the command failing, not the count, that this reports.
359        self.run::<()>(redis::cmd("DEL").arg(key)).await.is_some()
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use std::sync::Mutex;
367    use std::time::{Duration, Instant};
368
369    /// An in-process stand-in for Redis, with expiry, so the store's own logic
370    /// is covered without a server in the loop.
371    #[derive(Default)]
372    struct MemoryBackend {
373        entries: Mutex<BTreeMap<String, (String, Instant)>>,
374    }
375
376    impl MemoryBackend {
377        fn live(&self, key: &str) -> Option<String> {
378            let entries = self.entries.lock().unwrap();
379            let (value, expires) = entries.get(key)?;
380            (*expires > Instant::now()).then(|| value.clone())
381        }
382
383        fn len(&self) -> usize {
384            let now = Instant::now();
385            self.entries
386                .lock()
387                .unwrap()
388                .values()
389                .filter(|(_, expires)| *expires > now)
390                .count()
391        }
392    }
393
394    #[async_trait]
395    impl Backend for Arc<MemoryBackend> {
396        async fn get(&self, key: &str) -> Option<String> {
397            self.live(key)
398        }
399
400        async fn set(&self, key: &str, value: &str, ttl: u64) {
401            self.entries.lock().unwrap().insert(
402                key.to_string(),
403                (value.to_string(), Instant::now() + Duration::from_secs(ttl)),
404            );
405        }
406
407        async fn touch(&self, key: &str, ttl: u64) {
408            if let Some(entry) = self.entries.lock().unwrap().get_mut(key) {
409                entry.1 = Instant::now() + Duration::from_secs(ttl);
410            }
411        }
412
413        async fn del(&self, key: &str) -> bool {
414            self.entries.lock().unwrap().remove(key);
415            true
416        }
417    }
418
419    fn store() -> (RedisStore, Arc<MemoryBackend>) {
420        let backend = Arc::new(MemoryBackend::default());
421        let store = RedisStore {
422            backend: Arc::new(backend.clone()),
423            prefix: DEFAULT_PREFIX.to_string(),
424            ttl: DEFAULT_TTL,
425            sliding: true,
426        };
427        (store, backend)
428    }
429
430    /// A backend that reads and writes happily but never manages a delete.
431    ///
432    /// It is not merely a broken `del`: it is the shape of the interesting
433    /// failure, where everything looks healthy right up to the moment a
434    /// visitor asks to be signed out and the one operation that matters is the
435    /// one that does not happen.
436    struct RefusesDeletes(Arc<MemoryBackend>);
437
438    #[async_trait]
439    impl Backend for RefusesDeletes {
440        async fn get(&self, key: &str) -> Option<String> {
441            self.0.get(key).await
442        }
443
444        async fn set(&self, key: &str, value: &str, ttl: u64) {
445            self.0.set(key, value, ttl).await
446        }
447
448        async fn touch(&self, key: &str, ttl: u64) {
449            self.0.touch(key, ttl).await
450        }
451
452        async fn del(&self, _key: &str) -> bool {
453            false
454        }
455    }
456
457    fn refusing_store() -> (RedisStore, Arc<MemoryBackend>) {
458        let backend = Arc::new(MemoryBackend::default());
459        let store = RedisStore {
460            backend: Arc::new(RefusesDeletes(backend.clone())),
461            prefix: DEFAULT_PREFIX.to_string(),
462            ttl: DEFAULT_TTL,
463            sliding: true,
464        };
465        (store, backend)
466    }
467
468    fn data(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
469        pairs
470            .iter()
471            .map(|(k, v)| (k.to_string(), v.to_string()))
472            .collect()
473    }
474
475    #[tokio::test]
476    async fn a_session_round_trips_through_the_backend() {
477        let (store, _) = store();
478        let id = store
479            .store(&data(&[("user", "ana")]), None)
480            .await
481            .expect("the write must succeed")
482            .expect("a new session gets an id");
483
484        let back = store.load(&id).await.expect("it must load again");
485        assert_eq!(back.get("user").map(String::as_str), Some("ana"));
486    }
487
488    #[tokio::test]
489    async fn the_cookie_carries_only_an_identifier() {
490        let (store, backend) = store();
491        let id = store
492            .store(&data(&[("user", "ana"), ("secret", "hunter2")]), None)
493            .await
494            .unwrap()
495            .unwrap();
496
497        assert!(is_well_formed(&id));
498        assert!(
499            !id.contains("ana") && !id.contains("hunter2"),
500            "session contents must not travel in the cookie: {id}"
501        );
502        let raw = backend.live(&format!("{DEFAULT_PREFIX}{id}")).unwrap();
503        assert!(raw.contains("hunter2"), "the value lives server side");
504    }
505
506    #[tokio::test]
507    async fn identifiers_are_unpredictable_and_distinct() {
508        let (store, _) = store();
509        let mut seen = std::collections::HashSet::new();
510        for _ in 0..256 {
511            let id = store
512                .store(&data(&[("k", "v")]), None)
513                .await
514                .unwrap()
515                .unwrap();
516            assert_eq!(id.len(), ID_CHARS);
517            assert!(seen.insert(id), "a session id was reused");
518        }
519    }
520
521    #[tokio::test]
522    async fn logging_out_deletes_the_record() {
523        let (store, backend) = store();
524        let id = store
525            .store(&data(&[("user", "ana")]), None)
526            .await
527            .unwrap()
528            .unwrap();
529        assert_eq!(backend.len(), 1);
530
531        // An emptied session is what `Session::clear` produces.
532        let reissued = store.store(&BTreeMap::new(), Some(&id)).await;
533        assert!(
534            matches!(reissued, Ok(None)),
535            "a completed logout has no new cookie to set"
536        );
537        assert_eq!(
538            backend.len(),
539            0,
540            "the record must be gone, not merely stale"
541        );
542        assert!(
543            store.load(&id).await.is_none(),
544            "a cookie copied before logout must stop working"
545        );
546    }
547
548    #[tokio::test]
549    async fn a_logout_the_backend_refused_is_not_reported_as_a_logout() {
550        let (store, backend) = refusing_store();
551        let id = store
552            .store(&data(&[("user", "ana")]), None)
553            .await
554            .unwrap()
555            .unwrap();
556
557        let outcome = store.store(&BTreeMap::new(), Some(&id)).await;
558
559        assert!(
560            store.load(&id).await.is_some(),
561            "the stand-in must have kept the record, or this proves nothing"
562        );
563        assert_eq!(backend.len(), 1, "the record survived the failed delete");
564        assert!(
565            outcome.is_err(),
566            "a session that is still valid must not be reported as withdrawn"
567        );
568    }
569
570    #[tokio::test]
571    async fn a_rotation_whose_withdrawal_failed_is_refused_too() {
572        // `Session::rotate`, which `Identity::login` calls, is the session
573        // fixation defence: the planted identifier must stop resolving. If the
574        // delete does not happen the defence did not happen either, so the
575        // request must not be answered as though it had.
576        let (store, _) = refusing_store();
577        let first = store
578            .store(&data(&[("cart", "3")]), None)
579            .await
580            .unwrap()
581            .unwrap();
582
583        let mut rotated = store.load(&first).await.unwrap();
584        rotated.remove(SESSION_ID_KEY);
585        rotated.insert("user".into(), "ana".into());
586
587        assert!(
588            store.store(&rotated, Some(&first)).await.is_err(),
589            "the pre-login identifier still resolves, so the rotation failed"
590        );
591        assert!(
592            store.load(&first).await.is_some(),
593            "the stand-in must have kept the record, or this proves nothing"
594        );
595    }
596
597    #[tokio::test]
598    async fn rotating_mints_a_new_id_and_withdraws_the_old_one() {
599        let (store, backend) = store();
600        let first = store
601            .store(&data(&[("cart", "3")]), None)
602            .await
603            .unwrap()
604            .unwrap();
605
606        // What `Session::rotate` leaves behind: the contents, minus the id.
607        let mut rotated = store.load(&first).await.unwrap();
608        rotated.remove(SESSION_ID_KEY);
609        rotated.insert("user".into(), "ana".into());
610
611        let second = store.store(&rotated, Some(&first)).await.unwrap().unwrap();
612        assert_ne!(first, second, "a rotated session must change identifier");
613        assert!(
614            store.load(&first).await.is_none(),
615            "the pre-login identifier must not still resolve"
616        );
617        let carried = store.load(&second).await.unwrap();
618        assert_eq!(carried.get("cart").map(String::as_str), Some("3"));
619        assert_eq!(carried.get("user").map(String::as_str), Some("ana"));
620        assert_eq!(backend.len(), 1, "the old record was not left behind");
621    }
622
623    #[tokio::test]
624    async fn an_unchanged_session_keeps_its_identifier() {
625        let (store, backend) = store();
626        let id = store
627            .store(&data(&[("user", "ana")]), None)
628            .await
629            .unwrap()
630            .unwrap();
631
632        let mut loaded = store.load(&id).await.unwrap();
633        loaded.insert("theme".into(), "dark".into());
634        let again = store.store(&loaded, Some(&id)).await.unwrap().unwrap();
635
636        assert_eq!(id, again, "an ordinary write must not rotate the session");
637        assert_eq!(backend.len(), 1);
638    }
639
640    #[tokio::test]
641    async fn a_malformed_identifier_is_refused_without_a_lookup() {
642        let (store, _) = store();
643        for hostile in [
644            "",
645            "short",
646            "../../etc/passwd",
647            "churust:session:*",
648            "a b",
649            &"x".repeat(4096),
650        ] {
651            assert!(!is_well_formed(hostile), "{hostile:?} should not be valid");
652            assert!(store.load(hostile).await.is_none());
653        }
654    }
655
656    #[tokio::test]
657    async fn an_unknown_identifier_loads_nothing() {
658        let (store, _) = store();
659        assert!(store.load(&new_id()).await.is_none());
660    }
661
662    #[tokio::test]
663    async fn the_stored_value_does_not_repeat_the_identifier() {
664        let (store, backend) = store();
665        let id = store
666            .store(&data(&[("user", "ana")]), None)
667            .await
668            .unwrap()
669            .unwrap();
670        let raw = backend.live(&format!("{DEFAULT_PREFIX}{id}")).unwrap();
671        assert!(
672            !raw.contains(SESSION_ID_KEY),
673            "the key is the identifier; storing it twice invites disagreement: {raw}"
674        );
675    }
676
677    #[tokio::test]
678    async fn expiry_removes_a_session() {
679        let (mut store, _) = store();
680        store.ttl = 1;
681        let id = store
682            .store(&data(&[("user", "ana")]), None)
683            .await
684            .unwrap()
685            .unwrap();
686        assert!(store.load(&id).await.is_some());
687
688        tokio::time::sleep(Duration::from_millis(1100)).await;
689        assert!(
690            store.load(&id).await.is_none(),
691            "a session past its ttl must not load"
692        );
693    }
694
695    #[tokio::test]
696    async fn sliding_expiry_extends_on_read() {
697        let (mut store, backend) = store();
698        store.ttl = 2;
699        let id = store
700            .store(&data(&[("user", "ana")]), None)
701            .await
702            .unwrap()
703            .unwrap();
704
705        // Read across more than the original ttl, in steps shorter than it.
706        for _ in 0..3 {
707            tokio::time::sleep(Duration::from_millis(800)).await;
708            assert!(store.load(&id).await.is_some());
709        }
710        assert_eq!(backend.len(), 1);
711    }
712
713    #[tokio::test]
714    async fn absolute_expiry_does_not_extend_on_read() {
715        let (mut store, _) = store();
716        store.ttl = 1;
717        store.sliding = false;
718        let id = store
719            .store(&data(&[("user", "ana")]), None)
720            .await
721            .unwrap()
722            .unwrap();
723
724        tokio::time::sleep(Duration::from_millis(600)).await;
725        assert!(store.load(&id).await.is_some());
726        tokio::time::sleep(Duration::from_millis(600)).await;
727        assert!(
728            store.load(&id).await.is_none(),
729            "reading must not have extended the deadline"
730        );
731    }
732
733    #[tokio::test]
734    async fn a_custom_prefix_is_applied() {
735        let (mut store, backend) = store();
736        store.prefix = "app:sess:".into();
737        let id = store
738            .store(&data(&[("user", "ana")]), None)
739            .await
740            .unwrap()
741            .unwrap();
742        assert!(backend.live(&format!("app:sess:{id}")).is_some());
743    }
744
745    #[test]
746    #[should_panic(expected = "at least one second")]
747    fn a_zero_ttl_is_refused() {
748        let (store, _) = store();
749        let _ = store.ttl(0);
750    }
751}