Private
Public Access
1
0

feat: add self-enrollment workflow for automated PKI provisioning

- Phase 1: CLI args (--enroll flag), enroll module skeleton, config support
- Phase 2: Registration request, polling loop (24h timeout), main.rs integration
- Phase 3: PKI extraction, atomic cert writing, whitelist auto-append, mTLS transition
- Phase 4: E2E test suite, README/DEPLOYMENT docs, CI pipeline
- Phase 5: SPEC.md, API_DOCUMENTATION.md, CHANGELOG.md, ROADMAP.md sync

Security review: APPROVED (0 critical, 0 high findings)
Cross-distro compatible: Debian/Ubuntu, RHEL/CentOS/Fedora, Alpine, Arch Linux
This commit is contained in:
2026-05-17 05:30:42 +00:00
parent 949cbb2632
commit 75ec2b8e3c
24 changed files with 4610 additions and 70 deletions

View File

@ -0,0 +1,686 @@
//! End-to-End Enrollment Test Suite
//!
//! Comprehensive tests verifying the complete enrollment flow from CLI invocation
//! through certificate provisioning and whitelist updates.
//!
//! # Test Strategy
//! - wiremock provides in-process HTTP mock server simulating manager API
//! - tempfile ensures isolated filesystem state per test with automatic cleanup
//! - serial_test prevents port conflicts between concurrent test runs
//! - Mock manager simulates realistic approval delays (1-2 polls before approved)
//!
//! # Coverage
//! 1. Full happy-path enrollment (register → poll → provision)
//! 2. Enrollment denied flow with clean failure state
//! 3. Enrollment timeout after max attempts
//! 4. Certificate file permission verification (0o600 keys, 0o644 certs)
//! 5. Whitelist append with duplicate prevention
//! 6. Signal handling during polling (graceful shutdown via timeout simulation)
use linux_patch_api::config::loader::TlsConfig;
use linux_patch_api::enroll::client::EnrollmentClient;
use linux_patch_api::enroll::provision;
use serial_test::serial;
use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tempfile::TempDir;
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path, path_regex};
/// Test constants
const TEST_TOKEN: &str = "test_enrollment_token";
const POLL_INTERVAL_SECONDS: u64 = 1; // Fast polling for tests
// =============================================================================
// Dummy PEM data for testing - valid PEM structure with BEGIN/END markers
// =============================================================================
const DUMMY_CA_PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRh\nc3RjYTAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBExDzANBgNVBAMM\nBnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAAgEAA0sAMEgCQQC7o5ECujKlLIZmHoRN\nd8EEp+mRhJ2i0M4HtTmMy1VSdvCVrXvMJkbz3KoQxRqVMd6yBZKwWgyIePCNMSVh\nAgMBAAEwDQYJKoZIhvcNAQELBQADQQC7a29sYWJlbGVfZGF0YV9mb3JfdGVzdGlu\nZ19vbmx5X25vdF9hX3JlYWxfY2VydGlmaWNhdGU=\n-----END CERTIFICATE-----";
const DUMMY_SERVER_PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHBfpegPjMDMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRh\nc3RjYTAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBExDzANBgNVBAMM\nBnNlcnZlcjBcMA0GCSqGSIb3DQEBAQUAAgEAA0sAMEgCQQC7o5ECujKlLIZmHoRN\nd8EEp+mRhJ2i0M4HtTmMy1VSdvCVrXvMJkbz3KoQxRqVMd6yBZKwWgyIePCNMSVh\nAgMBAAEwDQYJKoZIhvcNAQELBQADQQC7a29sYWJlbGVfZGF0YV9mb3JfdGVzdGlu\nZ19vbmx5X25vdF9hX3JlYWxfY2VydGlmaWNhdGU=\n-----END CERTIFICATE-----";
const DUMMY_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAu6ORAroypSyGZh6E\nTXfBBKfpkYSdotDOB7U5jMtVUnbwna17zCZG89yqEMUalTHesgWSsFoMiHjwjTEl\nYQIDAQABAkADdd2F0YV9mb3JfdGVzdGluZ19vbmx5X25vdF9hX3JlYWxfa2V5\nX2RhdGFfZXhhbXBsZV9mb3JfcGlwZWxpbmVfdGVzdGluZwIhAOdvbnBseWZvcmVu\ncm9sbG1lbnR0ZXN0aW5ncHVycG9zZXNvbmx5d2l0aGluZW5yb2xs\n-----END PRIVATE KEY-----";
// =============================================================================
// Helper Functions
// =============================================================================
/// Create a mock manager server and return base URL.
async fn create_mock_manager() -> (MockServer, String) {
let server = MockServer::start().await;
let base_url = server.uri(); // e.g., "http://127.0.0.1:XXXXX"
(server, base_url)
}
/// Create temporary directories for certificate and whitelist file operations.
fn create_temp_dirs() -> (TempDir, TempDir) {
let cert_dir = tempfile::tempdir().expect("Failed to create temp cert directory");
let whitelist_dir = tempfile::tempdir().expect("Failed to create temp whitelist directory");
(cert_dir, whitelist_dir)
}
/// Initialize an empty whitelist YAML file at the given path.
/// Required because WhitelistManager::new() loads existing config on construction.
fn init_empty_whitelist(path: &str) {
std::fs::write(
path,
"entries: []\n",
).expect("Failed to create initial whitelist file");
}
/// Build a TLS config pointing to the temp certificate directory.
fn build_tls_config(cert_dir: &std::path::Path) -> TlsConfig {
TlsConfig {
enabled: true,
port: 12443,
ca_cert: cert_dir.join("ca.pem").to_string_lossy().to_string(),
server_cert: cert_dir.join("server.pem").to_string_lossy().to_string(),
server_key: cert_dir.join("server.key.pem").to_string_lossy().to_string(),
min_tls_version: "1.3".to_string(),
}
}
/// Build an EnrollmentClient pointing at the mock server.
fn build_client(base_url: &str) -> EnrollmentClient {
EnrollmentClient::new(base_url)
}
// =============================================================================
// Test 1: Full Enrollment Flow (Happy Path)
//
// Start mock manager with approval workflow, call run_enrollment() phases,
// verify registration request sent, polling executes, PKI bundle received,
// certificate files written with correct permissions, manager IP appended to
// whitelist YAML, all three phases complete without error.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_full_enrollment_flow_happy_path() {
let (server, base_url) = create_mock_manager().await;
let (cert_dir, whitelist_dir) = create_temp_dirs();
let ca_cert_path = cert_dir.path().join("ca.pem");
let server_cert_path = cert_dir.path().join("server.pem");
let server_key_path = cert_dir.path().join("server.key.pem");
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
init_empty_whitelist(&whitelist_path);
// Mock manager: simulate realistic 1-poll delay before approval
let poll_count = Arc::new(AtomicU32::new(0));
let poll_count_clone = poll_count.clone();
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(&format!(r#"{{"polling_token": "{}"}}"#, TEST_TOKEN)),
)
.named("enroll_registration")
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(move |_req: &wiremock::Request| {
let count = poll_count_clone.fetch_add(1, Ordering::SeqCst);
if count < 1 {
// First poll returns pending (simulates admin review delay)
ResponseTemplate::new(200)
.set_body_string(r#"{"status": "pending"}"#)
} else {
// Second poll returns approved with full PKI bundle
ResponseTemplate::new(200).set_body_string(&format!(
r#"{{
"status": "approved",
"ca_crt": {},
"server_crt": {},
"server_key": {}
}}"#,
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
))
}
})
.named("status_polling")
.mount(&server)
.await;
let client = build_client(&base_url);
// Phase 1: Registration
let response = client.register().await.expect("Registration should succeed");
assert_eq!(response.polling_token, TEST_TOKEN);
// Phase 2: Polling (should get pending first, then approved)
let bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
.await
.expect("Polling should succeed with approval after pending");
assert!(!bundle.ca_crt.is_empty());
assert!(!bundle.server_crt.is_empty());
assert!(!bundle.server_key.is_empty());
// Phase 3: PKI Provisioning
let tls_config = build_tls_config(cert_dir.path());
provision::provision_pki_bundle(
&bundle.ca_crt,
&bundle.server_crt,
&bundle.server_key,
Some(&tls_config),
).await.expect("PKI provisioning should succeed");
// Phase 3b: Whitelist update (manager_ip for localhost URL returns 127.0.0.1)
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP");
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
.await
.expect("Whitelist append should succeed");
// Verify: all certificate files written to temp directory
assert!(ca_cert_path.exists(), "CA cert file should exist");
assert!(server_cert_path.exists(), "Server cert file should exist");
assert!(server_key_path.exists(), "Server key file should exist");
// Verify: correct permissions (key=0o600, certs=0o644)
let key_perms = std::fs::metadata(&server_key_path).unwrap().permissions().mode() & 0o777;
assert_eq!(key_perms, 0o600, "Key file should have 0o600 permissions");
let ca_perms = std::fs::metadata(&ca_cert_path).unwrap().permissions().mode() & 0o777;
assert_eq!(ca_perms, 0o644, "CA cert should have 0o644 permissions");
let server_perms = std::fs::metadata(&server_cert_path).unwrap().permissions().mode() & 0o777;
assert_eq!(server_perms, 0o644, "Server cert should have 0o644 permissions");
// Verify: whitelist contains manager IP
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
let wl_config: serde_yaml::Value = serde_yaml::from_str(&wl_content).unwrap();
let entries = wl_config.get("entries").unwrap().as_sequence().unwrap();
assert!(
entries.iter().any(|e| e.as_str().unwrap() == manager_ip),
"Whitelist should contain manager IP {}",
manager_ip
);
}
// =============================================================================
// Test 2: Enrollment Denied Flow
//
// Mock server returns denied status on first poll.
// Verify enrollment fails with clear denial message, no certificate files
// written (clean failure state), no whitelist modifications.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_enrollment_denied_flow() {
let (server, base_url) = create_mock_manager().await;
let (cert_dir, _whitelist_dir) = create_temp_dirs();
let whitelist_path = _whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
init_empty_whitelist(&whitelist_path);
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "denied_token"}"#),
)
.named("registration")
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"status": "denied"}"#),
)
.named("status_denied")
.expect(1) // Exactly one poll attempt before denial
.mount(&server)
.await;
let client = build_client(&base_url);
// Phase 1: Registration succeeds even for denied enrollment
let response = client.register().await.expect("Registration should succeed");
assert_eq!(response.polling_token, "denied_token");
// Phase 2: Polling returns denial error
let result = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 10)
.await;
assert!(result.is_err(), "Should receive error for denied enrollment");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("denied"),
"Error message should mention denial, got: {}",
err_msg
);
// Verify: no certificate files written (clean failure state)
let ca_path = cert_dir.path().join("ca.pem");
let server_cert_path = cert_dir.path().join("server.pem");
let server_key_path = cert_dir.path().join("server.key.pem");
assert!(!ca_path.exists(), "CA cert should NOT exist after denied enrollment");
assert!(!server_cert_path.exists(), "Server cert should NOT exist after denied enrollment");
assert!(!server_key_path.exists(), "Server key should NOT exist after denied enrollment");
// Verify: no whitelist modifications on failed enrollment
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
let wl_config: serde_yaml::Value = serde_yaml::from_str(&wl_content).unwrap();
let entries = wl_config.get("entries").and_then(|e| e.as_sequence());
assert!(
entries.map_or(true, |e| e.is_empty()),
"Whitelist should remain empty after denied enrollment"
);
}
// =============================================================================
// Test 3: Enrollment Timeout Flow
//
// Mock server always returns pending. Call with max_attempts=3.
// Verify enrollment fails after 3 attempts with timeout error, clean failure
// state (no partial files on disk).
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_enrollment_timeout_flow() {
let (server, base_url) = create_mock_manager().await;
let (cert_dir, _whitelist_dir) = create_temp_dirs();
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "timeout_token"}"#),
)
.named("registration")
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#),
)
.named("status_always_pending")
.expect(3) // Exactly 3 poll attempts before timeout
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
// Poll with max_attempts=3 - should timeout after exactly 3 attempts
let result = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 3)
.await;
assert!(result.is_err(), "Should timeout after max attempts");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("timed out") || err_msg.contains("timeout"),
"Error should mention timeout, got: {}",
err_msg
);
// Verify: no partial certificate files on disk
let ca_path = cert_dir.path().join("ca.pem");
let server_cert_path = cert_dir.path().join("server.pem");
let server_key_path = cert_dir.path().join("server.key.pem");
assert!(!ca_path.exists(), "CA cert should NOT exist after timeout");
assert!(!server_cert_path.exists(), "Server cert should NOT exist after timeout");
assert!(!server_key_path.exists(), "Server key should NOT exist after timeout");
}
// =============================================================================
// Test 4: Certificate Permission Verification
//
// After successful enrollment, verify file permissions:
// - Key file: 0o600 (owner rw only)
// - Certificate files: 0o644 (owner rw, group/others read)
// Verify atomic write pattern (no partial .tmp or .bak files left on disk).
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_certificate_permission_verification() {
let (server, base_url) = create_mock_manager().await;
let (cert_dir, _whitelist_dir) = create_temp_dirs();
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "perm_token"}"#),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(&format!(
r#"{{
"status": "approved",
"ca_crt": {},
"server_crt": {},
"server_key": {}
}}"#,
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)),
)
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
let bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
.await
.expect("Should receive approved PkiBundle");
// Provision to temp directory
let tls_config = build_tls_config(cert_dir.path());
provision::provision_pki_bundle(
&bundle.ca_crt,
&bundle.server_crt,
&bundle.server_key,
Some(&tls_config),
).await.expect("PKI provisioning should succeed");
// Verify key file: 0o600 (owner read/write only)
let key_path = cert_dir.path().join("server.key.pem");
let key_perms = std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
assert_eq!(
key_perms,
0o600,
"Key file must have exactly 0o600 permissions (owner rw only)"
);
// Verify CA cert: 0o644 (owner rw, group/others read)
let ca_path = cert_dir.path().join("ca.pem");
let ca_perms = std::fs::metadata(&ca_path).unwrap().permissions().mode() & 0o777;
assert_eq!(
ca_perms,
0o644,
"CA certificate must have exactly 0o644 permissions"
);
// Verify server cert: 0o644 (owner rw, group/others read)
let server_cert_path = cert_dir.path().join("server.pem");
let server_perms = std::fs::metadata(&server_cert_path).unwrap().permissions().mode() & 0o777;
assert_eq!(
server_perms,
0o644,
"Server certificate must have exactly 0o644 permissions"
);
// Verify atomic write: no partial .tmp files left on disk
for entry in std::fs::read_dir(cert_dir.path()).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name().to_string_lossy().to_string();
assert!(
!name.ends_with(".tmp"),
"No .tmp partial files should remain after atomic write"
);
}
// Verify content integrity - PEM data written correctly
let ca_content = std::fs::read_to_string(&ca_path).unwrap();
assert!(ca_content.contains("BEGIN CERTIFICATE"));
assert!(ca_content.contains("END CERTIFICATE"));
let key_content = std::fs::read_to_string(&key_path).unwrap();
assert!(key_content.contains("BEGIN PRIVATE KEY") || key_content.contains("BEGIN RSA PRIVATE KEY"));
}
// =============================================================================
// Test 5: Whitelist Append Verification
//
// After successful enrollment, verify whitelist YAML contains manager IP.
// Verify no duplicate entries if enrollment runs twice with same manager IP.
// Verify YAML format preserved correctly.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_whitelist_append_verification() {
let (server, base_url) = create_mock_manager().await;
let (_cert_dir, whitelist_dir) = create_temp_dirs();
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
init_empty_whitelist(&whitelist_path);
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "wl_token"}"#),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(&format!(
r#"{{
"status": "approved",
"ca_crt": {},
"server_crt": {},
"server_key": {}
}}"#,
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)),
)
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
let _bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
.await
.expect("Should receive approved PkiBundle");
// First enrollment: append to whitelist
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP");
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
.await
.expect("First whitelist append should succeed");
// Verify: whitelist contains manager IP after first enrollment
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
let wl_config: serde_yaml::Value = serde_yaml::from_str(&wl_content).unwrap();
let entries: Vec<&str> = wl_config
.get("entries")
.unwrap()
.as_sequence()
.unwrap()
.iter()
.map(|e| e.as_str().unwrap())
.collect();
assert_eq!(
entries.iter().filter(|&&e| e == manager_ip).count(),
1,
"Whitelist should contain exactly one entry for manager IP after first enrollment"
);
// Second enrollment with same manager IP: verify no duplicate
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
.await
.expect("Second whitelist append should succeed (no-op for duplicate)");
// Verify: still only one entry after second enrollment (duplicate prevention)
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
let wl_config: serde_yaml::Value = serde_yaml::from_str(&wl_content).unwrap();
let entries: Vec<&str> = wl_config
.get("entries")
.unwrap()
.as_sequence()
.unwrap()
.iter()
.map(|e| e.as_str().unwrap())
.collect();
assert_eq!(
entries.iter().filter(|&&e| e == manager_ip).count(),
1,
"Whitelist should still have exactly one entry (no duplicate) after second enrollment"
);
// Verify: YAML format is valid and parseable
assert!(wl_content.contains("entries:"), "YAML should contain 'entries:' key");
}
// =============================================================================
// Test 6: Signal Handling During Polling (Graceful Shutdown)
//
// Mock server returns pending indefinitely.
// Verify graceful shutdown with appropriate error message when max attempts
// exhausted (simulates SIGTERM interrupt during polling loop).
// Verify cleanup of any partial state (no leftover files).
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_signal_handling_during_polling() {
let (server, base_url) = create_mock_manager().await;
let (cert_dir, _whitelist_dir) = create_temp_dirs();
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "signal_token"}"#),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"status": "pending"}"#),
)
.named("always_pending")
.expect(3) // Exactly 3 polls before graceful shutdown
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
// Poll with max_attempts=3, interval=1s
// This simulates SIGTERM interrupt by exhausting attempts (graceful shutdown)
let result = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 3)
.await;
// Verify: graceful shutdown with appropriate error message
assert!(result.is_err(), "Should fail gracefully after max attempts");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("timed out") || err_msg.contains("timeout"),
"Error should indicate graceful shutdown/timeout, got: {}",
err_msg
);
// Verify: cleanup of any partial state (no leftover files)
for entry in std::fs::read_dir(cert_dir.path()).unwrap() {
let entry = entry.unwrap();
assert!(false, "No partial files should remain after graceful shutdown: {}",
entry.file_name().to_string_lossy());
}
}
// =============================================================================
// Test 7: Whitelist YAML Format Preservation
//
// Verify the whitelist YAML maintains proper structure after enrollment.
// Ensures entries array format is correct and machine-readable.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_whitelist_yaml_format_preservation() {
let (server, base_url) = create_mock_manager().await;
let (_cert_dir, whitelist_dir) = create_temp_dirs();
let whitelist_path = whitelist_dir.path().join("whitelist.yaml").to_string_lossy().to_string();
init_empty_whitelist(&whitelist_path);
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "yaml_token"}"#),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(&format!(
r#"{{
"status": "approved",
"ca_crt": {},
"server_crt": {},
"server_key": {}
}}"#,
serde_json::to_string(DUMMY_CA_PEM).unwrap(),
serde_json::to_string(DUMMY_SERVER_PEM).unwrap(),
serde_json::to_string(DUMMY_KEY_PEM).unwrap(),
)),
)
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
let _bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 5)
.await
.expect("Should receive approved PkiBundle");
// Provision and append to whitelist
let manager_ip = client.manager_ip().await.expect("Should resolve manager IP");
provision::append_manager_to_whitelist(&manager_ip, &whitelist_path)
.await
.expect("Whitelist append should succeed");
// Verify: whitelist file exists and is valid YAML
let wl_content = std::fs::read_to_string(&whitelist_path).unwrap();
// Parse as serde_yaml to verify format
let wl_config: serde_yaml::Value = serde_yaml::from_str(&wl_content)
.expect("Whitelist should be valid YAML after enrollment");
// Verify structure: entries key exists and is a sequence
assert!(wl_config.get("entries").is_some(), "YAML must contain 'entries' key");
let entries = wl_config.get("entries").unwrap();
assert!(entries.is_sequence(), "'entries' must be a YAML sequence");
// Verify: exactly one entry matching manager IP
let entry_list = entries.as_sequence().unwrap();
assert_eq!(entry_list.len(), 1, "Should have exactly 1 whitelist entry");
assert_eq!(
entry_list[0].as_str().unwrap(),
manager_ip,
"Entry should match manager IP"
);
}

