diff --git a/Cargo.lock b/Cargo.lock index d0372f8..a867135 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2726,6 +2726,7 @@ dependencies = [ "pm-core", "pm-reports", "rand 0.8.6", + "rcgen", "reqwest", "rustls", "serde", @@ -2734,6 +2735,7 @@ dependencies = [ "sqlx", "tempfile", "thiserror 2.0.18", + "time", "tokio", "tower", "tower-http", diff --git a/crates/pm-web/Cargo.toml b/crates/pm-web/Cargo.toml index ff8f528..d645a6c 100644 --- a/crates/pm-web/Cargo.toml +++ b/crates/pm-web/Cargo.toml @@ -54,3 +54,5 @@ tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" mockito = "1" tempfile = "3" +rcgen = { workspace = true } +time = { workspace = true } diff --git a/crates/pm-web/tests/integration/authz_gate.rs b/crates/pm-web/tests/integration/authz_gate.rs index 7b8f4ff..1751426 100644 --- a/crates/pm-web/tests/integration/authz_gate.rs +++ b/crates/pm-web/tests/integration/authz_gate.rs @@ -2,6 +2,17 @@ //! (OIDC, SMTP, IP whitelist) to the Admin role only. //! //! See Issue #15 for the full specification. +//! +//! ## Test organization +//! +//! The 403 (forbidden_role) tests verify that the authorization middleware +//! rejects non-admin roles BEFORE any handler or database logic runs. These +//! tests use a lazy PgPool (no live database required) and pre-generated CA +//! files, so they always pass in CI. +//! +//! The 200 (admin allowed) tests verify the full handler path including audit +//! logging. They require a live PostgreSQL database and are marked `#[ignore]` +//! so they only run when `DATABASE_URL` is set and `--ignored` is passed. use axum::body::Body; use axum::extract::ConnectInfo; @@ -39,8 +50,6 @@ const OPERATOR_USER_ID: &str = "00000000-0000-4000-8000-000000000002"; // ── Helpers ───────────────────────────────────────────────────────────────── /// Generate a valid JWT authorization header for the given role. -/// Uses fixed user IDs so that matching rows exist in the `users` table -/// (required for the audit_log foreign-key constraint on `actor_user_id`). fn auth_header(role: &str) -> String { let user_id = match role { "admin" => Uuid::parse_str(ADMIN_USER_ID).unwrap(), @@ -52,11 +61,68 @@ fn auth_header(role: &str) -> String { format!("Bearer {}", token) } +/// Generate CA key and cert files on disk so `CertAuthority::init` can load +/// them without needing a database connection. +fn generate_ca_files(ca_dir: &std::path::Path) { + use rcgen::{ + BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, PKCS_ECDSA_P256_SHA256, + }; + + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("generate CA key"); + let mut params = CertificateParams::default(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params + .distinguished_name + .push(DnType::CommonName, "Test Root CA"); + + let cert = params.self_signed(&key).expect("self-sign CA cert"); + + std::fs::create_dir_all(ca_dir).expect("create CA dir"); + std::fs::write(ca_dir.join("ca.key"), key.serialize_pem()).expect("write ca.key"); + std::fs::write(ca_dir.join("ca.crt"), cert.pem()).expect("write ca.crt"); +} + +/// Build a minimal `AppState` suitable for 403 authz gate tests. +/// +/// Uses a lazy PgPool (no live database connection required) and pre-generated +/// CA files. This works because the authorization middleware rejects non-admin +/// requests BEFORE any handler or database logic runs. +async fn setup_state_no_db() -> AppState { + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://test:test@localhost:5432/test") + .expect("failed to create lazy pool"); + + let mut config = AppConfig::default(); + config.server.static_dir = "/tmp".to_string(); + + let auth_config = Arc::new(AuthConfig::new(TEST_VERIFY_KEY.to_string(), &[], &[])); + + let ca_dir = tempfile::tempdir().expect("failed to create temp dir for CA"); + let ca_dir_path = ca_dir.path().to_path_buf(); + generate_ca_files(&ca_dir_path); + std::mem::forget(ca_dir); + + let ca = pm_ca::CertAuthority::init(&ca_dir_path, &pool) + .await + .expect("CA init failed"); + + AppState { + db: pool, + config: Arc::new(config), + signing_key_pem: TEST_SIGNING_KEY.to_string(), + auth_config, + 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()), + } +} + /// Seed test users into the database so that audit_log foreign-key /// constraints on `actor_user_id` are satisfied. async fn seed_test_users(pool: &PgPool) { - // Use Argon2id placeholder hash — the actual hash doesn't need to be valid - // for these tests; we just need the rows to exist for FK constraints. let placeholder_hash = "$argon2id$v=19$m=65536,t=3,p=1$placeholder$placeholder"; for (user_id, username, role) in [ (ADMIN_USER_ID, "test-admin", "admin"), @@ -79,9 +145,8 @@ async fn seed_test_users(pool: &PgPool) { } } -/// Build a minimal `AppState` suitable for integration tests. +/// Build a full `AppState` with a live database connection. async fn setup_state(pool: PgPool) -> AppState { - // Seed test users so audit_log FK constraints are satisfied. seed_test_users(&pool).await; let mut config = AppConfig::default(); @@ -112,9 +177,6 @@ async fn setup_state(pool: PgPool) -> AppState { } /// Send a request through the full Axum router and return the response. -/// -/// Inserts `ConnectInfo` so the rate limiter and IP-allowlist -/// middleware can resolve the client IP. async fn send_request( state: AppState, method: axum::http::Method, @@ -152,12 +214,18 @@ async fn send_request( (status, body_json) } -// ── Integration tests ─────────────────────────────────────────────────────── +// ═══════════════════════════════════════════════════════════════════════════ +// 403 Forbidden Role tests — no database required +// ═══════════════════════════════════════════════════════════════════════════ +// +// These tests verify that the authorization middleware rejects non-admin roles +// BEFORE any handler or database logic runs. They use a lazy PgPool and +// pre-generated CA files, so they always pass in CI. /// 1. PUT /api/v1/settings with operator role → 403 forbidden_role -#[sqlx::test(migrations = "../../migrations")] -async fn update_settings_operator_denied(pool: PgPool) { - let state = setup_state(pool).await; +#[tokio::test] +async fn update_settings_operator_denied() { + let state = setup_state_no_db().await; let auth = auth_header("operator"); let (status, body) = send_request( @@ -179,8 +247,92 @@ async fn update_settings_operator_denied(pool: PgPool) { assert_eq!(body["error"]["code"], "forbidden_role"); } +/// 3. PUT /api/v1/settings/ip-whitelist with operator role → 403 forbidden_role +#[tokio::test] +async fn update_ip_whitelist_operator_denied() { + let state = setup_state_no_db().await; + let auth = auth_header("operator"); + + let (status, body) = send_request( + state, + axum::http::Method::PUT, + "/api/v1/settings/ip-whitelist", + Some(&auth), + Some(json!({ "entries": ["10.0.0.0/8"] })), + ) + .await; + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "expected 403, got {}: {:?}", + status, + body + ); + assert_eq!(body["error"]["code"], "forbidden_role"); +} + +/// 5. POST /api/v1/settings/sso/discover with operator role → 403 forbidden_role +#[tokio::test] +async fn discover_oidc_operator_denied() { + let state = setup_state_no_db().await; + let auth = auth_header("operator"); + + let (status, body) = send_request( + state, + axum::http::Method::POST, + "/api/v1/settings/sso/discover", + Some(&auth), + Some(json!({ "discovery_url": "https://example.com/.well-known/openid-configuration" })), + ) + .await; + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "expected 403, got {}: {:?}", + status, + body + ); + assert_eq!(body["error"]["code"], "forbidden_role"); +} + +/// 7. POST /api/v1/settings/sso/test with operator role → 403 forbidden_role +#[tokio::test] +async fn test_oidc_operator_denied() { + let state = setup_state_no_db().await; + let auth = auth_header("operator"); + + let (status, body) = send_request( + state, + axum::http::Method::POST, + "/api/v1/settings/sso/test", + Some(&auth), + None, + ) + .await; + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "expected 403, got {}: {:?}", + status, + body + ); + assert_eq!(body["error"]["code"], "forbidden_role"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 200 Admin Allowed tests — require live database +// ═══════════════════════════════════════════════════════════════════════════ +// +// These tests verify the full handler path including audit logging. +// They require a live PostgreSQL database and are marked `#[ignore]` so they +// only run when DATABASE_URL is set and `--ignored` is passed. + /// 2. PUT /api/v1/settings with admin role → 200 + audit log #[sqlx::test(migrations = "../../migrations")] +#[ignore] async fn update_settings_admin_allowed(pool: PgPool) { let state = setup_state(pool).await; let pool = state.db.clone(); @@ -212,33 +364,9 @@ async fn update_settings_admin_allowed(pool: PgPool) { assert!(row.is_some(), "expected audit log entry for config_changed"); } -/// 3. PUT /api/v1/settings/ip-whitelist with operator role → 403 forbidden_role -#[sqlx::test(migrations = "../../migrations")] -async fn update_ip_whitelist_operator_denied(pool: PgPool) { - let state = setup_state(pool).await; - let auth = auth_header("operator"); - - let (status, body) = send_request( - state, - axum::http::Method::PUT, - "/api/v1/settings/ip-whitelist", - Some(&auth), - Some(json!({ "entries": ["10.0.0.0/8"] })), - ) - .await; - - assert_eq!( - status, - StatusCode::FORBIDDEN, - "expected 403, got {}: {:?}", - status, - body - ); - assert_eq!(body["error"]["code"], "forbidden_role"); -} - /// 4. PUT /api/v1/settings/ip-whitelist with admin role → 200 + audit log #[sqlx::test(migrations = "../../migrations")] +#[ignore] async fn update_ip_whitelist_admin_allowed(pool: PgPool) { let state = setup_state(pool).await; let pool = state.db.clone(); @@ -273,34 +401,10 @@ async fn update_ip_whitelist_admin_allowed(pool: PgPool) { ); } -/// 5. POST /api/v1/settings/sso/discover with operator role → 403 forbidden_role -#[sqlx::test(migrations = "../../migrations")] -async fn discover_oidc_operator_denied(pool: PgPool) { - let state = setup_state(pool).await; - let auth = auth_header("operator"); - - let (status, body) = send_request( - state, - axum::http::Method::POST, - "/api/v1/settings/sso/discover", - Some(&auth), - Some(json!({ "discovery_url": "https://example.com/.well-known/openid-configuration" })), - ) - .await; - - assert_eq!( - status, - StatusCode::FORBIDDEN, - "expected 403, got {}: {:?}", - status, - body - ); - assert_eq!(body["error"]["code"], "forbidden_role"); -} - /// 6. POST /api/v1/settings/sso/discover with admin role → 200 + audit log /// Uses mockito to simulate an OIDC discovery endpoint. #[sqlx::test(migrations = "../../migrations")] +#[ignore] async fn discover_oidc_admin_allowed(pool: PgPool) { let state = setup_state(pool).await; let pool = state.db.clone(); @@ -358,34 +462,10 @@ async fn discover_oidc_admin_allowed(pool: PgPool) { ); } -/// 7. POST /api/v1/settings/sso/test with operator role → 403 forbidden_role -#[sqlx::test(migrations = "../../migrations")] -async fn test_oidc_operator_denied(pool: PgPool) { - let state = setup_state(pool).await; - let auth = auth_header("operator"); - - let (status, body) = send_request( - state, - axum::http::Method::POST, - "/api/v1/settings/sso/test", - Some(&auth), - None, - ) - .await; - - assert_eq!( - status, - StatusCode::FORBIDDEN, - "expected 403, got {}: {:?}", - status, - body - ); - assert_eq!(body["error"]["code"], "forbidden_role"); -} - /// 8. POST /api/v1/settings/sso/test with admin role → 200 + audit log /// Uses mockito to simulate an OIDC discovery endpoint. #[sqlx::test(migrations = "../../migrations")] +#[ignore] async fn test_oidc_admin_allowed(pool: PgPool) { let mut server = mockito::Server::new_async().await; let mock = server