secretumvault/tests/surrealdb_integration.rs

535 lines
16 KiB
Rust
Raw Normal View History

//! Integration tests for the SurrealDB storage backend against a real instance.
//!
//! Requires a running SurrealDB server. Start one with:
//!
//! ```bash
//! docker run --rm -p 8000:8000 surrealdb/surrealdb:latest \
//! start --user root --pass root
//! ```
//!
//! Environment variables (all optional):
//! - `SURREALDB_URL` — defaults to `ws://localhost:8000`
//! - `SURREALDB_USER` — defaults to `root`
//! - `SURREALDB_PASS` — defaults to `root`
//!
//! Each test creates an isolated UUID-named database; leftovers accumulate on
//! the server and can be cleared by restarting the container.
//!
//! Run:
//! ```bash
//! cargo test --features surrealdb-storage --test surrealdb_integration \
//! -- --include-ignored
//! ```
#![cfg(all(test, feature = "surrealdb-storage"))]
use std::collections::HashMap;
use std::sync::Arc;
use chrono::{Duration, Utc};
use secretumvault::config::SurrealDBStorageConfig;
use secretumvault::error::StorageError;
use secretumvault::storage::surrealdb::SurrealDBBackend;
use secretumvault::storage::{EncryptedData, Lease, StorageBackend, StoredKey, StoredPolicy};
use uuid::Uuid;
// ── Helpers ────────────────────────────────────────────────────────────────
fn surrealdb_url() -> String {
std::env::var("SURREALDB_URL").unwrap_or_else(|_| "ws://localhost:8000".to_string())
}
fn surrealdb_user() -> String {
std::env::var("SURREALDB_USER").unwrap_or_else(|_| "root".to_string())
}
fn surrealdb_pass() -> String {
std::env::var("SURREALDB_PASS").unwrap_or_else(|_| "root".to_string())
}
/// Config with a UUID-named database for full test isolation.
fn isolated_config() -> SurrealDBStorageConfig {
SurrealDBStorageConfig {
url: surrealdb_url(),
namespace: Some("secretumvault_test".to_string()),
database: Some(format!("t{}", Uuid::new_v4().simple())),
username: Some(surrealdb_user()),
password: Some(surrealdb_pass()),
endpoint: None,
}
}
async fn connect() -> SurrealDBBackend {
SurrealDBBackend::new(&isolated_config()).await.expect(
"connect failed — is SurrealDB running? (docker run --rm -p 8000:8000 \
surrealdb/surrealdb:latest start --user root --pass root)",
)
}
fn sample_secret() -> EncryptedData {
EncryptedData {
ciphertext: (0u8..32).collect(),
nonce: (0u8..12).collect(),
algorithm: "AES-256-GCM".to_string(),
}
}
fn sample_key(id: &str) -> StoredKey {
let now = Utc::now();
StoredKey {
id: id.to_string(),
name: id.to_string(),
version: 1,
algorithm: "AES-256-GCM".to_string(),
key_data: vec![0xAB; 32],
public_key: None,
created_at: now,
updated_at: now,
}
}
fn sample_policy(name: &str) -> StoredPolicy {
let now = Utc::now();
StoredPolicy {
name: name.to_string(),
content: "permit(principal, action, resource);".to_string(),
created_at: now,
updated_at: now,
}
}
// ── Connection & auth ──────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_real_connection_and_health_check() {
let backend = connect().await;
backend.health_check().await.expect("health_check failed");
}
#[tokio::test]
#[ignore]
async fn test_wrong_url_returns_error() {
let config = SurrealDBStorageConfig {
url: "ws://127.0.0.1:19999".to_string(),
namespace: Some("v".to_string()),
database: Some("t".to_string()),
username: None,
password: None,
endpoint: None,
};
let result = SurrealDBBackend::new(&config).await;
assert!(result.is_err(), "connecting to a dead port must return Err");
}
#[tokio::test]
#[ignore]
async fn test_wrong_credentials_return_error() {
let config = SurrealDBStorageConfig {
url: surrealdb_url(),
namespace: Some("v".to_string()),
database: Some("t".to_string()),
username: Some("nobody".to_string()),
password: Some("wrongpass".to_string()),
endpoint: None,
};
let result = SurrealDBBackend::new(&config).await;
assert!(result.is_err(), "bad credentials must return Err");
}
// ── Secret CRUD ────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_secret_store_and_retrieve() {
let backend = connect().await;
let secret = sample_secret();
backend
.store_secret("kv/prod/db", &secret)
.await
.expect("store failed");
let got = backend.get_secret("kv/prod/db").await.expect("get failed");
assert_eq!(got.ciphertext, secret.ciphertext);
assert_eq!(got.nonce, secret.nonce);
assert_eq!(got.algorithm, secret.algorithm);
}
#[tokio::test]
#[ignore]
async fn test_secret_not_found() {
let backend = connect().await;
match backend.get_secret("no/such/path").await {
Err(StorageError::NotFound(_)) => {}
other => panic!("expected NotFound, got {other:?}"),
}
}
#[tokio::test]
#[ignore]
async fn test_secret_upsert_overwrites() {
let backend = connect().await;
let v1 = EncryptedData {
ciphertext: vec![1, 1, 1],
nonce: vec![0; 12],
algorithm: "AES-256-GCM".to_string(),
};
let v2 = EncryptedData {
ciphertext: vec![2, 2, 2],
nonce: vec![0; 12],
algorithm: "ChaCha20-Poly1305".to_string(),
};
backend.store_secret("kv/overwrite", &v1).await.unwrap();
backend.store_secret("kv/overwrite", &v2).await.unwrap();
let got = backend.get_secret("kv/overwrite").await.unwrap();
assert_eq!(got.ciphertext, v2.ciphertext);
assert_eq!(got.algorithm, v2.algorithm);
}
#[tokio::test]
#[ignore]
async fn test_secret_delete() {
let backend = connect().await;
backend
.store_secret("kv/to-delete", &sample_secret())
.await
.unwrap();
backend.delete_secret("kv/to-delete").await.unwrap();
assert!(backend.get_secret("kv/to-delete").await.is_err());
}
#[tokio::test]
#[ignore]
async fn test_list_secrets_prefix_isolation() {
let backend = connect().await;
let s = sample_secret();
for path in ["app/prod/db", "app/prod/api-key", "app/dev/db", "other/x"] {
backend.store_secret(path, &s).await.unwrap();
}
let mut prod = backend.list_secrets("app/prod/").await.unwrap();
prod.sort();
assert_eq!(prod, vec!["app/prod/api-key", "app/prod/db"]);
let all_app = backend.list_secrets("app/").await.unwrap();
assert_eq!(all_app.len(), 3);
let none = backend.list_secrets("missing/").await.unwrap();
assert!(none.is_empty());
}
// ── Persistence across reconnect ───────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_persistence_across_reconnect() {
let config = isolated_config();
let secret = sample_secret();
{
let b = SurrealDBBackend::new(&config).await.unwrap();
b.store_secret("persistent/key", &secret).await.unwrap();
}
{
let b = SurrealDBBackend::new(&config).await.unwrap();
let got = b
.get_secret("persistent/key")
.await
.expect("secret must survive reconnect");
assert_eq!(got.ciphertext, secret.ciphertext);
assert_eq!(got.nonce, secret.nonce);
}
}
// ── Key CRUD ───────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_key_roundtrip_pqc_sizes() {
let backend = connect().await;
let now = Utc::now();
// ML-KEM-768: public 1184 B, private 2400 B.
let key = StoredKey {
id: "mlkem768".to_string(),
name: "root-kek".to_string(),
version: 1,
algorithm: "ML-KEM-768".to_string(),
key_data: vec![0xAB; 2400],
public_key: Some(vec![0xCD; 1184]),
created_at: now,
updated_at: now,
};
backend.store_key(&key).await.unwrap();
let got = backend.get_key("mlkem768").await.unwrap();
assert_eq!(got.key_data.len(), 2400);
assert_eq!(got.public_key.as_ref().map(|v| v.len()), Some(1184));
assert_eq!(got.key_data, key.key_data);
assert_eq!(got.public_key, key.public_key);
}
#[tokio::test]
#[ignore]
async fn test_key_version_increment() {
let backend = connect().await;
let now = Utc::now();
let mut key = StoredKey {
id: "transit-key".to_string(),
name: "transit-key".to_string(),
version: 1,
algorithm: "AES-256-GCM".to_string(),
key_data: vec![0u8; 32],
public_key: None,
created_at: now,
updated_at: now,
};
backend.store_key(&key).await.unwrap();
key.version = 2;
key.key_data = vec![0xFF; 32];
backend.store_key(&key).await.unwrap();
let got = backend.get_key("transit-key").await.unwrap();
assert_eq!(got.version, 2);
assert_eq!(got.key_data, vec![0xFF; 32]);
}
#[tokio::test]
#[ignore]
async fn test_list_keys() {
let backend = connect().await;
for id in ["k-a", "k-b", "k-c"] {
backend.store_key(&sample_key(id)).await.unwrap();
}
let mut keys = backend.list_keys().await.unwrap();
keys.sort();
assert_eq!(keys, vec!["k-a", "k-b", "k-c"]);
}
// ── Policy CRUD ────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_policy_roundtrip() {
let backend = connect().await;
let now = Utc::now();
let policy = StoredPolicy {
name: "admin".to_string(),
content: r#"permit(
principal in SecretumVault::Group::"admins",
action,
resource
);"#
.to_string(),
created_at: now,
updated_at: now,
};
backend.store_policy("admin", &policy).await.unwrap();
let got = backend.get_policy("admin").await.unwrap();
assert_eq!(got.name, policy.name);
assert_eq!(got.content, policy.content);
}
#[tokio::test]
#[ignore]
async fn test_policy_not_found() {
let backend = connect().await;
match backend.get_policy("no-such-policy").await {
Err(StorageError::NotFound(_)) => {}
other => panic!("expected NotFound, got {other:?}"),
}
}
#[tokio::test]
#[ignore]
async fn test_list_policies() {
let backend = connect().await;
for name in ["pol-a", "pol-b", "pol-c"] {
backend
.store_policy(name, &sample_policy(name))
.await
.unwrap();
}
let mut names = backend.list_policies().await.unwrap();
names.sort();
assert_eq!(names, vec!["pol-a", "pol-b", "pol-c"]);
}
// ── Lease CRUD ─────────────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_lease_full_lifecycle() {
let backend = connect().await;
let now = Utc::now();
let lease = Lease {
id: format!("lease-{}", Uuid::new_v4().simple()),
secret_id: "kv/prod/db".to_string(),
issued_at: now,
expires_at: now + Duration::hours(1),
data: HashMap::from([
("username".to_string(), "alice".to_string()),
("role".to_string(), "read-only".to_string()),
]),
};
let lease_id = lease.id.clone();
backend.store_lease(&lease).await.unwrap();
let got = backend.get_lease(&lease_id).await.unwrap();
assert_eq!(got.secret_id, lease.secret_id);
assert_eq!(got.data["username"], "alice");
// Appears in +2h window.
let expiring = backend
.list_expiring_leases(now + Duration::hours(2))
.await
.unwrap();
assert!(expiring.iter().any(|l| l.id == lease_id));
// Does NOT appear before its expiry.
let early = backend
.list_expiring_leases(now - Duration::seconds(1))
.await
.unwrap();
assert!(!early.iter().any(|l| l.id == lease_id));
backend.delete_lease(&lease_id).await.unwrap();
assert!(backend.get_lease(&lease_id).await.is_err());
}
#[tokio::test]
#[ignore]
async fn test_list_expiring_leases_window() {
let backend = connect().await;
let now = Utc::now();
let windows = [
(Duration::hours(1), "1h"),
(Duration::hours(3), "3h"),
(Duration::hours(6), "6h"),
];
let mut ids = Vec::new();
for (offset, label) in windows {
let id = format!("lease-win-{}-{}", label, Uuid::new_v4().simple());
backend
.store_lease(&Lease {
id: id.clone(),
secret_id: "s".to_string(),
issued_at: now,
expires_at: now + offset,
data: HashMap::new(),
})
.await
.unwrap();
ids.push((id, offset));
}
// Cutoff at +4h: 1h and 3h leases must appear, 6h must not.
let expiring = backend
.list_expiring_leases(now + Duration::hours(4))
.await
.unwrap();
assert!(expiring.iter().any(|l| l.id == ids[0].0), "1h missing");
assert!(expiring.iter().any(|l| l.id == ids[1].0), "3h missing");
assert!(
!expiring.iter().any(|l| l.id == ids[2].0),
"6h must not appear"
);
}
// ── Concurrent writes ──────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_concurrent_secret_writes() {
let backend = Arc::new(connect().await);
let mut handles = Vec::new();
for i in 0u8..16 {
let b = Arc::clone(&backend);
handles.push(tokio::spawn(async move {
let s = EncryptedData {
ciphertext: vec![i; 32],
nonce: vec![0u8; 12],
algorithm: "AES-256-GCM".to_string(),
};
b.store_secret(&format!("concurrent/{i}"), &s).await
}));
}
for h in handles {
h.await.expect("task panicked").expect("store failed");
}
for i in 0u8..16 {
let got = backend
.get_secret(&format!("concurrent/{i}"))
.await
.unwrap();
assert_eq!(got.ciphertext, vec![i; 32]);
}
}
#[tokio::test]
#[ignore]
async fn test_concurrent_key_writes() {
let backend = Arc::new(connect().await);
let now = Utc::now();
let mut handles = Vec::new();
for i in 0u8..8 {
let b = Arc::clone(&backend);
handles.push(tokio::spawn(async move {
b.store_key(&StoredKey {
id: format!("ck-{i}"),
name: format!("key-{i}"),
version: 1,
algorithm: "ML-KEM-768".to_string(),
key_data: vec![i; 2400],
public_key: Some(vec![i; 1184]),
created_at: now,
updated_at: now,
})
.await
}));
}
for h in handles {
h.await.expect("task panicked").expect("store_key failed");
}
let mut keys = backend.list_keys().await.unwrap();
keys.sort();
let expected: Vec<String> = (0u8..8).map(|i| format!("ck-{i}")).collect();
assert_eq!(keys, expected);
}