Private
Public Access
1
0
Files
linux_patch_api/tests/e2e/test_enrollment_e2e.rs
Echo 0d582f2fda
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Failing after 44s
CI/CD Pipeline / Enrollment Tests (push) Has been skipped
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Has been skipped
CI/CD Pipeline / All Unit Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Debian Package (push) Has been skipped
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Has been skipped
CI/CD Pipeline / Build RPM Package (push) Has been skipped
CI/CD Pipeline / Build Alpine Package (push) Has been skipped
CI/CD Pipeline / Build Arch Package (push) Has been skipped
CI/CD Pipeline / Security Audit (push) Successful in 5s
style: apply cargo fmt formatting
2026-05-18 02:06:25 +00:00

766 lines
28 KiB
Rust

//! 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::matchers::{method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// 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.
/// Uses a test report_ip so enrollment works inside Docker containers
/// where the only IPs are in the 172.16.0.0/12 bridge range (filtered).
fn build_client(base_url: &str) -> EnrollmentClient {
EnrollmentClient::with_ip_overrides(base_url, None, Some("192.168.1.10".to_string()))
}
// =============================================================================
// 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)
let remaining: Vec<_> = std::fs::read_dir(cert_dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.collect();
assert!(
remaining.is_empty(),
"No partial files should remain after graceful shutdown: {:?}",
remaining
);
}
// =============================================================================
// 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"
);
}