Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 4s
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 1m15s
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 4s
Format all enrollment module source files and tests per rustfmt standards. Resolves Gitea CI workflow cargo fmt check failures.
763 lines
28 KiB
Rust
763 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.
|
|
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"
|
|
);
|
|
}
|