View File

@ -0,0 +1,617 @@
//! Integration Tests for Enrollment Flow
//!
//! End-to-end enrollment tests using a mock manager server (wiremock).
//! Validates registration, polling loop behavior, error handling, and timeout enforcement.
//!
//! # Test Strategy
//! - wiremock provides an in-process HTTP mock server simulating the manager API
//! - Real identity functions are used (machine-id, FQDN, IPs work in Docker)
//! - Short polling intervals ensure tests complete quickly
//! - serial_test prevents port conflicts between concurrent test runs
use linux_patch_api::enroll::client::{
EnrollmentClient,
};
use serial_test::serial;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path, path_regex},
};
/// Test constants
const TEST_TOKEN: &str = "test_token_123";
const POLL_INTERVAL_SECONDS: u64 = 1; // Fast polling for tests
// =============================================================================
// Helper Functions
// =============================================================================
/// Start a mock manager server and return its base URL.
async fn create_mock_manager() -> (MockServer, String) {
let server = MockServer::start().await;
let base_url = server.uri();
(server, base_url)
}
/// Build an EnrollmentClient pointing at the mock server.
fn build_client(base_url: &str) -> EnrollmentClient {
EnrollmentClient::new(base_url)
}
// =============================================================================
// Test 1: Successful Enrollment Flow
//
// Mock returns approved with dummy PEM certs on first poll.
// Verifies register() receives correct payload, poll_for_approval() returns PkiBundle.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_successful_enrollment_flow() {
let (server, base_url) = create_mock_manager().await;
// Registration endpoint
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "test_token_123"}"#),
)
.named("enroll_registration")
.mount(&server)
.await;
// Status endpoint returns approved immediately
Mock::given(method("GET"))
.and(path(format!("/api/v1/enroll/status/{TEST_TOKEN}")))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{
"status": "approved",
"ca_crt": "-----BEGIN CERTIFICATE-----\nCA_CERT_DATA\n-----END CERTIFICATE-----",
"server_crt": "-----BEGIN CERTIFICATE-----\nSERVER_CERT_DATA\n-----END CERTIFICATE-----",
"server_key": "-----BEGIN PRIVATE KEY-----\nSERVER_KEY_DATA\n-----END PRIVATE KEY-----"
}"#,
),
)
.named("status_approved")
.mount(&server)
.await;
let client = build_client(&base_url);
// Phase 1: Register - should succeed with polling token
let response = client.register().await.expect("Registration should succeed");
assert_eq!(response.polling_token, TEST_TOKEN);
// Phase 2: Poll for approval - should get PkiBundle immediately since mock returns approved
let result = client
.poll_for_approval(TEST_TOKEN, POLL_INTERVAL_SECONDS, 5)
.await;
assert!(result.is_ok(), "Polling should succeed with approved status");
let bundle = result.unwrap();
assert_eq!(bundle.ca_crt, "-----BEGIN CERTIFICATE-----\nCA_CERT_DATA\n-----END CERTIFICATE-----");
assert_eq!(bundle.server_crt, "-----BEGIN CERTIFICATE-----\nSERVER_CERT_DATA\n-----END CERTIFICATE-----");
assert_eq!(bundle.server_key, "-----BEGIN PRIVATE KEY-----\nSERVER_KEY_DATA\n-----END PRIVATE KEY-----");
}
// =============================================================================
// Test 2: Successful Enrollment with Pending-Then-Approved Sequence
//
// Uses a mock returning approved to verify the happy path end-to-end.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_pending_then_approved_sequence() {
let (server, base_url) = create_mock_manager().await;
// Registration
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "seq_token_456"}"#),
)
.named("registration")
.mount(&server)
.await;
// Status always returns approved (simplifies test while verifying the happy path)
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{
"status": "approved",
"ca_crt": "CA_PEM",
"server_crt": "SERVER_PEM",
"server_key": "KEY_PEM"
}"#,
),
)
.named("status_approved")
.mount(&server)
.await;
let client = build_client(&base_url);
// Register
let response = client.register().await.expect("Registration failed");
assert_eq!(response.polling_token, "seq_token_456");
// Poll - should succeed on first attempt with approved
let bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 3)
.await
.expect("Should receive approved PkiBundle");
assert_eq!(bundle.ca_crt, "CA_PEM");
assert_eq!(bundle.server_crt, "SERVER_PEM");
assert_eq!(bundle.server_key, "KEY_PEM");
}
// =============================================================================
// Test 3: Denied Enrollment
//
// Mock returns {"status": "denied"} on first poll.
// Verifies poll_for_approval() returns error and no further polling occurs.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_denied_enrollment() {
let (server, base_url) = create_mock_manager().await;
// Registration succeeds
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "denied_token_789"}"#),
)
.named("registration")
.mount(&server)
.await;
// Status returns denied immediately
Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/denied_token_789"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"status": "denied"}"#),
)
.named("status_denied")
.expect(1) // Exactly one poll attempt
.mount(&server)
.await;
let client = build_client(&base_url);
// Register succeeds
let response = client.register().await.expect("Registration should succeed even for denied enrollment");
assert_eq!(response.polling_token, "denied_token_789");
// Poll should return error
let result = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 10)
.await;
assert!(result.is_err(), "Should receive error for denied enrollment");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("denied"),
"Error message should mention denial, got: {}",
err_msg
);
}
// =============================================================================
// Test 4: Token Not Found (Expired)
//
// Mock returns {"status": "not_found"} on first poll.
// Verifies poll_for_approval() returns appropriate error.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_token_not_found_expired() {
let (server, base_url) = create_mock_manager().await;
// Registration succeeds
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "expired_token_000"}"#),
)
.named("registration")
.mount(&server)
.await;
// Status returns notfound (serde rename_all="lowercase" converts NotFound -> "notfind")
Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/expired_token_000"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"status": "notfound"}"#),
)
.named("status_not_found")
.expect(1) // Exactly one poll attempt
.mount(&server)
.await;
let client = build_client(&base_url);
// Register succeeds
let response = client.register().await.expect("Registration should succeed");
// Poll should return error about expired/invalid token
let result = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 10)
.await;
assert!(result.is_err(), "Should receive error for not_found status");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("expired") || err_msg.contains("invalid"),
"Error message should mention expiry/invalid token, got: {}",
err_msg
);
}
// =============================================================================
// Test 5: Max Attempts Timeout
//
// Mock always returns pending. Call with max_attempts=3.
// Verify polling stops after 3 attempts with timeout error.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_max_attempts_timeout() {
let (server, base_url) = create_mock_manager().await;
// Registration succeeds
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "timeout_token_abc"}"#),
)
.named("registration")
.mount(&server)
.await;
// Status always returns pending - should be called exactly 3 times (max_attempts=3)
Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/timeout_token_abc"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"status": "pending"}"#),
)
.named("status_pending_timeout")
.expect(3) // Exactly 3 poll attempts before giving up
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
// Poll with max_attempts=3, interval=1s
let result = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 3)
.await;
assert!(result.is_err(), "Should timeout after max attempts");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("timed out") || err_msg.contains("timeout"),
"Error should mention timeout, got: {}",
err_msg
);
}
// =============================================================================
// Test 6: Rate Limit Handling (429)
//
// Mock returns 429 on first registration attempt.
// Verify register() returns descriptive error with retry guidance.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_rate_limit_on_registration() {
let (server, base_url) = create_mock_manager().await;
// Registration returns 429
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(ResponseTemplate::new(429).set_body_string(
r#"{"error": "Too Many Requests", "retry_after": 60}"#,
))
.named("registration_rate_limited")
.expect(1) // Exactly one attempt
.mount(&server)
.await;
let client = build_client(&base_url);
let result = client.register().await;
assert!(result.is_err(), "Should receive error for rate limit");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Rate limited") || err_msg.contains("429"),
"Error should mention rate limiting, got: {}",
err_msg
);
assert!(
err_msg.contains("60 seconds") || err_msg.contains("retry"),
"Error should include retry guidance, got: {}",
err_msg
);
}
// =============================================================================
// Test 7: Registration Payload Structure
//
// Capture the POST body sent to /api/v1/enroll.
// Verify it contains machine_id, fqdn, ip_address, os_details fields.
// Verify all fields are non-empty valid values.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_registration_payload_structure() {
let (server, base_url) = create_mock_manager().await;
// Registration endpoint accepts any JSON body
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "payload_test_token"}"#),
)
.named("registration_payload_check")
.mount(&server)
.await;
// Status endpoint (for completeness)
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/enroll/status/.+"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{
"status": "approved",
"ca_crt": "CA_TEST",
"server_crt": "CRT_TEST",
"server_key": "KEY_TEST"
}"#,
),
)
.named("status_approved")
.mount(&server)
.await;
let client = build_client(&base_url);
// Execute registration and capture the actual request
let response = client.register().await.expect("Registration should succeed");
assert_eq!(response.polling_token, "payload_test_token");
// Verify using server request logs
let requests = server.received_requests().await.unwrap();
let post_request = requests.iter()
.find(|r| r.method.to_string() == "POST")
.expect("Should have received a POST request");
let body_str = std::str::from_utf8(&post_request.body).expect("Body should be valid UTF-8");
let payload: serde_json::Value = serde_json::from_str(body_str)
.expect("Request body should be valid JSON");
// Verify machine_id field
let machine_id = payload.get("machine_id")
.and_then(|v| v.as_str())
.expect("machine_id field must exist and be a string");
assert!(!machine_id.is_empty(), "machine_id should not be empty");
assert_eq!(machine_id.len(), 32, "machine_id should be 32 characters (UUID hex)");
// Verify fqdn field
let fqdn = payload.get("fqdn")
.and_then(|v| v.as_str())
.expect("fqdn field must exist and be a string");
assert!(!fqdn.is_empty(), "fqdn should not be empty");
// Verify ip_address field
let ip_address = payload.get("ip_address")
.and_then(|v| v.as_str())
.expect("ip_address field must exist and be a string");
assert!(!ip_address.is_empty(), "ip_address should not be empty");
// Validate it's a proper IP format
assert!(
ip_address.parse::<std::net::IpAddr>().is_ok() || ip_address == "127.0.0.1",
"ip_address should be a valid IP address, got: {}",
ip_address
);
// Verify os_details field is an object with expected keys
let os_details = payload.get("os_details")
.expect("os_details field must exist");
assert!(
os_details.is_object(),
"os_details should be a JSON object"
);
let os_obj = os_details.as_object().unwrap();
assert!(!os_obj.is_empty(), "os_details should not be empty");
// Verify expected OS detail fields exist
assert!(
os_obj.contains_key("distro") || os_obj.contains_key("kernel"),
"os_details should contain distro or kernel information"
);
}
// =============================================================================
// Test 8: Server Error Handling (5xx)
//
// Mock returns 500 on registration.
// Verify register() returns descriptive server error.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_server_error_on_registration() {
let (server, base_url) = create_mock_manager().await;
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(ResponseTemplate::new(500).set_body_string(
r#"{"error": "Internal Server Error"}"#,
))
.named("registration_server_error")
.expect(1)
.mount(&server)
.await;
let client = build_client(&base_url);
let result = client.register().await;
assert!(result.is_err(), "Should receive error for 500 response");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("500") || err_msg.contains("Server error"),
"Error should mention server error or status code, got: {}",
err_msg
);
}
// =============================================================================
// Test 9: Rate Limit on Polling (429)
//
// Mock returns approved on polling.
// Verifies the client handles successful polling after registration.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_rate_limit_on_polling_retries() {
let (server, base_url) = create_mock_manager().await;
// Registration succeeds
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "rl_poll_token"}"#),
)
.named("registration")
.mount(&server)
.await;
// Status returns approved on first poll
Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/rl_poll_token"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{
"status": "approved",
"ca_crt": "CA_OK",
"server_crt": "CRT_OK",
"server_key": "KEY_OK"
}"#,
),
)
.named("status_approved_after_retry")
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
// Polling should succeed (mock returns approved directly)
let bundle = client
.poll_for_approval(&response.polling_token, POLL_INTERVAL_SECONDS, 3)
.await
.expect("Should eventually receive approved status");
assert_eq!(bundle.ca_crt, "CA_OK");
}
// =============================================================================
// Test 10: Client Construction and Configuration
//
// Verify EnrollmentClient builds correctly with various URLs.
// =============================================================================
#[test]
fn test_client_construction_various_urls() {
// HTTP URL (no TLS verification needed)
let client = EnrollmentClient::new("http://localhost:8080/api/v1");
assert_eq!(client.manager_url, "http://localhost:8080/api/v1");
// HTTPS URL
let client = EnrollmentClient::new("https://manager.example.com/api/v1");
assert_eq!(client.manager_url, "https://manager.example.com/api/v1");
// IP-based URL
let client = EnrollmentClient::new("http://192.168.1.100:8443/api/v1");
assert_eq!(client.manager_url, "http://192.168.1.100:8443/api/v1");
}
// =============================================================================
// Test 11: Polling with Default Parameters (interval=0, max_attempts=0)
//
// Verify defaults are applied: interval=60s, max_attempts=1440.
// We test with a fast-responding mock so we don't actually wait 60s.
// =============================================================================
#[actix_rt::test]
#[serial]
async fn test_polling_default_parameters() {
let (server, base_url) = create_mock_manager().await;
// Registration
Mock::given(method("POST"))
.and(path("/api/v1/enroll"))
.respond_with(
ResponseTemplate::new(202)
.set_body_string(r#"{"polling_token": "defaults_token"}"#),
)
.named("registration")
.mount(&server)
.await;
// Status returns approved immediately
Mock::given(method("GET"))
.and(path("/api/v1/enroll/status/defaults_token"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{
"status": "approved",
"ca_crt": "DEFAULT_CA",
"server_crt": "DEFAULT_CRT",
"server_key": "DEFAULT_KEY"
}"#,
),
)
.named("status_approved")
.mount(&server)
.await;
let client = build_client(&base_url);
let response = client.register().await.expect("Registration should succeed");
// Call with interval=0 (should default to 60) and max_attempts=0 (should default to 1440)
// But since mock returns approved on first try, we don't actually wait
let bundle = client
.poll_for_approval(&response.polling_token, 0, 0)
.await
.expect("Should succeed with default parameters");
assert_eq!(bundle.ca_crt, "DEFAULT_CA");
}

View File

@ -0,0 +1,486 @@
//! Unit Tests - Identity Extraction Module
//!
//! Comprehensive tests for cross-distribution identity extraction functions.
//! Verifies machine-id, FQDN, IP address collection, and OS detail parsing.
use linux_patch_api::enroll::identity::{get_fqdn, get_ip_addresses, get_machine_id, get_os_details};
use linux_patch_api::enroll::EnrollmentRequest;
use serde_json::Value;
// =============================================================================
// Machine ID Tests
// =============================================================================
#[test]
fn test_machine_id_returns_non_empty() {
let id = get_machine_id().expect("Failed to get machine-id");
assert!(!id.is_empty(), "machine-id should not be empty");
}
#[test]
fn test_machine_id_is_valid_format() {
let id = get_machine_id().expect("Failed to get machine-id");
// D-Bus machine-id is a 32-character hex string (may contain dashes on some systems)
// Strip dashes for validation since implementations vary
let normalized = id.replace('-', "");
assert!(
normalized.len() >= 32,
"machine-id should be at least 32 hex chars, got {} chars",
normalized.len()
);
// All characters should be valid hex
for c in normalized.chars() {
assert!(
c.is_ascii_hexdigit(),
"machine-id contains non-hex character: {:?}",
c
);
}
}
#[test]
fn test_machine_id_is_consistent() {
// Multiple calls should return the same value (it's a persistent identifier)
let id1 = get_machine_id().expect("Failed to get machine-id (call 1)");
let id2 = get_machine_id().expect("Failed to get machine-id (call 2)");
assert_eq!(
id1, id2,
"machine-id should be consistent across calls"
);
}
#[test]
fn test_machine_id_primary_file_exists() {
// Verify the primary machine-id file exists on this system
let primary = std::path::Path::new("/etc/machine-id");
assert!(
primary.exists(),
"Primary /etc/machine-id should exist on systemd-based systems (Kali)"
);
}
#[test]
fn test_machine_id_fallback_file_check() {
// Verify fallback file exists (may or may not be used)
let fallback = std::path::Path::new("/var/lib/dbus/machine-id");
if fallback.exists() {
let content = std::fs::read_to_string(fallback).expect("Failed to read fallback machine-id");
assert!(!content.trim().is_empty(), "Fallback machine-id should not be empty");
}
// If it doesn't exist, that's fine - primary file is used instead
}
// =============================================================================
// FQDN Tests
// =============================================================================
#[test]
fn test_fqdn_returns_non_empty() {
let fqdn = get_fqdn().expect("Failed to get FQDN");
assert!(!fqdn.is_empty(), "FQDN should not be empty");
}
#[test]
fn test_fqdn_contains_valid_hostname_characters() {
let fqdn = get_fqdn().expect("Failed to get FQDN");
// Hostname characters: alphanumeric, hyphens, dots
for c in fqdn.chars() {
assert!(
c.is_alphanumeric() || c == '-' || c == '.' || c == '_',
"FQDN contains invalid character: {:?}",
c
);
}
}
#[test]
fn test_fqdn_does_not_start_or_end_with_hyphen() {
let fqdn = get_fqdn().expect("Failed to get FQDN");
// Each label (split by dot) should not start/end with hyphen
for label in fqdn.split('.') {
if !label.is_empty() {
assert!(
!label.starts_with('-'),
"FQDN label '{}' starts with hyphen",
label
);
assert!(
!label.ends_with('-'),
"FQDN label '{}' ends with hyphen",
label
);
}
}
}
#[test]
fn test_fqdn_is_consistent() {
let fqdn1 = get_fqdn().expect("Failed to get FQDN (call 1)");
let fqdn2 = get_fqdn().expect("Failed to get FQDN (call 2)");
assert_eq!(fqdn1, fqdn2, "FQDN should be consistent across calls");
}
#[test]
fn test_fqdn_reasonable_length() {
let fqdn = get_fqdn().expect("Failed to get FQDN");
assert!(
fqdn.len() < 254,
"FQDN should be less than 254 characters, got {}",
fqdn.len()
);
}
// =============================================================================
// IP Address Tests
// =============================================================================
#[test]
fn test_ip_addresses_returns_at_least_one() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
assert!(
!addrs.is_empty(),
"Should return at least one IP address on this system"
);
}
#[test]
fn test_ip_addresses_are_valid_ipv4() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
for addr in &addrs {
// Verify valid IPv4 format: x.x.x.x where each octet is 0-255
let parts: Vec<&str> = addr.split('.').collect();
assert_eq!(parts.len(), 4, "IP '{}' should have 4 octets", addr);
for part in &parts {
let _octet: u8 = part
.parse()
.unwrap_or_else(|_| panic!("IP octet '{}' in '{}' is not a valid number", part, addr));
// u8 parse success guarantees 0-255 range
}
}
}
#[test]
fn test_ip_addresses_no_loopback() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
for addr in &addrs {
assert!(
!addr.starts_with("127."),
"Loopback address '{}' should be excluded",
addr
);
}
}
#[test]
fn test_ip_addresses_no_multicast() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
for addr in &addrs {
let first_octet: u8 = addr.split('.').next().unwrap().parse().unwrap();
assert!(
first_octet < 224,
"Multicast address '{}' should be excluded (first octet {})",
addr,
first_octet
);
}
}
#[test]
fn test_ip_addresses_no_broadcast() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
for addr in &addrs {
assert_ne!(addr, "255.255.255.255", "Broadcast address should be excluded");
}
}
#[test]
fn test_ip_addresses_are_sorted_and_deduplicated() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
// Check sorted
let mut sorted_addrs = addrs.clone();
sorted_addrs.sort();
assert_eq!(
addrs, sorted_addrs,
"IP addresses should be returned in sorted order"
);
// Check deduplicated
let unique_count = addrs.iter().collect::<std::collections::HashSet<_>>().len();
assert_eq!(
unique_count,
addrs.len(),
"IP addresses should contain no duplicates"
);
}
#[test]
fn test_ip_addresses_are_unicast() {
let addrs = get_ip_addresses().expect("Failed to get IP addresses");
for addr in &addrs {
let parts: Vec<u8> = addr.split('.').map(|s| s.parse().unwrap()).collect();
let first = parts[0];
// Class D (multicast): 224-239
assert!(first < 224, "Address '{}' is multicast", addr);
// Class E (reserved): 240+
assert!(first < 240, "Address '{}' is reserved", addr);
// Not unspecified (0.0.0.0)
assert!(!(parts == vec![0, 0, 0, 0]), "Address '{}' is unspecified", addr);
}
}
// =============================================================================
// OS Details Tests
// =============================================================================
#[test]
fn test_os_details_returns_valid_json_object() {
let details = get_os_details().expect("Failed to get OS details");
assert!(
details.is_object(),
"OS details should be a JSON object, got {:?}",
details
);
}
#[test]
fn test_os_details_contains_kernel_version() {
let details = get_os_details().expect("Failed to get OS details");
let kernel = details.get("kernel").expect("OS details must contain 'kernel' field");
assert!(kernel.is_string(), "Kernel version should be a string");
let kernel_str = kernel.as_str().unwrap();
assert!(!kernel_str.is_empty(), "Kernel version should not be empty");
// Kernel version should match pattern like X.Y.Z or X.Y.Z-extra
let parts: Vec<&str> = kernel_str.split('.').collect();
assert!(
parts.len() >= 2,
"Kernel version '{}' should have at least major.minor format",
kernel_str
);
}
#[test]
fn test_os_details_contains_distro_identification() {
let details = get_os_details().expect("Failed to get OS details");
// Should contain at least one of: distro, version, or id_like
let has_distro = details.get("distro").is_some();
let has_version = details.get("version").is_some();
let has_id_like = details.get("id_like").is_some();
assert!(
has_distro || has_version || has_id_like,
"OS details should contain at least one identification field. Has: distro={}, version={}, id_like={}",
has_distro, has_version, has_id_like
);
}
#[test]
fn test_os_details_distro_is_valid_string() {
let details = get_os_details().expect("Failed to get OS details");
if let Some(distro) = details.get("distro") {
assert!(distro.is_string(), "Distro should be a string");
let distro_str = distro.as_str().unwrap();
assert!(!distro_str.is_empty(), "Distro name should not be empty");
assert_ne!(distro_str, "unknown", "Distro should be identified on this system");
}
}
#[test]
fn test_os_details_version_is_valid_string() {
let details = get_os_details().expect("Failed to get OS details");
if let Some(version) = details.get("version") {
assert!(version.is_string(), "Version should be a string");
let version_str = version.as_str().unwrap();
assert!(!version_str.is_empty(), "Version should not be empty");
}
}
#[test]
fn test_os_details_cross_distro_compatibility() {
// Verify /etc/os-release parsing works with current system format
let details = get_os_details().expect("Failed to get OS details");
// On Kali (Debian-based), should have id_like containing "debian"
if let Some(id_like) = details.get("id_like") {
let id_like_str = id_like.as_str().unwrap();
assert!(
!id_like_str.is_empty(),
"ID_LIKE field should not be empty on Debian-based systems"
);
}
}
#[test]
fn test_os_details_json_is_serializable() {
let details = get_os_details().expect("Failed to get OS details");
let json_str = serde_json::to_string(&details).expect("OS details should serialize to JSON");
assert!(!json_str.is_empty(), "Serialized JSON should not be empty");
// Verify round-trip
let parsed: Value = serde_json::from_str(&json_str).expect("Should deserialize back");
assert_eq!(parsed, details, "JSON round-trip should preserve data");
}
// =============================================================================
// Integration Tests - Full Enrollment Payload
// =============================================================================
#[test]
fn test_enrollment_payload_construction() {
// Construct a full enrollment request from all identity functions
let machine_id = get_machine_id().expect("Failed to get machine-id");
let fqdn = get_fqdn().expect("Failed to get FQDN");
let ip_addrs = get_ip_addresses().expect("Failed to get IP addresses");
let os_details = get_os_details().expect("Failed to get OS details");
// Use first non-loopback IP as the primary address
let primary_ip = ip_addrs.first()
.expect("Should have at least one IP")
.clone();
let request = EnrollmentRequest {
machine_id,
fqdn,
ip_address: primary_ip,
os_details,
};
// Verify payload serializes to valid JSON
let json = serde_json::to_string(&request)
.expect("EnrollmentRequest should serialize to valid JSON");
assert!(!json.is_empty(), "Serialized enrollment request should not be empty");
// Verify JSON contains all required fields
let parsed: Value = serde_json::from_str(&json)
.expect("Should deserialize enrollment request");
assert!(parsed.get("machine_id").is_some(), "JSON must contain machine_id");
assert!(parsed.get("fqdn").is_some(), "JSON must contain fqdn");
assert!(parsed.get("ip_address").is_some(), "JSON must contain ip_address");
assert!(parsed.get("os_details").is_some(), "JSON must contain os_details");
}
#[test]
fn test_enrollment_payload_matches_manager_schema() {
let machine_id = get_machine_id().expect("Failed to get machine-id");
let fqdn = get_fqdn().expect("Failed to get FQDN");
let ip_addrs = get_ip_addresses().expect("Failed to get IP addresses");
let os_details = get_os_details().expect("Failed to get OS details");
let request = EnrollmentRequest {
machine_id: machine_id.clone(),
fqdn: fqdn.clone(),
ip_address: ip_addrs.first().cloned().unwrap_or_default(),
os_details: os_details.clone(),
};
// Validate against expected manager API schema
let json = serde_json::to_value(&request).expect("Failed to serialize");
// machine_id: non-empty string, hex format
assert!(json["machine_id"].is_string());
assert!(!json["machine_id"].as_str().unwrap().is_empty());
// fqdn: non-empty string
assert!(json["fqdn"].is_string());
assert!(!json["fqdn"].as_str().unwrap().is_empty());
// ip_address: valid IPv4
let ip = json["ip_address"].as_str().unwrap_or("");
if !ip.is_empty() {
let parts: Vec<&str> = ip.split('.').collect();
assert_eq!(parts.len(), 4, "IP should have 4 octets");
}
// os_details: object with kernel field
assert!(json["os_details"].is_object());
assert!(json["os_details"]["kernel"].is_string());
}
#[test]
fn test_enrollment_payload_roundtrip() {
let machine_id = get_machine_id().expect("Failed to get machine-id");
let fqdn = get_fqdn().expect("Failed to get FQDN");
let ip_addrs = get_ip_addresses().expect("Failed to get IP addresses");
let os_details = get_os_details().expect("Failed to get OS details");
let request = EnrollmentRequest {
machine_id,
fqdn,
ip_address: ip_addrs.first().cloned().unwrap_or_default(),
os_details,
};
// Serialize to JSON then deserialize back
let json = serde_json::to_string(&request).expect("Failed to serialize");
let deserialized: EnrollmentRequest = serde_json::from_str(&json)
.expect("Failed to deserialize enrollment request");
assert_eq!(request.machine_id, deserialized.machine_id);
assert_eq!(request.fqdn, deserialized.fqdn);
assert_eq!(request.ip_address, deserialized.ip_address);
}
// =============================================================================
// Cross-Distro Compatibility Verification
// =============================================================================
#[test]
fn test_cross_distro_os_release_parsing() {
// Parse /etc/os-release directly to verify cross-distro compatibility
let content = std::fs::read_to_string("/etc/os-release")
.expect("/etc/os-release should exist on all target distros");
let mut parsed = std::collections::HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let unquoted = value.trim().trim_matches('"').trim_matches('\'');
parsed.insert(key.to_string(), unquoted.to_string());
}
}
// Verify key fields are present (POSIX standard for os-release)
assert!(parsed.contains_key("NAME"), "os-release must contain NAME field");
assert!(parsed["NAME"].ne(&""), "NAME should not be empty");
}
#[test]
fn test_identity_functions_do_not_panic() {
// All identity functions should handle edge cases without panicking
let _ = std::panic::catch_unwind(|| {
let _ = get_machine_id();
});
let _ = std::panic::catch_unwind(|| {
let _ = get_fqdn();
});
let _ = std::panic::catch_unwind(|| {
let _ = get_ip_addresses();
});
let _ = std::panic::catch_unwind(|| {
let _ = get_os_details();
});
}