All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Successful in 1m56s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
* test: add authz gate integration tests (closes #15) * fix: separate authz gate 403 tests from DB-dependent tests --------- Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
531 lines
18 KiB
Rust
531 lines
18 KiB
Rust
//! Integration tests for the authz gate that restricts auth config mutations
|
|
//! (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;
|
|
use axum::http::{Request, StatusCode};
|
|
use dashmap::DashMap;
|
|
use http_body_util::BodyExt;
|
|
use pm_auth::jwt;
|
|
use pm_auth::rbac::AuthConfig;
|
|
use pm_core::config::AppConfig;
|
|
use pm_web::routes::sso::OidcCache;
|
|
use pm_web::{build_router, AppState};
|
|
use serde_json::json;
|
|
use sqlx::PgPool;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use tokio::sync::Mutex;
|
|
use tower::ServiceExt;
|
|
use uuid::Uuid;
|
|
|
|
// ── Ed25519 test key pair ────────────────────────────────────────────────────
|
|
const TEST_SIGNING_KEY: &str = "-----BEGIN PRIVATE KEY-----
|
|
MC4CAQAwBQYDK2VwBCIEIBrWiMMcgpPXwtGDSSBl01fcQyb5Vh4CMzEmxcSXvcrJ
|
|
-----END PRIVATE KEY-----
|
|
";
|
|
|
|
const TEST_VERIFY_KEY: &str = "-----BEGIN PUBLIC KEY-----
|
|
MCowBQYDK2VwAyEACgE6fMDCcG11NOpPKSO/ASpPUSntB7XsF5sBFBYDjFo=
|
|
-----END PUBLIC KEY-----
|
|
";
|
|
|
|
// ── Fixed test user IDs (so we can seed matching rows in the DB) ─────────────
|
|
const ADMIN_USER_ID: &str = "00000000-0000-4000-8000-000000000001";
|
|
const OPERATOR_USER_ID: &str = "00000000-0000-4000-8000-000000000002";
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/// Generate a valid JWT authorization header for the given role.
|
|
fn auth_header(role: &str) -> String {
|
|
let user_id = match role {
|
|
"admin" => Uuid::parse_str(ADMIN_USER_ID).unwrap(),
|
|
_ => Uuid::parse_str(OPERATOR_USER_ID).unwrap(),
|
|
};
|
|
let username = format!("test-{}", role);
|
|
let token = jwt::issue_access_token(user_id, &username, role, 900, TEST_SIGNING_KEY)
|
|
.expect("failed to issue test JWT");
|
|
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) {
|
|
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"),
|
|
(OPERATOR_USER_ID, "test-operator", "operator"),
|
|
] {
|
|
sqlx::query(
|
|
r#"INSERT INTO users (id, username, display_name, email, role, auth_provider, password_hash)
|
|
VALUES ($1, $2, $3, $4, $5::user_role, 'local', $6)
|
|
ON CONFLICT (id) DO NOTHING"#,
|
|
)
|
|
.bind(Uuid::parse_str(user_id).unwrap())
|
|
.bind(username)
|
|
.bind(username)
|
|
.bind(format!("{}@test.example.com", username))
|
|
.bind(role)
|
|
.bind(placeholder_hash)
|
|
.execute(pool)
|
|
.await
|
|
.expect("failed to seed test user");
|
|
}
|
|
}
|
|
|
|
/// Build a full `AppState` with a live database connection.
|
|
async fn setup_state(pool: PgPool) -> AppState {
|
|
seed_test_users(&pool).await;
|
|
|
|
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();
|
|
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()),
|
|
}
|
|
}
|
|
|
|
/// Send a request through the full Axum router and return the response.
|
|
async fn send_request(
|
|
state: AppState,
|
|
method: axum::http::Method,
|
|
uri: &str,
|
|
auth_header: Option<&str>,
|
|
body: Option<serde_json::Value>,
|
|
) -> (StatusCode, serde_json::Value) {
|
|
let router = build_router(state);
|
|
let mut builder = Request::builder().method(method).uri(uri);
|
|
if let Some(auth) = auth_header {
|
|
builder = builder.header("authorization", auth);
|
|
}
|
|
builder = builder.header("content-type", "application/json");
|
|
|
|
let req = if let Some(b) = body {
|
|
builder.body(Body::from(b.to_string())).unwrap()
|
|
} else {
|
|
builder.body(Body::empty()).unwrap()
|
|
};
|
|
|
|
// Insert ConnectInfo so tower_governor's SmartIpKeyExtractor can resolve the client IP.
|
|
let (mut parts, body) = req.into_parts();
|
|
parts
|
|
.extensions
|
|
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
|
let req = Request::from_parts(parts, body);
|
|
|
|
let resp = router.oneshot(req).await.unwrap();
|
|
let status = resp.status();
|
|
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
|
let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap_or_else(|_| {
|
|
let raw = String::from_utf8_lossy(&body_bytes);
|
|
json!({ "_raw": raw.to_string() })
|
|
});
|
|
(status, body_json)
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 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
|
|
#[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(
|
|
state,
|
|
axum::http::Method::PUT,
|
|
"/api/v1/settings",
|
|
Some(&auth),
|
|
Some(json!({ "polling": { "health_poll_interval_secs": 300 } })),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::FORBIDDEN,
|
|
"expected 403, got {}: {:?}",
|
|
status,
|
|
body
|
|
);
|
|
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();
|
|
let auth = auth_header("admin");
|
|
|
|
let (status, body) = send_request(
|
|
state,
|
|
axum::http::Method::PUT,
|
|
"/api/v1/settings",
|
|
Some(&auth),
|
|
Some(json!({ "polling": { "health_poll_interval_secs": 300 } })),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::OK,
|
|
"expected 200, got {}: {:?}",
|
|
status,
|
|
body
|
|
);
|
|
|
|
let row: Option<(String,)> = sqlx::query_as(
|
|
"SELECT action::text FROM audit_log WHERE action::text = 'config_changed' ORDER BY created_at DESC LIMIT 1",
|
|
)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.expect("audit log query failed");
|
|
assert!(row.is_some(), "expected audit log entry for config_changed");
|
|
}
|
|
|
|
/// 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();
|
|
let auth = auth_header("admin");
|
|
|
|
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::OK,
|
|
"expected 200, got {}: {:?}",
|
|
status,
|
|
body
|
|
);
|
|
|
|
let row: Option<(String,)> = sqlx::query_as(
|
|
"SELECT action::text FROM audit_log WHERE action::text = 'ip_whitelist_updated' ORDER BY created_at DESC LIMIT 1",
|
|
)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.expect("audit log query failed");
|
|
assert!(
|
|
row.is_some(),
|
|
"expected audit log entry for ip_whitelist_updated"
|
|
);
|
|
}
|
|
|
|
/// 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();
|
|
let auth = auth_header("admin");
|
|
|
|
let mut server = mockito::Server::new_async().await;
|
|
let mock = server
|
|
.mock("GET", "/.well-known/openid-configuration")
|
|
.with_status(200)
|
|
.with_header("content-type", "application/json")
|
|
.with_body(
|
|
json!({
|
|
"issuer": "https://mock-oidc.example.com",
|
|
"authorization_endpoint": "https://mock-oidc.example.com/auth",
|
|
"token_endpoint": "https://mock-oidc.example.com/token",
|
|
"jwks_uri": "https://mock-oidc.example.com/jwks",
|
|
"userinfo_endpoint": "https://mock-oidc.example.com/userinfo"
|
|
})
|
|
.to_string(),
|
|
)
|
|
.create_async()
|
|
.await;
|
|
|
|
let discovery_url = format!("{}/.well-known/openid-configuration", server.url());
|
|
|
|
let (status, body) = send_request(
|
|
state,
|
|
axum::http::Method::POST,
|
|
"/api/v1/settings/sso/discover",
|
|
Some(&auth),
|
|
Some(json!({ "discovery_url": discovery_url })),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::OK,
|
|
"expected 200, got {}: {:?}",
|
|
status,
|
|
body
|
|
);
|
|
assert_eq!(body["success"], true);
|
|
|
|
mock.assert_async().await;
|
|
|
|
let row: Option<(String,)> = sqlx::query_as(
|
|
"SELECT action::text FROM audit_log WHERE action::text = 'oidc_discover_performed' ORDER BY created_at DESC LIMIT 1",
|
|
)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.expect("audit log query failed");
|
|
assert!(
|
|
row.is_some(),
|
|
"expected audit log entry for oidc_discover_performed"
|
|
);
|
|
}
|
|
|
|
/// 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
|
|
.mock("GET", "/.well-known/openid-configuration")
|
|
.with_status(200)
|
|
.with_header("content-type", "application/json")
|
|
.with_body(
|
|
json!({
|
|
"issuer": "https://mock-oidc.example.com",
|
|
"authorization_endpoint": "https://mock-oidc.example.com/auth",
|
|
"token_endpoint": "https://mock-oidc.example.com/token",
|
|
"jwks_uri": "https://mock-oidc.example.com/jwks"
|
|
})
|
|
.to_string(),
|
|
)
|
|
.create_async()
|
|
.await;
|
|
|
|
let discovery_url = format!("{}/.well-known/openid-configuration", server.url());
|
|
|
|
// Seed the oidc_config table with an enabled provider pointing to mockito.
|
|
sqlx::query("UPDATE oidc_config SET enabled = true, discovery_url = $1 WHERE id = 1")
|
|
.bind(&discovery_url)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("failed to seed oidc_config");
|
|
|
|
let state = setup_state(pool).await;
|
|
let pool = state.db.clone();
|
|
let auth = auth_header("admin");
|
|
|
|
let (status, body) = send_request(
|
|
state,
|
|
axum::http::Method::POST,
|
|
"/api/v1/settings/sso/test",
|
|
Some(&auth),
|
|
None,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::OK,
|
|
"expected 200, got {}: {:?}",
|
|
status,
|
|
body
|
|
);
|
|
assert_eq!(body["success"], true);
|
|
|
|
mock.assert_async().await;
|
|
|
|
let row: Option<(String,)> = sqlx::query_as(
|
|
"SELECT action::text FROM audit_log WHERE action::text = 'oidc_test_performed' ORDER BY created_at DESC LIMIT 1",
|
|
)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.expect("audit log query failed");
|
|
assert!(
|
|
row.is_some(),
|
|
"expected audit log entry for oidc_test_performed"
|
|
);
|
|
}
|