SurrealDBBackend was backed by Arc<RwLock<HashMap>> — no connection to
SurrealDB whatsoever. Rewrite to a real Surreal<Any> connection:
- engine::any dispatch: mem:// (embedded, tests) and ws://wss:// (prod)
- All 11 StorageBackend methods: SurrealQL upsert/select/delete/query
- Vec<u8> fields base64-encoded; timestamps as RFC3339 UTC strings
- MVCC write-conflict retry: exponential backoff 5ms→80ms + uniform
jitter, 5 attempts — resolves SurrealDB optimistic-concurrency errors
under concurrent load without external locking
- Mirror ID fields in records to avoid RecordId enum parsing in lists
- 9 unit tests (mem://, no server) + 19 integration tests with UUID
database isolation; concurrent coverage: 16 secret + 8 key writers
535 lines
16 KiB
Rust
535 lines
16 KiB
Rust
//! 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);
|
|
}
|