cli: avoid data-dir initialization for version; create db parent dirs; redact generated passwords in CLI output

- Prevent 'nx9-wg version' from creating data directories by avoiding database initialization.
- Create parent directories when an explicit --database path is provided.
- Redact printed generated administrator passwords; announce file path or redact instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
thakaresandCopilot committed 2026-08-16 16:26:24 +05:30
commit 2ac6c81dfe
140 files changed
+31342

No files matched your search

@@ -0,0 +1,80 @@
//! Tests for Administrator repository operations and security invariants.
use nx9_wg_core::crypto::{hash_password, verify_password};
use nx9_wg_db::Store;
#[tokio::test]
async fn test_admin_single_identity_and_crud() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
// Initially no admin exists
assert!(!store.admin_exists().await.expect("admin_exists"));
assert!(store.get_admin().await.expect("get_admin").is_none());
// Create single admin with Argon2id hash
let password = "CorrectHorseBatteryStaple123!";
let password_hash = hash_password(password).expect("hash password");
let admin = store
.create_admin("admin", &password_hash)
.await
.expect("create_admin");
assert_eq!(admin.id, 1);
assert_eq!(admin.username, "admin");
assert!(!admin.totp_enabled);
assert!(admin.last_login_at.is_none());
// Verify admin_exists returns true
assert!(store.admin_exists().await.expect("admin_exists"));
// Verify lookup by username
let fetched = store
.get_admin_by_username("admin")
.await
.expect("get_admin_by_username")
.expect("admin found");
assert_eq!(fetched.id, 1);
assert!(verify_password(password, &fetched.password_hash).expect("verify password"));
// Reject second admin creation
let second_res = store.create_admin("admin2", "hash2").await;
assert!(second_res.is_err(), "second admin must be rejected");
// Test password change
let new_password = "NewSuperSecurePassword456!";
let new_hash = hash_password(new_password).expect("new hash");
store
.update_admin_password(&new_hash)
.await
.expect("update_admin_password");
let updated = store.get_admin().await.expect("get_admin").expect("admin");
assert!(verify_password(new_password, &updated.password_hash).expect("verify new"));
assert!(!verify_password(password, &updated.password_hash).expect("old password fails"));
// Test TOTP update
store
.update_admin_totp(Some("JBSWY3DPEHPK3PXP"), true)
.await
.expect("update_admin_totp");
let totp_admin = store.get_admin().await.expect("get_admin").expect("admin");
assert!(totp_admin.totp_enabled);
assert_eq!(totp_admin.totp_secret.as_deref(), Some("JBSWY3DPEHPK3PXP"));
// Test recording login
store
.record_admin_login(Some("192.168.1.100"))
.await
.expect("record_admin_login");
let login_admin = store.get_admin().await.expect("get_admin").expect("admin");
assert!(login_admin.last_login_at.is_some());
assert_eq!(login_admin.last_login_ip.as_deref(), Some("192.168.1.100"));
// Verify Debug formatting redacts password_hash and totp_secret
let debug_str = format!("{:?}", login_admin);
assert!(debug_str.contains("[REDACTED]"));
assert!(!debug_str.contains(password));
assert!(!debug_str.contains(new_password));
assert!(!debug_str.contains("JBSWY3DPEHPK3PXP"));
}
@@ -0,0 +1,177 @@
//! Tests for Session and API Token repository operations.
use chrono::{Duration, Utc};
use nx9_wg_core::crypto::{generate_api_token, hash_password};
use nx9_wg_core::types::auth::{ApiToken, Session};
use nx9_wg_db::Store;
use uuid::Uuid;
#[tokio::test]
async fn test_session_lifecycle() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
let pw_hash = hash_password("AdminPass123!").expect("hash");
store.create_admin("admin", &pw_hash).await.expect("admin");
let now = Utc::now().naive_utc();
let session_id = Uuid::new_v4().to_string();
let session = Session {
id: session_id.clone(),
admin_id: 1,
created_at: now,
expires_at: now + Duration::hours(24),
last_seen_at: Some(now),
ip_address: Some("10.0.0.5".to_string()),
user_agent: Some("TestAgent/1.0".to_string()),
};
store
.create_session(&session)
.await
.expect("create_session");
let fetched = store
.get_session(&session_id)
.await
.expect("get_session")
.expect("session found");
assert_eq!(fetched.id, session_id);
assert_eq!(fetched.admin_id, 1);
assert_eq!(fetched.ip_address.as_deref(), Some("10.0.0.5"));
// Touch session
store
.touch_session(&session_id)
.await
.expect("touch_session");
// Test delete expired sessions
let expired_id = Uuid::new_v4().to_string();
let expired_session = Session {
id: expired_id.clone(),
admin_id: 1,
created_at: now - Duration::hours(48),
expires_at: now - Duration::hours(24),
last_seen_at: None,
ip_address: None,
user_agent: None,
};
store
.create_session(&expired_session)
.await
.expect("expired session");
let deleted = store
.delete_expired_sessions()
.await
.expect("delete expired");
assert_eq!(deleted, 1);
assert!(store.get_session(&expired_id).await.expect("get").is_none());
assert!(store.get_session(&session_id).await.expect("get").is_some());
// Delete single session
store
.delete_session(&session_id)
.await
.expect("delete session");
assert!(store.get_session(&session_id).await.expect("get").is_none());
// Test delete_all_admin_sessions
let s1 = Session {
id: "s1".to_string(),
admin_id: 1,
created_at: now,
expires_at: now + Duration::hours(1),
last_seen_at: None,
ip_address: None,
user_agent: None,
};
let s2 = Session {
id: "s2".to_string(),
admin_id: 1,
created_at: now,
expires_at: now + Duration::hours(1),
last_seen_at: None,
ip_address: None,
user_agent: None,
};
store.create_session(&s1).await.expect("s1");
store.create_session(&s2).await.expect("s2");
let deleted_all = store
.delete_all_admin_sessions(1)
.await
.expect("delete all");
assert_eq!(deleted_all, 2);
}
#[tokio::test]
async fn test_api_token_lifecycle() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
let pw_hash = hash_password("AdminPass123!").expect("hash");
store.create_admin("admin", &pw_hash).await.expect("admin");
let (raw_token, token_hash) = generate_api_token();
let token_id = Uuid::new_v4().to_string();
let now = Utc::now().naive_utc();
let token = ApiToken {
id: token_id.clone(),
admin_id: 1,
name: "CI/CD Deployment Token".to_string(),
token_hash: token_hash.clone(),
created_at: now,
expires_at: Some(now + Duration::days(30)),
last_used_at: None,
revoked_at: None,
revoked: false,
};
store.create_token(&token).await.expect("create_token");
// Lookup by hash
let found = store
.find_token_by_hash(&token_hash)
.await
.expect("find by hash")
.expect("token found");
assert_eq!(found.id, token_id);
assert_eq!(found.name, "CI/CD Deployment Token");
assert!(!found.revoked);
// Verify raw token is never in the stored record
let debug_out = format!("{:?}", found);
assert!(debug_out.contains("[REDACTED]"));
assert!(!debug_out.contains(&raw_token));
// Mark token used
store.mark_token_used(&token_id).await.expect("mark used");
let after_use = store
.get_token(&token_id)
.await
.expect("get")
.expect("token");
assert!(after_use.last_used_at.is_some());
// List tokens
let tokens = store.list_tokens().await.expect("list tokens");
assert_eq!(tokens.len(), 1);
// Revoke token
store.revoke_token(&token_id).await.expect("revoke token");
let revoked = store
.get_token(&token_id)
.await
.expect("get")
.expect("token");
assert!(revoked.revoked);
assert!(revoked.revoked_at.is_some());
// Delete token
store.delete_token(&token_id).await.expect("delete token");
assert!(store.get_token(&token_id).await.expect("get").is_none());
}
@@ -0,0 +1,106 @@
//! Tests for client profile repository operations and built-in profiles.
use nx9_wg_core::types::client_profile::{ClientProfile, ConnectionType, DeviceCategory, NatType};
use nx9_wg_db::Store;
#[tokio::test]
async fn test_client_profiles_crud_and_builtin_protection() {
let store = Store::connect_in_memory().await.unwrap();
store.migrate().await.unwrap();
// Verify built-in profiles pre-populated by migration
let profiles = store.list_client_profiles().await.unwrap();
assert!(
profiles.len() >= 10,
"expected at least 10 built-in profiles"
);
// Check specific built-ins
let mobile = store.get_client_profile("default-mobile").await.unwrap();
assert!(mobile.is_some());
let mobile = mobile.unwrap();
assert_eq!(mobile.connection_type, ConnectionType::Mobile);
assert_eq!(mobile.mtu, 1280);
assert_eq!(mobile.persistent_keepalive, Some(25));
assert!(mobile.is_builtin);
let cgnat = store.get_client_profile("default-cgnat").await.unwrap();
assert!(cgnat.is_some());
let cgnat = cgnat.unwrap();
assert_eq!(cgnat.nat_type, NatType::Cgnat);
assert_eq!(cgnat.mtu, 1360);
// Verify built-in cannot be modified or deleted
let mut modified_builtin = mobile.clone();
modified_builtin.mtu = 1400;
assert!(
store
.update_client_profile(&modified_builtin)
.await
.is_err()
);
assert!(store.delete_client_profile("default-mobile").await.is_err());
// Create a custom profile
let now = chrono::Utc::now().naive_utc();
let custom = ClientProfile {
id: "office-fiber".to_string(),
name: "Office Fiber Direct".to_string(),
provider: Some("att".to_string()),
device: Some(DeviceCategory::Linux),
connection_type: ConnectionType::Wired,
nat_type: NatType::Direct,
mtu: 1420,
dns: Some("1.1.1.1, 1.0.0.1".to_string()),
persistent_keepalive: Some(15),
is_builtin: false,
description: Some("Direct fiber connection at headquarters".to_string()),
created_at: now,
updated_at: now,
};
store.create_client_profile(&custom).await.unwrap();
let fetched = store
.get_client_profile("office-fiber")
.await
.unwrap()
.unwrap();
assert_eq!(fetched.name, "Office Fiber Direct");
assert_eq!(fetched.provider.as_deref(), Some("att"));
assert_eq!(fetched.mtu, 1420);
assert!(!fetched.is_builtin);
// Update custom profile
let mut updated = fetched.clone();
updated.description = Some("Updated headquarters fiber".to_string());
updated.mtu = 1440;
store.update_client_profile(&updated).await.unwrap();
let fetched_updated = store
.get_client_profile("office-fiber")
.await
.unwrap()
.unwrap();
assert_eq!(fetched_updated.mtu, 1440);
assert_eq!(
fetched_updated.description.as_deref(),
Some("Updated headquarters fiber")
);
// List distinct providers
let providers = store.list_distinct_providers().await.unwrap();
assert!(providers.contains(&"att".to_string()));
assert!(providers.contains(&"tmobile".to_string()));
assert!(providers.contains(&"starlink".to_string()));
// Delete custom profile
store.delete_client_profile("office-fiber").await.unwrap();
assert!(
store
.get_client_profile("office-fiber")
.await
.unwrap()
.is_none()
);
}
@@ -0,0 +1,288 @@
//! Tests for Network, Route, and Firewall Rule repositories.
use chrono::Utc;
use ipnet::IpNet;
use nx9_wg_core::crypto::generate_keypair;
use nx9_wg_core::types::firewall::{
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
};
use nx9_wg_core::types::network::{Network, Route};
use nx9_wg_core::types::wireguard::Interface;
use nx9_wg_db::Store;
use std::net::IpAddr;
use std::str::FromStr;
use uuid::Uuid;
#[tokio::test]
async fn test_network_and_route_crud() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
let now = Utc::now().naive_utc();
let net_id = Uuid::new_v4();
let net = Network {
id: net_id,
name: "Home Lab".to_string(),
cidr: IpNet::from_str("192.168.10.0/24").expect("cidr"),
enabled: true,
description: Some("Internal lab subnet".to_string()),
created_at: now,
updated_at: now,
};
store.create_network(&net).await.expect("create_network");
let fetched_net = store
.get_network(net_id)
.await
.expect("get")
.expect("found");
assert_eq!(fetched_net.name, "Home Lab");
assert_eq!(fetched_net.cidr.to_string(), "192.168.10.0/24");
assert!(fetched_net.enabled);
// Test routes
let route_id = Uuid::new_v4();
let route = Route {
id: route_id,
network_id: Some(net_id),
interface_id: None,
destination: IpNet::from_str("192.168.10.0/24").expect("dest cidr"),
gateway: Some(IpAddr::from_str("10.0.0.1").expect("gateway")),
interface_name: None,
metric: Some(100),
enabled: true,
description: Some("Lab route via wg gateway".to_string()),
created_at: now,
updated_at: now,
};
store.create_route(&route).await.expect("create_route");
let fetched_route = store
.get_route(route_id)
.await
.expect("get")
.expect("route found");
assert_eq!(fetched_route.network_id, Some(net_id));
assert_eq!(
fetched_route.gateway,
Some(IpAddr::from_str("10.0.0.1").unwrap())
);
assert_eq!(fetched_route.metric, Some(100));
// Enable/disable route
store
.set_route_enabled(route_id, false)
.await
.expect("disable");
let disabled_route = store
.get_route(route_id)
.await
.expect("get")
.expect("route");
assert!(!disabled_route.enabled);
// List routes for network
let net_routes = store
.list_routes_for_network(net_id)
.await
.expect("list net routes");
assert_eq!(net_routes.len(), 1);
// Deleting network sets route's network_id to NULL (ON DELETE SET NULL)
store.delete_network(net_id).await.expect("delete network");
let route_after_net_delete = store
.get_route(route_id)
.await
.expect("get")
.expect("route");
assert!(
route_after_net_delete.network_id.is_none(),
"network_id must be SET NULL when network is deleted"
);
}
#[tokio::test]
async fn test_firewall_rule_crud_and_priority_ordering() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
let now = Utc::now().naive_utc();
let iface_id = Uuid::new_v4();
let (priv_k, pub_k) = generate_keypair();
let iface = Interface {
id: iface_id,
name: "wg0".to_string(),
private_key: priv_k,
public_key: pub_k,
listen_port: 51820,
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
address_v6: None,
mtu: None,
dns: None,
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
store
.create_interface(&iface)
.await
.expect("create interface");
let rule1_id = Uuid::new_v4();
let rule1 = FirewallRule {
id: rule1_id,
name: "Allow SSH".to_string(),
interface_id: Some(iface_id),
peer_id: None,
direction: FirewallDirection::In,
action: FirewallAction::Accept,
protocol: FirewallProtocol::Tcp,
source: None,
destination: None,
source_port: None,
destination_port: Some(22),
port_range: None,
priority: 50,
enabled: true,
description: Some("SSH access".to_string()),
created_at: now,
updated_at: now,
};
let rule2_id = Uuid::new_v4();
let rule2 = FirewallRule {
id: rule2_id,
name: "Drop All Other".to_string(),
interface_id: Some(iface_id),
peer_id: None,
direction: FirewallDirection::In,
action: FirewallAction::Drop,
protocol: FirewallProtocol::Any,
source: None,
destination: None,
source_port: None,
destination_port: None,
port_range: None,
priority: 100,
enabled: true,
description: Some("Default drop".to_string()),
created_at: now,
updated_at: now,
};
store
.create_firewall_rule(&rule2)
.await
.expect("create rule2");
store
.create_firewall_rule(&rule1)
.await
.expect("create rule1");
// List rules should order by priority ASC (rule1 priority 50 comes before rule2 priority 100)
let rules = store.list_firewall_rules().await.expect("list rules");
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].id, rule1_id);
assert_eq!(rules[0].priority, 50);
assert_eq!(rules[1].id, rule2_id);
assert_eq!(rules[1].priority, 100);
// List rules for interface
let iface_rules = store
.list_firewall_rules_for_interface(iface_id)
.await
.expect("list iface rules");
assert_eq!(iface_rules.len(), 2);
// Enable/disable rule
store
.set_firewall_rule_enabled(rule1_id, false)
.await
.expect("disable");
let disabled = store
.get_firewall_rule(rule1_id)
.await
.expect("get")
.expect("rule");
assert!(!disabled.enabled);
// Delete rule
store.delete_firewall_rule(rule1_id).await.expect("delete");
assert!(
store
.get_firewall_rule(rule1_id)
.await
.expect("get")
.is_none()
);
// Peer-specific rule with port range
let peer_id = Uuid::new_v4();
let peer = nx9_wg_core::types::wireguard::Peer {
id: peer_id,
interface_id: iface_id,
name: "test-peer-fw".to_string(),
peer_type: nx9_wg_core::types::wireguard::PeerType::RoadWarrior,
state: nx9_wg_core::types::wireguard::PeerState::Active,
public_key: nx9_wg_core::types::wireguard::WireGuardPublicKey::new(
"testpubkey12345678901234567890123456789012=".to_string(),
),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "10.0.0.2/32".to_string(),
server_allowed_ips: None,
address_v4: Some("10.0.0.2/32".parse().unwrap()),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: nx9_wg_core::types::wireguard::PeerProfile::FullTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
store.create_peer(&peer).await.expect("create peer");
let peer_rule_id = Uuid::new_v4();
let peer_rule = FirewallRule {
id: peer_rule_id,
name: "Peer Port Range Rule".to_string(),
interface_id: Some(iface_id),
peer_id: Some(peer_id),
direction: FirewallDirection::In,
action: FirewallAction::Accept,
protocol: FirewallProtocol::TcpUdp,
source: None,
destination: None,
source_port: None,
destination_port: None,
port_range: Some("8000-8100".to_string()),
priority: 25,
enabled: true,
description: Some("Custom peer range".to_string()),
created_at: now,
updated_at: now,
};
store
.create_firewall_rule(&peer_rule)
.await
.expect("create peer rule");
let peer_rules = store
.list_firewall_rules_for_peer(peer_id)
.await
.expect("list peer rules");
assert_eq!(peer_rules.len(), 1);
assert_eq!(peer_rules[0].port_range.as_deref(), Some("8000-8100"));
assert_eq!(peer_rules[0].protocol, FirewallProtocol::TcpUdp);
}
@@ -0,0 +1,52 @@
//! Tests for Store initialization, WAL configuration, migrations, and SQLite invariants.
use nx9_wg_db::Store;
use sqlx::Row;
use tempfile::NamedTempFile;
#[tokio::test]
async fn test_in_memory_store_lifecycle() {
let store = Store::connect_in_memory().await.expect("connect in-memory");
store.migrate().await.expect("run migrations");
// Verify foreign keys are enabled
let row = sqlx::query("PRAGMA foreign_keys")
.fetch_one(store.pool())
.await
.expect("pragma foreign_keys");
let fk: i64 = row.get(0);
assert_eq!(fk, 1, "foreign keys must be enabled");
}
#[tokio::test]
async fn test_temp_file_store_wal_mode() {
let tmp = NamedTempFile::new().expect("temp file");
let path = tmp.path();
let store = Store::connect_path(path).await.expect("connect path");
store.migrate().await.expect("run migrations");
// Verify WAL mode is configured
let row = sqlx::query("PRAGMA journal_mode")
.fetch_one(store.pool())
.await
.expect("pragma journal_mode");
let mode: String = row.get(0);
assert_eq!(mode.to_lowercase(), "wal", "WAL mode must be active");
// Verify migrations table exists and records the initial migration
let migration_count_row = sqlx::query("SELECT COUNT(*) FROM _sqlx_migrations")
.fetch_one(store.pool())
.await
.expect("query migrations");
let count: i64 = migration_count_row.get(0);
assert!(count >= 1, "at least one migration should be recorded");
}
#[tokio::test]
async fn test_migration_idempotence() {
let store = Store::connect_in_memory().await.expect("connect in-memory");
store.migrate().await.expect("first migration run");
// Running migrate a second time must succeed idempotently
store.migrate().await.expect("second migration run");
}
@@ -0,0 +1,227 @@
//! Tests for Settings, Audit Log, and Backup repositories.
use chrono::Utc;
use nx9_wg_core::types::audit::AuditEventType;
use nx9_wg_core::types::backup::BackupMeta;
use nx9_wg_db::{AuditFilter, Store};
use uuid::Uuid;
#[tokio::test]
async fn test_settings_repository() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
// Initially missing key returns None
assert!(
store
.get_setting("non_existent")
.await
.expect("get")
.is_none()
);
assert!(
store
.get_setting_value("non_existent")
.await
.expect("get val")
.is_none()
);
// Set normal setting
store
.set_setting("server_endpoint", "vpn.example.com:51820", false)
.await
.expect("set");
let ep = store
.get_setting("server_endpoint")
.await
.expect("get")
.expect("setting found");
assert_eq!(ep.value, "vpn.example.com:51820");
assert!(!ep.is_secret);
// Set secret setting
store
.set_setting("session_secret", "SuperSecretKey999", true)
.await
.expect("set secret");
let sec = store
.get_setting("session_secret")
.await
.expect("get")
.expect("setting found");
assert_eq!(sec.value, "SuperSecretKey999");
assert!(sec.is_secret);
// Verify Debug formatting of secret setting redacts value
let sec_debug = format!("{:?}", sec);
assert!(sec_debug.contains("[REDACTED]"));
assert!(!sec_debug.contains("SuperSecretKey999"));
// Upsert existing setting
store
.set_setting("server_endpoint", "vpn2.example.com:51820", false)
.await
.expect("upsert");
let ep2 = store
.get_setting_value("server_endpoint")
.await
.expect("get")
.expect("value found");
assert_eq!(ep2, "vpn2.example.com:51820");
// List settings
let all = store.list_settings().await.expect("list");
assert_eq!(all.len(), 2);
// Delete setting
store
.delete_setting("server_endpoint")
.await
.expect("delete");
assert!(
store
.get_setting("server_endpoint")
.await
.expect("get")
.is_none()
);
}
#[tokio::test]
async fn test_audit_log_append_only_and_filtering() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
// Record various events
store
.record_audit(
AuditEventType::Login,
"admin",
Some("session"),
Some("sess-1"),
Some("Admin login succeeded"),
None,
Some("192.168.1.50"),
)
.await
.expect("record login");
store
.record_audit(
AuditEventType::InterfaceCreate,
"admin",
Some("interface"),
Some("wg0"),
Some("Interface wg0 created"),
None,
Some("192.168.1.50"),
)
.await
.expect("record iface create");
store
.record_audit(
AuditEventType::PeerCreate,
"admin",
Some("peer"),
Some("peer-alice"),
Some("Peer alice created"),
None,
Some("192.168.1.50"),
)
.await
.expect("record peer create");
// Total count
let total = store
.count_audit_events(&AuditFilter::default())
.await
.expect("count");
assert_eq!(total, 3);
// Filter by event_type
let login_filter = AuditFilter {
event_type: Some(AuditEventType::Login),
..Default::default()
};
let login_events = store
.list_audit_events(&login_filter, 10, 0)
.await
.expect("list login");
assert_eq!(login_events.len(), 1);
assert_eq!(login_events[0].event_type, AuditEventType::Login);
// Filter by resource_type
let peer_filter = AuditFilter {
resource_type: Some("peer".to_string()),
..Default::default()
};
let peer_events = store
.list_audit_events(&peer_filter, 10, 0)
.await
.expect("list peer events");
assert_eq!(peer_events.len(), 1);
assert_eq!(peer_events[0].resource_id.as_deref(), Some("peer-alice"));
// Pagination test: limit 2, offset 0 -> 2 items; offset 2 -> 1 item
let page1 = store
.list_audit_events(&AuditFilter::default(), 2, 0)
.await
.expect("page1");
assert_eq!(page1.len(), 2);
let page2 = store
.list_audit_events(&AuditFilter::default(), 2, 2)
.await
.expect("page2");
assert_eq!(page2.len(), 1);
}
#[tokio::test]
async fn test_backup_metadata_crud() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
let now = Utc::now().naive_utc();
let backup_id = Uuid::new_v4();
let meta = BackupMeta {
id: backup_id,
filename: "nx9-wg-backup-20260816.tar.gz".to_string(),
size_bytes: 1048576,
checksum: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
.to_string(),
schema_version: "1".to_string(),
encrypted: true,
description: Some("Automated nightly backup".to_string()),
created_at: now,
};
store
.create_backup_meta(&meta)
.await
.expect("create_backup_meta");
let fetched = store
.get_backup_meta(backup_id)
.await
.expect("get")
.expect("backup found");
assert_eq!(fetched.filename, "nx9-wg-backup-20260816.tar.gz");
assert_eq!(fetched.size_bytes, 1048576);
assert!(fetched.encrypted);
assert_eq!(fetched.schema_version, "1");
let list = store.list_backups().await.expect("list");
assert_eq!(list.len(), 1);
store.delete_backup_meta(backup_id).await.expect("delete");
assert!(
store
.get_backup_meta(backup_id)
.await
.expect("get")
.is_none()
);
}
@@ -0,0 +1,246 @@
//! Tests for WireGuard Interface and Peer repository operations.
use chrono::Utc;
use ipnet::IpNet;
use nx9_wg_core::crypto::{generate_keypair, generate_preshared_key};
use nx9_wg_core::types::wireguard::{
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPublicKey,
};
use nx9_wg_db::Store;
use std::str::FromStr;
use uuid::Uuid;
#[tokio::test]
async fn test_interface_and_peer_crud_and_cascade() {
let store = Store::connect_in_memory().await.expect("connect");
store.migrate().await.expect("migrate");
let now = Utc::now().naive_utc();
let iface_id = Uuid::new_v4();
let (priv_k, pub_k) = generate_keypair();
let iface = Interface {
id: iface_id,
name: "wg0".to_string(),
private_key: priv_k.clone(),
public_key: pub_k.clone(),
listen_port: 51820,
address_v4: IpNet::from_str("10.0.0.1/24").expect("valid cidr"),
address_v6: Some(IpNet::from_str("fd00::1/64").expect("valid cidr")),
mtu: Some(1420),
dns: Some("1.1.1.1, 8.8.8.8".to_string()),
enabled: true,
pre_up: None,
post_up: Some("iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE".to_string()),
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
store
.create_interface(&iface)
.await
.expect("create_interface");
// Lookup interface by ID and name
let fetched = store
.get_interface(iface_id)
.await
.expect("get_interface")
.expect("iface found");
assert_eq!(fetched.name, "wg0");
assert_eq!(fetched.listen_port, 51820);
assert_eq!(fetched.address_v4.to_string(), "10.0.0.1/24");
assert_eq!(fetched.mtu, Some(1420));
let by_name = store
.get_interface_by_name("wg0")
.await
.expect("get_by_name")
.expect("found");
assert_eq!(by_name.id, iface_id);
// Reject duplicate interface name
let dup_iface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
private_key: priv_k.clone(),
public_key: pub_k.clone(),
listen_port: 51821,
address_v4: IpNet::from_str("10.0.1.1/24").unwrap(),
address_v6: None,
mtu: None,
dns: None,
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
assert!(
store.create_interface(&dup_iface).await.is_err(),
"duplicate interface name must fail"
);
// Create a peer
let peer_id = Uuid::new_v4();
let (peer_priv, peer_pub) = generate_keypair();
let psk = generate_preshared_key();
let peer = Peer {
id: peer_id,
interface_id: iface_id,
name: "phone-alice".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: peer_pub.clone(),
private_key: Some(peer_priv.clone()),
preshared_key: Some(psk.clone()),
endpoint: None,
allowed_ips: "10.0.0.2/32".to_string(),
server_allowed_ips: Some("10.0.0.2/32".to_string()),
address_v4: Some(IpNet::from_str("10.0.0.2/32").unwrap()),
address_v6: None,
dns: Some("10.0.0.1".to_string()),
mtu: Some(1420),
persistent_keepalive: Some(25),
profile: PeerProfile::FullTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
store.create_peer(&peer).await.expect("create_peer");
// Fetch peer
let fetched_peer = store
.get_peer(peer_id)
.await
.expect("get_peer")
.expect("peer found");
assert_eq!(fetched_peer.name, "phone-alice");
assert_eq!(fetched_peer.peer_type, PeerType::RoadWarrior);
assert_eq!(fetched_peer.state, PeerState::Active);
assert_eq!(fetched_peer.profile, PeerProfile::FullTunnel);
assert_eq!(fetched_peer.allowed_ips, "10.0.0.2/32");
assert_eq!(fetched_peer.persistent_keepalive, Some(25));
// Lookup peer by name and by public key
let by_pname = store
.get_peer_by_name(iface_id, "phone-alice")
.await
.expect("by name")
.expect("found");
assert_eq!(by_pname.id, peer_id);
let by_pubk = store
.get_peer_by_public_key(iface_id, peer_pub.as_str())
.await
.expect("by pubk")
.expect("found");
assert_eq!(by_pubk.id, peer_id);
// Reject duplicate peer name on same interface
let dup_pname = Peer {
id: Uuid::new_v4(),
interface_id: iface_id,
name: "phone-alice".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: WireGuardPublicKey::new("different_key_123=".to_string()),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "10.0.0.3/32".to_string(),
server_allowed_ips: None,
address_v4: None,
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::SplitTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
assert!(
store.create_peer(&dup_pname).await.is_err(),
"duplicate peer name on same interface must fail"
);
// Reject peer for non-existent interface (foreign key violation)
let non_existent_iface_peer = Peer {
id: Uuid::new_v4(),
interface_id: Uuid::new_v4(),
name: "orphan-peer".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: WireGuardPublicKey::new("orphan_key_123=".to_string()),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "10.0.0.4/32".to_string(),
server_allowed_ips: None,
address_v4: None,
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::SplitTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
assert!(
store.create_peer(&non_existent_iface_peer).await.is_err(),
"peer for non-existent interface must fail foreign key constraint"
);
// Test peer state transition: active -> disabled -> revoked
store
.set_peer_state(peer_id, PeerState::Disabled)
.await
.expect("set disabled");
let disabled = store.get_peer(peer_id).await.expect("get").expect("peer");
assert_eq!(disabled.state, PeerState::Disabled);
store
.set_peer_state(peer_id, PeerState::Revoked)
.await
.expect("set revoked");
let revoked = store.get_peer(peer_id).await.expect("get").expect("peer");
assert_eq!(revoked.state, PeerState::Revoked);
// Test update_peer_handshake
let handshake_time = Utc::now().naive_utc();
store
.update_peer_handshake(peer_id, handshake_time)
.await
.expect("update handshake");
let after_hs = store.get_peer(peer_id).await.expect("get").expect("peer");
assert!(after_hs.last_handshake_at.is_some());
// Test list_peers_for_interface
let peer_list = store
.list_peers_for_interface(iface_id)
.await
.expect("list peers");
assert_eq!(peer_list.len(), 1);
// Test cascade delete: deleting interface must cascade and delete its peers
store
.delete_interface(iface_id)
.await
.expect("delete interface");
assert!(store.get_interface(iface_id).await.expect("get").is_none());
assert!(
store.get_peer(peer_id).await.expect("get").is_none(),
"peer must be cascade-deleted with interface"
);
}