Private
Public Access
1
0

feat: add self-enrollment workflow for automated PKI provisioning

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

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

View File

@ -0,0 +1,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");
}