cachekit-rs 0.13.1

Caching for Rust (beta). Supports cachekit.io SaaS, Redis, Memcached, local File, and Cloudflare Workers.
Documentation
//! Shared test utilities for the cachekit integration tests.

#![allow(dead_code)]

use std::collections::HashMap;
use std::time::Duration;

use async_trait::async_trait;
use tokio::sync::Mutex;
use zeroize::Zeroizing;

use cachekit::backend::{Backend, HealthStatus};
use cachekit::client::SharedBackend;
use cachekit::error::BackendError;
use cachekit::{interop, serializer, CacheKit, CachekitError, SwrRead};
use serde::de::DeserializeOwned;
use serde::Deserialize;

/// In-memory mock backend backed by a `Mutex<HashMap>` for use in tests.
///
/// The internal store uses `Arc<Mutex>` so cloning the backend shares state,
/// regardless of whether the outer wrapper is `Arc` or `Rc`.
#[derive(Debug, Default, Clone)]
pub struct MockBackend {
    pub store: std::sync::Arc<Mutex<HashMap<String, Vec<u8>>>>,
}

impl MockBackend {
    /// Create a new mock and immediately wrap it as a [`SharedBackend`].
    ///
    /// Use [`MockBackend::new_with_handle`] when tests need to inspect the
    /// store directly (e.g., to verify ciphertext).
    pub fn shared() -> SharedBackend {
        let mock = Self::default();
        Self::into_shared(mock)
    }

    /// Create a new mock, returning both a [`SharedBackend`] and a clone
    /// for direct store inspection.
    pub fn new_with_handle() -> (SharedBackend, Self) {
        let mock = Self::default();
        let handle = mock.clone();
        (Self::into_shared(mock), handle)
    }

    fn into_shared(mock: Self) -> SharedBackend {
        #[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
        {
            std::sync::Arc::new(mock)
        }
        #[cfg(any(target_arch = "wasm32", feature = "unsync"))]
        {
            std::rc::Rc::new(mock)
        }
    }
}

#[cfg_attr(not(any(target_arch = "wasm32", feature = "unsync")), async_trait)]
#[cfg_attr(any(target_arch = "wasm32", feature = "unsync"), async_trait(?Send))]
impl Backend for MockBackend {
    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
        Ok(self.store.lock().await.get(key).cloned())
    }

    async fn set(
        &self,
        key: &str,
        value: Vec<u8>,
        _ttl: Option<Duration>,
    ) -> Result<(), BackendError> {
        self.store.lock().await.insert(key.to_owned(), value);
        Ok(())
    }

    async fn delete(&self, key: &str) -> Result<bool, BackendError> {
        Ok(self.store.lock().await.remove(key).is_some())
    }

    async fn exists(&self, key: &str) -> Result<bool, BackendError> {
        Ok(self.store.lock().await.contains_key(key))
    }

    async fn health(&self) -> Result<HealthStatus, BackendError> {
        Ok(HealthStatus {
            is_healthy: true,
            latency_ms: 0.0,
            backend_type: "mock".to_owned(),
            details: HashMap::new(),
        })
    }
}

