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:
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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user