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()
.expect("secure() 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().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();
assert!(result.is_err(), "secure() without encryption should fail");
let err = result.unwrap_err();
assert!(
matches!(err, CachekitError::Config(_)),
"expected Config error, got: {err:?}"
);
assert!(
err.to_string().contains("CACHEKIT_MASTER_KEY"),
"error should mention CACHEKIT_MASTER_KEY: {err}"
);
}
#[tokio::test]
async fn secure_get_missing_returns_none() {
let client = make_encrypted_client(MockBackend::shared());
let secure = client.secure().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().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().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()
.unwrap()
.set("shared-key", &"tenant-a-secret")
.await
.unwrap();
let result: Result<Option<String>, _> = client_b.secure().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().unwrap();
secure.set("hex-test", &42u64).await.unwrap();
let val: u64 = secure.get("hex-test").await.unwrap().unwrap();
assert_eq!(val, 42);
}
#[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().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().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"
);
}
#[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().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");
}