402 lines
13 KiB
Rust
402 lines
13 KiB
Rust
//! Comprehensive Integration and Drift Matrix test suite for ReconciliationEngine.
|
|
//! Covers:
|
|
//! - WireGuard interface & peer drift (CREATE, UPDATE, DELETE, NOOP)
|
|
//! - Route, Firewall, NAT, and Forwarding drift detection
|
|
//! - Plan dry-run read-only determinism and idempotency
|
|
//! - Concurrent apply serialization
|
|
//! - Restart recovery
|
|
//! - Secret safety across plans and reports
|
|
|
|
use chrono::Utc;
|
|
use ipnet::IpNet;
|
|
use nx9_wg_api::reconciliation::ReconciliationEngine;
|
|
use nx9_wg_api::state::AppState;
|
|
use nx9_wg_core::crypto::generate_keypair;
|
|
use nx9_wg_core::types::firewall::{
|
|
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
|
|
};
|
|
use nx9_wg_core::types::network::Route;
|
|
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
|
|
use nx9_wg_core::validation::validate_cidr;
|
|
use nx9_wg_db::Store;
|
|
use nx9_wg_network::SimulatedNetworkEngine;
|
|
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
|
|
use std::sync::Arc;
|
|
use tempfile::{TempDir, tempdir};
|
|
use uuid::Uuid;
|
|
|
|
async fn setup_test_env() -> (
|
|
TempDir,
|
|
Store,
|
|
AppState,
|
|
Arc<SimulatedWireGuardEngine>,
|
|
Arc<SimulatedNetworkEngine>,
|
|
ReconciliationEngine,
|
|
) {
|
|
let dir = tempdir().expect("create temp dir");
|
|
let db_path = dir.path().join("drift_test.db");
|
|
let store = Store::connect(&db_path.to_string_lossy())
|
|
.await
|
|
.expect("connect to db");
|
|
store.migrate().await.expect("run migrations");
|
|
|
|
let state = AppState::new(store.clone());
|
|
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
|
|
let net_engine = Arc::new(SimulatedNetworkEngine::new());
|
|
let reconciler =
|
|
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
|
|
|
|
(dir, store, state, wg_engine, net_engine, reconciler)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_drift_matrix_peer_lifecycle() {
|
|
let (_dir, store, _state, wg_engine, _net_engine, reconciler) = setup_test_env().await;
|
|
|
|
// 1. Create interface & active peer in SQLite
|
|
let (priv_key, pub_key) = generate_keypair();
|
|
let iface_id = Uuid::new_v4();
|
|
let iface = Interface {
|
|
id: iface_id,
|
|
name: "nx9_test0".to_string(),
|
|
private_key: priv_key,
|
|
public_key: pub_key,
|
|
listen_port: 51820,
|
|
address_v4: validate_cidr("10.10.0.1/24").unwrap(),
|
|
address_v6: None,
|
|
mtu: Some(1420),
|
|
dns: None,
|
|
enabled: true,
|
|
pre_up: None,
|
|
post_up: None,
|
|
pre_down: None,
|
|
post_down: None,
|
|
created_at: Utc::now().naive_utc(),
|
|
updated_at: Utc::now().naive_utc(),
|
|
};
|
|
store.create_interface(&iface).await.unwrap();
|
|
|
|
let (_p_priv, p_pub) = generate_keypair();
|
|
let peer_id = Uuid::new_v4();
|
|
let peer = Peer {
|
|
id: peer_id,
|
|
interface_id: iface_id,
|
|
name: "peer-alice".to_string(),
|
|
public_key: p_pub.clone(),
|
|
private_key: None,
|
|
preshared_key: None,
|
|
address_v4: Some(validate_cidr("10.10.0.2/32").unwrap()),
|
|
address_v6: None,
|
|
allowed_ips: "10.10.0.2/32".to_string(),
|
|
server_allowed_ips: None,
|
|
endpoint: Some("203.0.113.5:51820".to_string()),
|
|
persistent_keepalive: Some(25),
|
|
dns: None,
|
|
mtu: None,
|
|
profile: PeerProfile::FullTunnel,
|
|
state: PeerState::Active,
|
|
peer_type: PeerType::RoadWarrior,
|
|
expires_at: None,
|
|
last_handshake_at: None,
|
|
created_at: Utc::now().naive_utc(),
|
|
updated_at: Utc::now().naive_utc(),
|
|
};
|
|
store.create_peer(&peer).await.unwrap();
|
|
|
|
// 2. Plan: Detect interface missing & peer missing
|
|
let plan = reconciler.plan().await.unwrap();
|
|
assert!(plan.has_drift);
|
|
assert_eq!(plan.interface_changes, 1);
|
|
assert_eq!(plan.peer_changes, 1);
|
|
|
|
// 3. Apply: Converges state to kernel
|
|
let report = reconciler.apply().await.unwrap();
|
|
assert!(report.success);
|
|
|
|
// 4. Verify live stats
|
|
let stats = wg_engine
|
|
.get_interface_stats("nx9_test0")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(stats.peers.len(), 1);
|
|
assert_eq!(stats.peers[0].public_key, p_pub.as_str());
|
|
|
|
// 5. Post-apply verify: zero drift
|
|
let plan2 = reconciler.verify().await.unwrap();
|
|
assert_eq!(plan2.interface_changes, 0);
|
|
assert_eq!(plan2.peer_changes, 0);
|
|
|
|
// 6. Drift injection: Mark peer Expired in SQLite
|
|
store.mark_peer_expired(peer_id).await.unwrap();
|
|
|
|
// Plan should detect active peer in kernel is no longer active in DB -> remove_inactive_peer
|
|
let plan3 = reconciler.plan().await.unwrap();
|
|
assert!(plan3.has_drift);
|
|
assert_eq!(plan3.peer_changes, 1);
|
|
assert!(
|
|
plan3
|
|
.actions
|
|
.iter()
|
|
.any(|a| a.action_type == "remove_inactive_peer")
|
|
);
|
|
|
|
// Apply removal
|
|
let report2 = reconciler.apply().await.unwrap();
|
|
assert!(report2.success);
|
|
|
|
// Live interface now has 0 peers
|
|
let stats2 = wg_engine
|
|
.get_interface_stats("nx9_test0")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(stats2.peers.len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_drift_matrix_routes_and_firewall() {
|
|
let (_dir, store, _state, _wg_engine, _net_engine, reconciler) = setup_test_env().await;
|
|
|
|
// 1. Add route in SQLite
|
|
let route = Route {
|
|
id: Uuid::new_v4(),
|
|
network_id: None,
|
|
interface_id: None,
|
|
destination: "192.168.50.0/24".parse::<IpNet>().unwrap(),
|
|
gateway: Some("10.10.0.1".parse().unwrap()),
|
|
interface_name: Some("nx9_test0".to_string()),
|
|
metric: Some(100),
|
|
description: Some("Test route".to_string()),
|
|
enabled: true,
|
|
created_at: Utc::now().naive_utc(),
|
|
updated_at: Utc::now().naive_utc(),
|
|
};
|
|
store.create_route(&route).await.unwrap();
|
|
|
|
// 2. Add firewall rule in SQLite
|
|
let fw = FirewallRule {
|
|
id: Uuid::new_v4(),
|
|
name: "allow-http".to_string(),
|
|
interface_id: None,
|
|
peer_id: None,
|
|
direction: FirewallDirection::In,
|
|
source: None,
|
|
destination: None,
|
|
protocol: FirewallProtocol::Tcp,
|
|
source_port: None,
|
|
destination_port: Some(80),
|
|
port_range: None,
|
|
action: FirewallAction::Accept,
|
|
priority: 100,
|
|
enabled: true,
|
|
description: None,
|
|
created_at: Utc::now().naive_utc(),
|
|
updated_at: Utc::now().naive_utc(),
|
|
};
|
|
store.create_firewall_rule(&fw).await.unwrap();
|
|
|
|
// 3. Plan should detect route changes and firewall changes
|
|
let plan = reconciler.plan().await.unwrap();
|
|
assert!(plan.has_drift);
|
|
assert_eq!(plan.route_changes, 1);
|
|
assert_eq!(plan.firewall_changes, 1);
|
|
|
|
// 4. Apply
|
|
let report = reconciler.apply().await.unwrap();
|
|
assert!(report.success);
|
|
assert!(report.executed_actions >= 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_reconciliation_dry_run_idempotency_and_read_only() {
|
|
let (_dir, store, _state, _wg_engine, _net_engine, reconciler) = setup_test_env().await;
|
|
|
|
let audit_count_before = store
|
|
.list_audit_events(&nx9_wg_db::AuditFilter::default(), 100, 0)
|
|
.await
|
|
.unwrap()
|
|
.len();
|
|
|
|
// Run plan multiple times
|
|
let plan1 = reconciler.plan().await.unwrap();
|
|
let plan2 = reconciler.plan().await.unwrap();
|
|
let plan3 = reconciler.verify().await.unwrap();
|
|
|
|
assert_eq!(plan1.has_drift, plan2.has_drift);
|
|
assert_eq!(plan1.actions.len(), plan2.actions.len());
|
|
assert_eq!(plan1.actions.len(), plan3.actions.len());
|
|
|
|
// Audit logs must not increase during plan/verify dry-runs
|
|
let audit_count_after = store
|
|
.list_audit_events(&nx9_wg_db::AuditFilter::default(), 100, 0)
|
|
.await
|
|
.unwrap()
|
|
.len();
|
|
assert_eq!(audit_count_before, audit_count_after);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_reconciliation_concurrent_apply_serialization() {
|
|
let (_dir, _store, _state, _wg_engine, _net_engine, reconciler) = setup_test_env().await;
|
|
let reconciler_arc = Arc::new(reconciler);
|
|
|
|
let mut handles = Vec::new();
|
|
for _ in 0..5 {
|
|
let r = Arc::clone(&reconciler_arc);
|
|
handles.push(tokio::spawn(async move { r.apply().await }));
|
|
}
|
|
|
|
for handle in handles {
|
|
let res = handle.await.unwrap();
|
|
assert!(res.is_ok());
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_restart_recovery_simulation() {
|
|
let dir = tempdir().expect("create temp dir");
|
|
let db_path = dir.path().join("restart_test.db");
|
|
let store = Store::connect(&db_path.to_string_lossy())
|
|
.await
|
|
.expect("connect to db");
|
|
store.migrate().await.expect("run migrations");
|
|
|
|
// 1. Initial run with interface
|
|
let (priv_key, pub_key) = generate_keypair();
|
|
let iface = Interface {
|
|
id: Uuid::new_v4(),
|
|
name: "nx9_boot".to_string(),
|
|
private_key: priv_key,
|
|
public_key: pub_key,
|
|
listen_port: 51820,
|
|
address_v4: validate_cidr("10.20.0.1/24").unwrap(),
|
|
address_v6: None,
|
|
mtu: Some(1420),
|
|
dns: None,
|
|
enabled: true,
|
|
pre_up: None,
|
|
post_up: None,
|
|
pre_down: None,
|
|
post_down: None,
|
|
created_at: Utc::now().naive_utc(),
|
|
updated_at: Utc::now().naive_utc(),
|
|
};
|
|
store.create_interface(&iface).await.unwrap();
|
|
|
|
let wg1 = Arc::new(SimulatedWireGuardEngine::new());
|
|
let net1 = Arc::new(SimulatedNetworkEngine::new());
|
|
let r1 = ReconciliationEngine::new(AppState::new(store.clone()), wg1.clone(), net1.clone());
|
|
r1.apply().await.unwrap();
|
|
assert!(wg1.get_interface_stats("nx9_boot").await.unwrap().is_some());
|
|
|
|
// 2. Simulate machine reboot / app restart:
|
|
// Create new live engine instance (empty kernel state), but reconnect same store
|
|
let wg2 = Arc::new(SimulatedWireGuardEngine::new());
|
|
let net2 = Arc::new(SimulatedNetworkEngine::new());
|
|
let r2 = ReconciliationEngine::new(AppState::new(store.clone()), wg2.clone(), net2.clone());
|
|
|
|
// Before reconcile, new engine is empty
|
|
assert!(wg2.get_interface_stats("nx9_boot").await.unwrap().is_none());
|
|
|
|
// Compute plan: detects missing interface
|
|
let plan = r2.plan().await.unwrap();
|
|
assert!(plan.has_drift);
|
|
assert_eq!(plan.interface_changes, 1);
|
|
|
|
// Apply reconciliation
|
|
r2.apply().await.unwrap();
|
|
|
|
// Kernel converged
|
|
assert!(wg2.get_interface_stats("nx9_boot").await.unwrap().is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_secret_redaction_in_reconciliation_plan_and_report() {
|
|
let (_dir, store, _state, _wg_engine, _net_engine, reconciler) = setup_test_env().await;
|
|
|
|
let (priv_key, pub_key) = generate_keypair();
|
|
let raw_secret = priv_key.as_str().to_string();
|
|
|
|
let iface = Interface {
|
|
id: Uuid::new_v4(),
|
|
name: "nx9_sec".to_string(),
|
|
private_key: priv_key,
|
|
public_key: pub_key,
|
|
listen_port: 51820,
|
|
address_v4: validate_cidr("10.30.0.1/24").unwrap(),
|
|
address_v6: None,
|
|
mtu: Some(1420),
|
|
dns: None,
|
|
enabled: true,
|
|
pre_up: None,
|
|
post_up: None,
|
|
pre_down: None,
|
|
post_down: None,
|
|
created_at: Utc::now().naive_utc(),
|
|
updated_at: Utc::now().naive_utc(),
|
|
};
|
|
store.create_interface(&iface).await.unwrap();
|
|
|
|
let plan = reconciler.plan().await.unwrap();
|
|
let plan_json = serde_json::to_string(&plan).unwrap();
|
|
assert!(
|
|
!plan_json.contains(&raw_secret),
|
|
"Private key must NOT leak into plan JSON"
|
|
);
|
|
|
|
let report = reconciler.apply().await.unwrap();
|
|
let report_json = serde_json::to_string(&report).unwrap();
|
|
assert!(
|
|
!report_json.contains(&raw_secret),
|
|
"Private key must NOT leak into report JSON"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_reconciliation_status_lifecycle_and_multi_cycle_idempotency() {
|
|
use nx9_wg_api::reconciliation::ReconciliationStatus;
|
|
|
|
let (_dir, store, _state, _wg_engine, _net_engine, reconciler) = setup_test_env().await;
|
|
|
|
let (priv_key, pub_key) = generate_keypair();
|
|
let iface = Interface {
|
|
id: Uuid::new_v4(),
|
|
name: "nx9_idem".to_string(),
|
|
private_key: priv_key,
|
|
public_key: pub_key,
|
|
listen_port: 51820,
|
|
address_v4: validate_cidr("10.50.0.1/24").unwrap(),
|
|
address_v6: None,
|
|
mtu: Some(1420),
|
|
dns: None,
|
|
enabled: true,
|
|
pre_up: None,
|
|
post_up: None,
|
|
pre_down: None,
|
|
post_down: None,
|
|
created_at: Utc::now().naive_utc(),
|
|
updated_at: Utc::now().naive_utc(),
|
|
};
|
|
store.create_interface(&iface).await.unwrap();
|
|
|
|
// 1. First apply converges
|
|
let report1 = reconciler.apply().await.unwrap();
|
|
assert!(report1.success);
|
|
assert_eq!(report1.status, ReconciliationStatus::Converged);
|
|
|
|
// 2. Run 5 consecutive apply cycles: all must succeed with Converged status
|
|
for cycle in 2..=6 {
|
|
let report = reconciler.apply().await.unwrap();
|
|
assert!(report.success, "Cycle {cycle} must succeed");
|
|
assert_eq!(
|
|
report.status,
|
|
ReconciliationStatus::Converged,
|
|
"Cycle {cycle} must report Converged"
|
|
);
|
|
|
|
let plan = reconciler.plan().await.unwrap();
|
|
assert!(!plan.has_drift, "Cycle {cycle} plan must show zero drift");
|
|
}
|
|
}
|