//! 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::http::Method; use wiremock::{ matchers::{method, path, path_regex}, Mock, MockServer, ResponseTemplate, }; /// 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. /// Uses a test report_ip so enrollment works inside Docker containers /// where the only IPs are in the 172.16.0.0/12 bridge range (filtered). fn build_client(base_url: &str) -> EnrollmentClient { EnrollmentClient::with_ip_overrides(base_url, None, Some("192.168.1.10".to_string())) } // ============================================================================= // Test 1: 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 == Method::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::().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" ); // Verify hostname field (optional, may be present or absent) // When present, it should be a non-empty string without dots (short hostname) if let Some(hostname) = payload.get("hostname").and_then(|v| v.as_str()) { assert!(!hostname.is_empty(), "hostname should not be empty when present"); assert!( !hostname.contains('.'), "hostname should be short form (no dots), got: {}", hostname ); } // hostname field is optional — its absence is valid (skip_serializing_if = None) } // ============================================================================= // 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"); }