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:
686
tests/e2e/test_enrollment_e2e.rs
Normal file
686
tests/e2e/test_enrollment_e2e.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
617
tests/integration/enrollment_test.rs
Normal file
617
tests/integration/enrollment_test.rs
Normal 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");
|
||||
}
|
||||
486
tests/unit/enroll_identity.rs
Normal file
486
tests/unit/enroll_identity.rs
Normal 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user