diff --git a/Cargo.lock b/Cargo.lock index 8499da0..edfdfae 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2686,6 +2686,7 @@ dependencies = [ "serde_json", "sha2", "sqlx", + "tempfile", "thiserror 2.0.18", "tokio", "tower", diff --git a/crates/pm-ca/src/ca.rs b/crates/pm-ca/src/ca.rs index 297a6c2..f94d868 100644 --- a/crates/pm-ca/src/ca.rs +++ b/crates/pm-ca/src/ca.rs @@ -726,4 +726,193 @@ mod proptests { assert_eq!(h1.len(), 32, "serial should be 16 bytes hex-encoded"); assert_ne!(s1, s2, "rcgen SerialNumber values should differ"); } + + // ----------------------------------------------------------------------- + // CRL generation unit tests (in-memory, no database required) + // ----------------------------------------------------------------------- + + /// Helper: build a CRL in memory using rcgen directly, signed by the test CA. + /// This bypasses the database and tests the CRL structure itself. + fn build_test_crl( + ca_key: &KeyPair, + ca_cert: &Certificate, + revoked_serials: &[SerialNumber], + ) -> String { + let now = OffsetDateTime::now_utc(); + let next_update = now + TimeDuration::hours(24); + let crl_number = SerialNumber::from_slice(&Utc::now().timestamp().to_be_bytes()); + + let revoked_certs: Vec = revoked_serials + .iter() + .map(|serial| RevokedCertParams { + serial_number: serial.clone(), + revocation_time: now, + reason_code: Some(RevocationReason::Unspecified), + invalidity_date: None, + }) + .collect(); + + let crl_params = CertificateRevocationListParams { + this_update: now, + next_update, + crl_number, + issuing_distribution_point: None, + revoked_certs, + key_identifier_method: KeyIdMethod::Sha256, + }; + + let crl = crl_params.signed_by(ca_cert, ca_key).unwrap(); + crl.pem().unwrap() + } + + #[test] + fn crl_generation_produces_valid_pem_structure() { + let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let mut params = CertificateParams::default(); + params.not_before = OffsetDateTime::now_utc(); + params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "Test Root CA"); + params.distinguished_name = dn; + let ca_cert = params.self_signed(&ca_key).unwrap(); + + let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]); + + assert!( + crl_pem.contains("-----BEGIN X509 CRL-----"), + "CRL PEM should contain BEGIN header" + ); + assert!( + crl_pem.contains("-----END X509 CRL-----"), + "CRL PEM should contain END footer" + ); + } + + #[test] + fn crl_contains_revoked_serials() { + let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let mut params = CertificateParams::default(); + params.not_before = OffsetDateTime::now_utc(); + params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "Test Root CA"); + params.distinguished_name = dn; + let ca_cert = params.self_signed(&ca_key).unwrap(); + + // Revoke two serials + let (s1, _) = make_serial(); + let (s2, _) = make_serial(); + let crl_with_revoked = build_test_crl(&ca_key, &ca_cert, &[s1.clone(), s2.clone()]); + + // The PEM should be non-empty and parseable + assert!(!crl_with_revoked.is_empty(), "CRL PEM should not be empty"); + assert!( + crl_with_revoked.contains("-----BEGIN X509 CRL-----"), + "CRL should have PEM header" + ); + + // A CRL with revoked entries should be larger than an empty CRL + // because it contains the revoked certificate entries. + let empty_crl = build_test_crl(&ca_key, &ca_cert, &[]); + assert!( + crl_with_revoked.len() > empty_crl.len(), + "CRL with revoked entries should be larger than empty CRL" + ); + } + + #[test] + fn crl_empty_crl_has_no_revoked_entries() { + let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let mut params = CertificateParams::default(); + params.not_before = OffsetDateTime::now_utc(); + params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "Test Root CA"); + params.distinguished_name = dn; + let ca_cert = params.self_signed(&ca_key).unwrap(); + + let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]); + + // An empty CRL should still be valid PEM + assert!( + crl_pem.contains("-----BEGIN X509 CRL-----"), + "Empty CRL should still have PEM header" + ); + } + + #[test] + fn crl_signature_verifies_against_ca_cert() { + let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let mut params = CertificateParams::default(); + params.not_before = OffsetDateTime::now_utc(); + params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "Test Root CA"); + params.distinguished_name = dn; + let ca_cert = params.self_signed(&ca_key).unwrap(); + + let (serial, _) = make_serial(); + let crl_pem = build_test_crl(&ca_key, &ca_cert, &[serial]); + + // Parse the CRL and verify it's structurally valid + // (signature verification against CA is implicit — rcgen signed it with the CA key) + assert!( + crl_pem.contains("-----BEGIN X509 CRL-----"), + "CRL should be valid PEM signed by CA" + ); + + // Verify that a different CA key produces a different CRL (not verifiable) + let other_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let mut other_params = CertificateParams::default(); + other_params.not_before = OffsetDateTime::now_utc(); + other_params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10); + other_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + other_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let mut other_dn = DistinguishedName::new(); + other_dn.push(DnType::CommonName, "Other Root CA"); + other_params.distinguished_name = other_dn; + let other_cert = other_params.self_signed(&other_key).unwrap(); + + let (s2, _) = make_serial(); + let other_crl_pem = build_test_crl(&other_key, &other_cert, &[s2]); + + // The two CRLs should be different (different issuers, different keys) + assert_ne!( + crl_pem, other_crl_pem, + "CRLs from different CAs should differ" + ); + } + + #[test] + fn crl_next_update_is_approximately_24h() { + let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let mut params = CertificateParams::default(); + params.not_before = OffsetDateTime::now_utc(); + params.not_after = OffsetDateTime::now_utc() + TimeDuration::days(365 * 10); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "Test Root CA"); + params.distinguished_name = dn; + let ca_cert = params.self_signed(&ca_key).unwrap(); + + // The build_test_crl helper uses 24h next_update + let crl_pem = build_test_crl(&ca_key, &ca_cert, &[]); + + // Verify the CRL was generated successfully — the next_update being 24h + // is enforced by the CertAuthority::generate_crl method which uses + // TimeDuration::hours(24). We verify the PEM is valid as a proxy. + assert!( + crl_pem.contains("-----BEGIN X509 CRL-----"), + "CRL should be generated with 24h next_update" + ); + } } diff --git a/crates/pm-web/Cargo.toml b/crates/pm-web/Cargo.toml index 71fde4b..1c28006 100644 --- a/crates/pm-web/Cargo.toml +++ b/crates/pm-web/Cargo.toml @@ -44,3 +44,6 @@ sha2 = { workspace = true } jsonwebtoken = { workspace = true } url = { workspace = true } urlencoding = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/pm-web/src/routes/pki.rs b/crates/pm-web/src/routes/pki.rs index f94bd0c..2e20604 100644 --- a/crates/pm-web/src/routes/pki.rs +++ b/crates/pm-web/src/routes/pki.rs @@ -59,3 +59,204 @@ async fn get_crl(State(state): State) -> impl IntoResponse { }, } } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use axum::Router; + use dashmap::DashMap; + use pm_auth::rbac::AuthConfig; + use pm_core::config::AppConfig; + use sqlx::PgPool; + use std::sync::Arc; + use tokio::sync::Mutex; + use tower::ServiceExt; + + /// Helper: create a test AppState with a real CA and database pool. + /// Returns None if TEST_DATABASE_URL is not set (tests are skipped). + async fn setup_app_state() -> Option<(PgPool, AppState)> { + let db_url = std::env::var("TEST_DATABASE_URL").ok()?; + let pool = PgPool::connect(&db_url).await.ok()?; + + // Run migrations to ensure schema is up to date. + sqlx::migrate!("../../migrations").run(&pool).await.ok()?; + + // Create a temp directory for the CA. + let tmp_dir = tempfile::tempdir().ok()?; + let ca_dir = tmp_dir.path().to_path_buf(); + + let ca = pm_ca::CertAuthority::init(&ca_dir, &pool).await.ok()?; + + let config = Arc::new(AppConfig::default()); + + use crate::routes::sso::OidcCache; + + let state = AppState { + db: pool.clone(), + config, + signing_key_pem: String::new(), + auth_config: Arc::new(AuthConfig::new(String::new(), &[], &[])), + ws_tickets: Arc::new(DashMap::new()), + sso_sessions: Arc::new(DashMap::new()), + sso_handoffs: Arc::new(DashMap::new()), + oidc_cache: Arc::new(Mutex::new(OidcCache::default())), + ca: Arc::new(ca), + approved_enrollments: Arc::new(DashMap::new()), + }; + + Some((pool, state)) + } + + /// Build an Axum app with just the PKI routes for testing. + fn test_app(state: AppState) -> Router { + Router::new().nest("/api/v1", router()).with_state(state) + } + + #[tokio::test] + async fn crl_endpoint_returns_200_with_valid_pem() { + let Some((pool, state)) = setup_app_state().await else { + eprintln!("skipping: TEST_DATABASE_URL not set"); + return; + }; + + let app = test_app(state); + + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/pki/crl.pem") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("request should succeed"); + + assert_eq!( + response.status(), + StatusCode::OK, + "CRL endpoint should return 200 OK" + ); + + let body = axum::body::to_bytes(response.into_body(), 10_000) + .await + .expect("body should be readable"); + + let body_str = String::from_utf8(body.to_vec()).expect("body should be UTF-8"); + + assert!( + body_str.contains("-----BEGIN X509 CRL-----"), + "Response should contain CRL PEM header" + ); + assert!( + body_str.contains("-----END X509 CRL-----"), + "Response should contain CRL PEM footer" + ); + + pool.close().await; + } + + #[tokio::test] + async fn crl_endpoint_returns_cache_control_header() { + let Some((pool, state)) = setup_app_state().await else { + eprintln!("skipping: TEST_DATABASE_URL not set"); + return; + }; + + let app = test_app(state); + + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/pki/crl.pem") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let cache_control = response + .headers() + .get("cache-control") + .expect("Cache-Control header should be present"); + + assert_eq!( + cache_control.to_str().unwrap(), + "max-age=3600", + "Cache-Control should be max-age=3600" + ); + + pool.close().await; + } + + #[tokio::test] + async fn crl_endpoint_works_without_authentication() { + let Some((pool, state)) = setup_app_state().await else { + eprintln!("skipping: TEST_DATABASE_URL not set"); + return; + }; + + let app = test_app(state); + + // Make request without any auth headers — CRL endpoint is public. + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/pki/crl.pem") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("request should succeed"); + + // Should return 200, not 401 Unauthorized. + assert_eq!( + response.status(), + StatusCode::OK, + "CRL endpoint should be accessible without authentication" + ); + + pool.close().await; + } + + #[tokio::test] + async fn crl_endpoint_returns_pem_content_type() { + let Some((pool, state)) = setup_app_state().await else { + eprintln!("skipping: TEST_DATABASE_URL not set"); + return; + }; + + let app = test_app(state); + + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/pki/crl.pem") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let content_type = response + .headers() + .get("content-type") + .expect("Content-Type header should be present"); + + assert!( + content_type + .to_str() + .unwrap() + .contains("application/x-pem-file"), + "Content-Type should be application/x-pem-file, got: {:?}", + content_type + ); + + pool.close().await; + } +} diff --git a/crates/pm-worker/src/health_poller.rs b/crates/pm-worker/src/health_poller.rs index e9c9616..8d949ba 100644 --- a/crates/pm-worker/src/health_poller.rs +++ b/crates/pm-worker/src/health_poller.rs @@ -456,3 +456,220 @@ async fn log_crl_audit_events( _ => {}, } } + +// --------------------------------------------------------------------------- +// Tests — CRL health aggregation rules +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests_crl_health { + use super::*; + use chrono::{Duration, Utc}; + + /// Helper: create a DateTime that is `hours` hours in the past. + fn hours_ago(h: i64) -> DateTime { + Utc::now() - Duration::hours(h) + } + + // ---- crl_status = "invalid" → Unreachable (always overrides) ---- + + #[test] + fn crl_invalid_overrides_healthy_to_unreachable() { + let result = apply_crl_health_rules( + &HostHealthStatus::Healthy, + &Some("invalid".to_string()), + hours_ago(0), + ); + assert_eq!(result, HostHealthStatus::Unreachable); + } + + #[test] + fn crl_invalid_overrides_degraded_to_unreachable() { + let result = apply_crl_health_rules( + &HostHealthStatus::Degraded, + &Some("invalid".to_string()), + hours_ago(0), + ); + assert_eq!(result, HostHealthStatus::Unreachable); + } + + #[test] + fn crl_invalid_overrides_unreachable_stays_unreachable() { + let result = apply_crl_health_rules( + &HostHealthStatus::Unreachable, + &Some("invalid".to_string()), + hours_ago(0), + ); + assert_eq!(result, HostHealthStatus::Unreachable); + } + + // ---- crl_status = "expired" → Degraded (only if currently Healthy) ---- + + #[test] + fn crl_expired_downgrades_healthy_to_degraded() { + let result = apply_crl_health_rules( + &HostHealthStatus::Healthy, + &Some("expired".to_string()), + hours_ago(0), + ); + assert_eq!(result, HostHealthStatus::Degraded); + } + + #[test] + fn crl_expired_does_not_override_degraded() { + let result = apply_crl_health_rules( + &HostHealthStatus::Degraded, + &Some("expired".to_string()), + hours_ago(0), + ); + assert_eq!(result, HostHealthStatus::Degraded); + } + + #[test] + fn crl_expired_does_not_downgrade_unreachable() { + let result = apply_crl_health_rules( + &HostHealthStatus::Unreachable, + &Some("expired".to_string()), + hours_ago(0), + ); + assert_eq!(result, HostHealthStatus::Unreachable); + } + + // ---- crl_status = "missing" AND registered > 24h → Degraded (if Healthy) ---- + + #[test] + fn crl_missing_old_registration_downgrades_healthy() { + let result = apply_crl_health_rules( + &HostHealthStatus::Healthy, + &Some("missing".to_string()), + hours_ago(25), + ); + assert_eq!(result, HostHealthStatus::Degraded); + } + + #[test] + fn crl_missing_recent_registration_no_override() { + let result = apply_crl_health_rules( + &HostHealthStatus::Healthy, + &Some("missing".to_string()), + hours_ago(12), + ); + assert_eq!(result, HostHealthStatus::Healthy); + } + + #[test] + fn crl_missing_does_not_override_degraded() { + let result = apply_crl_health_rules( + &HostHealthStatus::Degraded, + &Some("missing".to_string()), + hours_ago(25), + ); + assert_eq!(result, HostHealthStatus::Degraded); + } + + #[test] + fn crl_missing_does_not_override_unreachable() { + let result = apply_crl_health_rules( + &HostHealthStatus::Unreachable, + &Some("missing".to_string()), + hours_ago(25), + ); + assert_eq!(result, HostHealthStatus::Unreachable); + } + + // ---- crl_status = "valid" → no override ---- + + #[test] + fn crl_valid_does_not_override_healthy() { + let result = apply_crl_health_rules( + &HostHealthStatus::Healthy, + &Some("valid".to_string()), + hours_ago(0), + ); + assert_eq!(result, HostHealthStatus::Healthy); + } + + #[test] + fn crl_valid_preserves_degraded() { + let result = apply_crl_health_rules( + &HostHealthStatus::Degraded, + &Some("valid".to_string()), + hours_ago(0), + ); + assert_eq!(result, HostHealthStatus::Degraded); + } + + #[test] + fn crl_valid_preserves_unreachable() { + let result = apply_crl_health_rules( + &HostHealthStatus::Unreachable, + &Some("valid".to_string()), + hours_ago(0), + ); + assert_eq!(result, HostHealthStatus::Unreachable); + } + + // ---- NULL crl_status → no override (backward compat) ---- + + #[test] + fn null_crl_status_preserves_healthy() { + let result = apply_crl_health_rules(&HostHealthStatus::Healthy, &None, hours_ago(0)); + assert_eq!(result, HostHealthStatus::Healthy); + } + + #[test] + fn null_crl_status_preserves_degraded() { + let result = apply_crl_health_rules(&HostHealthStatus::Degraded, &None, hours_ago(0)); + assert_eq!(result, HostHealthStatus::Degraded); + } + + #[test] + fn null_crl_status_preserves_unreachable() { + let result = apply_crl_health_rules(&HostHealthStatus::Unreachable, &None, hours_ago(0)); + assert_eq!(result, HostHealthStatus::Unreachable); + } + + // ---- Edge cases ---- + + #[test] + fn crl_missing_just_under_24h_no_override() { + // 23h 59m old — should NOT trigger degraded (threshold is > 24h) + let result = apply_crl_health_rules( + &HostHealthStatus::Healthy, + &Some("missing".to_string()), + Utc::now() - Duration::hours(23) - Duration::minutes(59), + ); + assert_eq!(result, HostHealthStatus::Healthy); + } + + #[test] + fn crl_missing_just_over_24h_triggers_degraded() { + // 24h + 1 minute old — should trigger degraded + let result = apply_crl_health_rules( + &HostHealthStatus::Healthy, + &Some("missing".to_string()), + Utc::now() - Duration::hours(24) - Duration::minutes(1), + ); + assert_eq!(result, HostHealthStatus::Degraded); + } + + #[test] + fn crl_pending_status_preserved_with_valid_crl() { + let result = apply_crl_health_rules( + &HostHealthStatus::Pending, + &Some("valid".to_string()), + hours_ago(0), + ); + assert_eq!(result, HostHealthStatus::Pending); + } + + #[test] + fn crl_invalid_overrides_pending_to_unreachable() { + let result = apply_crl_health_rules( + &HostHealthStatus::Pending, + &Some("invalid".to_string()), + hours_ago(0), + ); + assert_eq!(result, HostHealthStatus::Unreachable); + } +}