/// One untrusted read: the path's name and what it decoded.
pub type Read<T> = (&'static str, Result<T, CachekitError>);

/// Both untrusted decode entry points: auto-mode `get` and interop `interop_get`.
pub fn decode_both<T: DeserializeOwned>(bytes: &[u8]) -> [Read<T>; 2] {
    [
        (
            "serializer::deserialize",
            serializer::deserialize::<T>(bytes),
        ),
        ("interop::deserialize", interop::deserialize::<T>(bytes)),
    ]
}

/// A current-thread runtime for driving a client from a sync test.
pub fn runtime() -> tokio::runtime::Runtime {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime")
}

/// Every interop read of `key`: `interop_get` and `interop_get_swr`, with the
/// SWR read flattened to the value or a miss. The client must have L1 off.
pub async fn interop_reads<T: DeserializeOwned>(
    client: &CacheKit,
    key: &str,
) -> [Read<Option<T>>; 2] {
    [
        ("CacheKit::interop_get", client.interop_get::<T>(key).await),
        (
            "CacheKit::interop_get_swr",
            client
                .interop_get_swr::<T>(key)
                .await
                .map(|read| match read {
                    SwrRead::Fresh(v) => Some(v),
                    SwrRead::Miss => None,
                    SwrRead::Stale(..) => panic!("with L1 off no read is stale"),
                }),
        ),
    ]
}

/// Every client read of `key`: `get`, then [`interop_reads`]. The client must
/// have L1 off.
pub async fn every_read<T: DeserializeOwned>(client: &CacheKit, key: &str) -> Vec<Read<Option<T>>> {
    let mut reads = vec![("CacheKit::get", client.get::<T>(key).await)];
    reads.extend(interop_reads::<T>(client, key).await);
    reads
}

/// The value inside the ByteStorage envelope of python-frame.json's default
/// write payload (`default_saas_write_msgpack_bytestorage_bin`), which
/// encryption.json's `container_envelope_to_plain_reader` also seals.
#[derive(Debug, Deserialize)]
pub struct EnvelopedValue {
    user_id: u64,
    name: String,
    active: bool,
}

/// Every read path of a client without encryption for `bytes`: both decoders
/// directly, then every client read of a backend entry holding exactly `bytes`.
/// This SDK writes plain MessagePack (no envelope), so the forged entry is the
/// input itself. L1 is off so each read reaches the backend and its decoder.
pub fn read_every_path<T: DeserializeOwned>(bytes: &[u8]) -> Vec<Read<T>> {
    const KEY: &str = "forged:entry";
    let runtime = runtime();
    let (backend, handle) = MockBackend::new_with_handle();
    let client = CacheKit::builder()
        .backend(backend)
        .no_l1()
        .build()
        .expect("client builds");
    let found = |r: Result<Option<T>, CachekitError>| {
        r.map(|v| v.expect("the forged entry must be found, not missed"))
    };
    let mut reads = Vec::from(decode_both::<T>(bytes));
    runtime.block_on(async {
        handle
            .store
            .lock()
            .await
            .insert(KEY.to_owned(), bytes.to_vec());
        for (path, result) in every_read::<T>(&client, KEY).await {
            reads.push((path, found(result)));
        }
    });
    reads
}

/// An encrypting client, L1 off so every read reaches the backend, holding
/// `bytes` at the backend key `stored_key`.
#[cfg(feature = "encryption")]
pub fn encrypting_client_holding(
    master_key_hex: &str,
    tenant_id: &str,
    namespace: Option<&str>,
    stored_key: &str,
    bytes: Vec<u8>,
) -> CacheKit {
    let (backend, handle) = MockBackend::new_with_handle();
    let mut builder = CacheKit::builder()
        .backend(backend)
        .no_l1()
        .encryption(master_key_hex, tenant_id)
        .expect("master key and tenant");
    if let Some(ns) = namespace {
        builder = builder.namespace(ns);
    }
    handle
        .store
        .try_lock()
        .expect("fresh store")
        .insert(stored_key.to_owned(), bytes);
    builder.build().expect("client builds")
}

/// The one error an AES-GCM tag mismatch reaches a reader as.
pub const AUTH_FAILURE: &str = "decrypt failed: Authentication verification failed";

/// The error a stored ciphertext shorter than a nonce and a tag (12 + 16
/// bytes) reaches a reader as: it is refused before AES-GCM runs.
pub const SHORT_CIPHERTEXT: &str =
    "decrypt failed: Invalid ciphertext format: Ciphertext too short";

/// RAII guard for `#[serial]` env tests: records each variable's pre-test
/// value and restores it on drop — including on assertion failure — so a
/// test can never destroy state the surrounding shell exported.
pub struct EnvGuard {
    /// `Zeroizing` because the saved set includes `CACHEKIT_MASTER_KEY` and
    /// `CACHEKIT_PREVIOUS_MASTER_KEYS` — a pre-test shell value is real key
    /// material, so the copy this guard holds is wiped on drop.
    saved: Vec<(&'static str, Option<Zeroizing<String>>)>,
}

impl EnvGuard {
    /// Apply `(name, value)` pairs: `Some` sets the variable, `None` removes
    /// it. The prior value of every named variable is restored on drop.
    ///
    /// Panics — before touching any variable — if one holds a non-UTF-8
    /// value: it could not be restored as a `String`, and treating it as
    /// absent would make drop delete it.
    pub fn set(vars: &[(&'static str, Option<&str>)]) -> Self {
        let saved = vars
            .iter()
            .map(|(name, _)| match std::env::var(name) {
                Ok(v) => (*name, Some(Zeroizing::new(v))),
                Err(std::env::VarError::NotPresent) => (*name, None),
                Err(std::env::VarError::NotUnicode(_)) => {
                    panic!("EnvGuard: {name} holds a non-UTF-8 value; refusing to clobber it")
                }
            })
            .collect();
        for (name, value) in vars {
            match value {
                Some(v) => std::env::set_var(name, v),
                None => std::env::remove_var(name),
            }
        }
        Self { saved }
    }
}

impl Drop for EnvGuard {
    fn drop(&mut self) {
        for (name, value) in &self.saved {
            match value {
                Some(v) => std::env::set_var(name, v.as_str()),
                None => std::env::remove_var(name),
            }
        }
    }
}

/// Backend whose every data operation fails with a transient error — the
/// "backend is down" fixture for breaker and not-counted-read tests.
#[derive(Debug, Default, Clone)]
pub struct FailingBackend;

impl FailingBackend {
    /// Wrap as a [`SharedBackend`].
    pub fn shared() -> SharedBackend {
        #[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
        {
            std::sync::Arc::new(Self)
        }
        #[cfg(any(target_arch = "wasm32", feature = "unsync"))]
        {
            std::rc::Rc::new(Self)
        }
    }
}

#[cfg_attr(not(any(target_arch = "wasm32", feature = "unsync")), async_trait)]
#[cfg_attr(any(target_arch = "wasm32", feature = "unsync"), async_trait(?Send))]
impl Backend for FailingBackend {
    async fn get(&self, _key: &str) -> Result<Option<Vec<u8>>, BackendError> {
        Err(BackendError::transient("down"))
    }

    async fn set(&self, _: &str, _: Vec<u8>, _: Option<Duration>) -> Result<(), BackendError> {
        Err(BackendError::transient("down"))
    }

    async fn delete(&self, _key: &str) -> Result<bool, BackendError> {
        Err(BackendError::transient("down"))
    }

    async fn exists(&self, _key: &str) -> Result<bool, BackendError> {
        Err(BackendError::transient("down"))
    }

    async fn health(&self) -> Result<HealthStatus, BackendError> {
        Ok(HealthStatus {
            is_healthy: false,
            latency_ms: 0.0,
            backend_type: "failing".to_owned(),
            details: HashMap::new(),
        })
    }
}