//! 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 = (0u8..8).map(|i| format!("ck-{i}")).collect(); assert_eq!(keys, expected); }