mod common;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::common::MockBackend;
use cachekit::client::SharedBackend;
use cachekit::{CacheKit, CachekitError};
const TEST_MASTER_KEY: &[u8] = b"test_master_key_32_bytes_long!!!";
fn test_master_key_hex() -> String {
hex::encode(TEST_MASTER_KEY)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Secret {
api_key: String,
user_id: u64,
}
fn make_encrypted_client(backend: SharedBackend) -> CacheKit {
CacheKit::builder()
.backend(backend)
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes(TEST_MASTER_KEY, "test-tenant")
.expect("encryption setup")
.build()
.expect("client builds")
}
fn make_encrypted_client_with_l1(backend: SharedBackend) -> CacheKit {
CacheKit::builder()
.backend(backend)
.default_ttl(Duration::from_secs(60))
.l1_capacity(100)
.encryption_from_bytes(TEST_MASTER_KEY, "test-tenant")
.expect("encryption setup")
.build()
.expect("client builds")
}
#[tokio::test]
async fn secure_set_and_get() {
let backend = MockBackend::shared();
let client = make_encrypted_client(backend);
let secret = Secret {
api_key: "sk-live-abc123".to_owned(), user_id: 42,
};
let secure = client
.secure_cache()
.expect("secure_cache() should work with encryption configured");
secure.set("secret:42", &secret).await.expect("secure set");
let retrieved: Secret = secure
.get("secret:42")
.await
.expect("secure get")
.expect("value should exist");
assert_eq!(retrieved, secret);
}
#[tokio::test]
async fn secure_data_is_encrypted_in_backend() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client(shared);
let secret = Secret {
api_key: "sk-live-SUPERSECRET".to_owned(), user_id: 999,
};
let secure = client.secure_cache().unwrap();
secure.set("secret:999", &secret).await.unwrap();
let raw_bytes = backend
.store
.lock()
.await
.get("secret:999")
.cloned()
.expect("key should exist in backend");
let raw_str = String::from_utf8_lossy(&raw_bytes);
assert!(
!raw_str.contains("SUPERSECRET"),
"backend must store ciphertext, not plaintext; got: {raw_str}"
);
assert!(
raw_bytes.len() >= 28,
"ciphertext too short: {} bytes (expected nonce + tag overhead)",
raw_bytes.len()
);
}
#[tokio::test]
async fn secure_without_master_key_fails() {
let client = CacheKit::builder()
.backend(MockBackend::shared())
.no_l1()
.build()
.expect("client builds without encryption");
let result = client.secure_cache();
assert!(
result.is_err(),
"secure_cache() without encryption should fail"
);
let err = result.unwrap_err();
assert!(
matches!(err, CachekitError::Config(_)),
"expected Config error, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("CacheKit::secure(url, master_key_hex)")
&& msg.contains("CacheKit::secure_from_env(url)")
&& msg.contains("CACHEKIT_MASTER_KEY"),
"error should name both secure preset forms and CACHEKIT_MASTER_KEY: {msg}"
);
}
#[tokio::test]
async fn secure_get_missing_returns_none() {
let client = make_encrypted_client(MockBackend::shared());
let secure = client.secure_cache().unwrap();
let result: Option<String> = secure.get("nonexistent").await.expect("get should succeed");
assert!(result.is_none());
}
#[tokio::test]
async fn secure_delete() {
let client = make_encrypted_client(MockBackend::shared());
let secure = client.secure_cache().unwrap();
secure.set("to-delete", &"temporary").await.unwrap();
assert!(secure.exists("to-delete").await.unwrap());
let deleted = secure.delete("to-delete").await.unwrap();
assert!(deleted);
let gone: Option<String> = secure.get("to-delete").await.unwrap();
assert!(gone.is_none());
}
#[tokio::test]
async fn secure_wrong_key_fails_decryption() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client(shared);
let secure = client.secure_cache().unwrap();
secure.set("key-a", &"secret data").await.unwrap();
let stored = backend.store.lock().await.get("key-a").cloned().unwrap();
backend
.store
.lock()
.await
.insert("key-b".to_owned(), stored);
let result: Result<Option<String>, _> = secure.get("key-b").await;
assert!(
result.is_err(),
"decryption with wrong cache key AAD must fail"
);
}
#[tokio::test]
async fn secure_different_tenants_cant_decrypt() {
let (shared_a, _backend) = MockBackend::new_with_handle();
let shared_b = shared_a.clone();
let client_a = CacheKit::builder()
.backend(shared_a)
.no_l1()
.encryption_from_bytes(TEST_MASTER_KEY, "tenant-a")
.unwrap()
.build()
.unwrap();
let client_b = CacheKit::builder()
.backend(shared_b)
.no_l1()
.encryption_from_bytes(TEST_MASTER_KEY, "tenant-b")
.unwrap()
.build()
.unwrap();
client_a
.secure_cache()
.unwrap()
.set("shared-key", &"tenant-a-secret")
.await
.unwrap();
let result: Result<Option<String>, _> =
client_b.secure_cache().unwrap().get("shared-key").await;
assert!(
result.is_err(),
"cross-tenant decryption must fail (different derived keys)"
);
}
#[tokio::test]
async fn secure_hex_builder() {
let client = CacheKit::builder()
.backend(MockBackend::shared())
.no_l1()
.encryption(&test_master_key_hex(), "hex-tenant")
.expect("hex encryption setup")
.build()
.unwrap();
let secure = client.secure_cache().unwrap();
secure.set("hex-test", &42u64).await.unwrap();
let val: u64 = secure.get("hex-test").await.unwrap().unwrap();
assert_eq!(val, 42);
}
#[test]
fn hex_builder_error_does_not_quote_the_key() {
let key = format!("{}Q9", "a1".repeat(31));
let err = CacheKit::builder()
.backend(MockBackend::shared())
.encryption(&key, "hex-tenant")
.err()
.expect("non-hex key must be rejected");
assert!(matches!(err, CachekitError::Config(_)), "got {err:?}");
assert!(
!err.to_string().contains('Q'),
"error quotes key material: {err}"
);
}
#[tokio::test]
async fn secure_with_l1_roundtrip() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client_with_l1(shared);
let secure = client.secure_cache().unwrap();
secure.set("l1-test", &"encrypted in L1").await.unwrap();
let val: String = secure.get("l1-test").await.unwrap().unwrap();
assert_eq!(val, "encrypted in L1");
backend.store.lock().await.remove("l1-test");
let val2: String = secure.get("l1-test").await.unwrap().unwrap();
assert_eq!(val2, "encrypted in L1");
}
#[tokio::test]
async fn secure_l1_stores_ciphertext_not_plaintext() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client_with_l1(shared);
let secure = client.secure_cache().unwrap();
secure.set("l1-cipher", &"PLAINTEXT_VALUE").await.unwrap();
let store = backend.store.lock().await;
let (_key, raw_bytes) = store.iter().next().expect("backend should have one entry");
let plaintext_msgpack = rmp_serde::to_vec_named(&"PLAINTEXT_VALUE").unwrap();
assert_ne!(
raw_bytes, &plaintext_msgpack,
"backend should store ciphertext, not plaintext msgpack"
);
assert!(
raw_bytes.len() > plaintext_msgpack.len(),
"ciphertext should be larger than plaintext due to AAD + GCM tag"
);
}
async fn plant_tampered(shared: SharedBackend, backend: &MockBackend, key: &str) -> Vec<u8> {
let writer = make_encrypted_client(shared);
writer
.secure_cache()
.unwrap()
.set(key, &"authentic")
.await
.unwrap();
let mut store = backend.store.lock().await;
let bytes = store.get_mut(key).expect("writer stored the entry");
*bytes.last_mut().unwrap() ^= 0x01;
bytes.clone()
}
#[tokio::test]
async fn secure_get_evicts_l1_after_decrypt_failure() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client_with_l1(shared.clone());
let secure = client.secure_cache().unwrap();
let planted = plant_tampered(shared, &backend, "poisoned").await;
let err = secure.get::<String>("poisoned").await.unwrap_err();
assert!(matches!(err, CachekitError::Encryption(_)), "got: {err:?}");
assert_eq!(backend.store.lock().await.get("poisoned"), Some(&planted));
backend.store.lock().await.remove("poisoned");
assert_eq!(secure.get::<String>("poisoned").await.unwrap(), None);
}
#[tokio::test]
async fn secure_interop_get_swr_evicts_l1_after_decrypt_failure() {
let (shared, backend) = MockBackend::new_with_handle();
let client = make_encrypted_client_with_l1(shared.clone());
let secure = client.secure_cache().unwrap();
let planted = plant_tampered(shared, &backend, "poisoned-swr").await;
let err = secure
.interop_get_swr::<String>("poisoned-swr")
.await
.unwrap_err();
assert!(matches!(err, CachekitError::Encryption(_)), "got: {err:?}");
assert_eq!(
backend.store.lock().await.get("poisoned-swr"),
Some(&planted)
);
backend.store.lock().await.remove("poisoned-swr");
assert!(matches!(
secure.interop_get_swr::<String>("poisoned-swr").await,
Ok(cachekit::SwrRead::Miss)
));
}
#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
#[tokio::test]
async fn secure_interop_get_swr_evicts_stale_l1_after_decrypt_failure() {
let (shared, backend) = MockBackend::new_with_handle();
let client = CacheKit::builder()
.backend(shared)
.l1_capacity(100)
.swr_threshold_ratio(0.001)
.encryption_from_bytes(TEST_MASTER_KEY, "test-tenant")
.expect("encryption setup")
.build()
.expect("client builds");
client
.set_with_ttl("poisoned-stale", &"plaintext", Duration::from_secs(60))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
let secure = client.secure_cache().unwrap();
let err = secure
.interop_get_swr::<String>("poisoned-stale")
.await
.unwrap_err();
assert!(matches!(err, CachekitError::Encryption(_)), "got: {err:?}");
let stats = client.stats();
assert_eq!((stats.l1_hits, stats.l2_hits), (1, 0), "{stats:?}");
backend.store.lock().await.remove("poisoned-stale");
assert!(matches!(
secure.interop_get_swr::<String>("poisoned-stale").await,
Ok(cachekit::SwrRead::Miss)
));
}
#[tokio::test]
async fn secure_with_namespace() {
let (shared, backend) = MockBackend::new_with_handle();
let client = CacheKit::builder()
.backend(shared)
.namespace("ns")
.no_l1()
.encryption_from_bytes(TEST_MASTER_KEY, "test-tenant")
.unwrap()
.build()
.unwrap();
let secure = client.secure_cache().unwrap();
secure.set("namespaced", &"value").await.unwrap();
let keys: Vec<String> = backend.store.lock().await.keys().cloned().collect();
assert!(
keys.contains(&"ns:namespaced".to_owned()),
"expected namespaced key, got: {keys:?}"
);
let val: String = secure.get("namespaced").await.unwrap().unwrap();
assert_eq!(val, "value");
}
#[tokio::test]
async fn secure_set_rejects_payload_whose_ciphertext_exceeds_limit() {
let client = CacheKit::builder()
.backend(MockBackend::shared())
.no_l1()
.max_payload_bytes(64)
.encryption_from_bytes(TEST_MASTER_KEY, "test-tenant")
.expect("encryption setup")
.build()
.expect("client builds");
let secure = client.secure_cache().expect("secure handle");
let value = "x".repeat(48); let err = secure
.set("boundary:key", &value)
.await
.expect_err("ciphertext over limit must fail at set time");
assert!(
matches!(err, CachekitError::PayloadTooLarge { .. }),
"expected PayloadTooLarge, got: {err:?}"
);
secure.set("small:key", &"ok").await.expect("small set");
let got: Option<String> = secure.get("small:key").await.expect("small get");
assert_eq!(got.as_deref(), Some("ok"));
}
#[tokio::test]
async fn rotation_round_trip_without_reencryption() {
const K1: &[u8] = &[0x11; 32];
const K2: &[u8] = &[0x22; 32];
let (backend, store) = common::MockBackend::new_with_handle();
let writer = CacheKit::builder()
.backend(backend.clone())
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes(K1, "test-tenant")
.expect("encryption setup")
.build()
.expect("client builds");
let secret = Secret {
api_key: "sk-live-rotate-me".to_owned(), user_id: 7,
};
writer
.secure_cache()
.expect("secure_cache()")
.set("secret:7", &secret)
.await
.expect("secure set under k1");
let ciphertext_before = store.store.lock().await.get("secret:7").cloned().unwrap();
let rotated = CacheKit::builder()
.backend(backend.clone())
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes_with_previous(K2, &[K1], "test-tenant")
.expect("keyring setup")
.build()
.expect("client builds");
let read_back: Secret = rotated
.secure_cache()
.expect("secure_cache()")
.get("secret:7")
.await
.expect("secure get after rotation")
.expect("value should exist");
assert_eq!(read_back, secret);
let ciphertext_after = store.store.lock().await.get("secret:7").cloned().unwrap();
assert_eq!(
ciphertext_before, ciphertext_after,
"read-through rotation must not rewrite the entry"
);
let cut_over = CacheKit::builder()
.backend(backend)
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes_with_previous(K2, &[], "test-tenant")
.expect("keyring setup")
.build()
.expect("client builds");
let result: Result<Option<Secret>, _> = cut_over
.secure_cache()
.expect("secure_cache()")
.get("secret:7")
.await;
assert!(
matches!(result, Err(CachekitError::Encryption(_))),
"dropped-key read must surface as an encryption error, got {result:?}"
);
}
#[tokio::test]
async fn rotation_drain_signal_is_visible_on_secure_cache() {
const K1: &[u8] = &[0x11; 32];
const K2: &[u8] = &[0x22; 32];
let backend = common::MockBackend::shared();
let writer = CacheKit::builder()
.backend(backend.clone())
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes(K1, "test-tenant")
.expect("encryption setup")
.build()
.expect("client builds");
writer
.secure_cache()
.expect("secure_cache()")
.set("drain:old", &"written under k1")
.await
.expect("secure set under k1");
let rotated = CacheKit::builder()
.backend(backend)
.default_ttl(Duration::from_secs(60))
.no_l1()
.encryption_from_bytes_with_previous(K2, &[K1], "test-tenant")
.expect("keyring setup")
.build()
.expect("client builds");
let secure = rotated.secure_cache().expect("secure_cache()");
assert_eq!(secure.previous_key_hits(), vec![0], "nothing read yet");
let _: Option<String> = secure.get("drain:old").await.expect("secure get");
assert_eq!(
secure.previous_key_hits(),
vec![1],
"previous-key hit is counted"
);
}
fn assert_builder_config_err(result: Result<cachekit::CacheKitBuilder, CachekitError>, what: &str) {
match result {
Err(CachekitError::Config(_)) => {}
Err(e) => panic!("{what}: expected Config error, got {e:?}"),
Ok(_) => panic!("{what}: expected Config error, got Ok"),
}
}
#[test]
fn encryption_from_bytes_requires_exactly_32_bytes() {
for len in [31, 33] {
assert_builder_config_err(
CacheKit::builder().encryption_from_bytes(&vec![7u8; len], "t"),
&format!("{len}-byte key"),
);
}
assert!(CacheKit::builder()
.encryption_from_bytes(&[7u8; 32], "t")
.is_ok());
}
#[test]
fn encryption_from_bytes_rejects_ascii_hex() {
let hex = "ab".repeat(32);
assert_builder_config_err(
CacheKit::builder().encryption_from_bytes(hex.as_bytes(), "t"),
"ASCII hex as raw bytes",
);
}
#[test]
fn encryption_from_bytes_with_previous_requires_exactly_32_bytes() {
const K1: &[u8] = &[0x11; 32];
const K2: &[u8] = &[0x22; 32];
for len in [31, 33] {
let bad = vec![7u8; len];
assert_builder_config_err(
CacheKit::builder().encryption_from_bytes_with_previous(&bad, &[K1], "t"),
&format!("{len}-byte current key"),
);
assert_builder_config_err(
CacheKit::builder().encryption_from_bytes_with_previous(K2, &[&bad], "t"),
&format!("{len}-byte previous key"),
);
}
assert!(CacheKit::builder()
.encryption_from_bytes_with_previous(K2, &[K1], "t")
.is_ok());
}
#[test]
fn encryption_hex_accepts_32_and_48_byte_keys() {
for bytes in [32, 48] {
assert!(
CacheKit::builder()
.encryption(&"ab".repeat(bytes), "t")
.is_ok(),
"{bytes}-byte hex key must be accepted"
);
}
}
#[cfg(feature = "cachekitio")]
#[test]
#[serial_test::serial]
fn from_env_accepts_32_and_48_byte_hex_keys() {
for bytes in [32, 48] {
let (current, previous) = ("22".repeat(bytes), "11".repeat(bytes));
let _env = common::EnvGuard::set(&[
("CACHEKIT_API_KEY", Some("test-key")),
("CACHEKIT_API_URL", None),
("CACHEKIT_MASTER_KEY", Some(current.as_str())),
("CACHEKIT_PREVIOUS_MASTER_KEYS", Some(previous.as_str())),
]);
let builder = CacheKit::from_env()
.unwrap_or_else(|e| panic!("{bytes}-byte hex keys must be accepted: {e}"));
assert!(builder.build().unwrap().secure_cache().is_ok());
}
}