Private
Public Access
1
0

test: add authz gate integration tests (closes #15)

This commit is contained in:
Draco Lunaris
2026-06-06 10:05:49 -05:00
parent dd6961265d
commit 53eef4eec4
6 changed files with 788 additions and 303 deletions

View File

@ -0,0 +1,450 @@
//! 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.
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.
/// 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(),
_ => 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)
}
/// 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"),
(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 minimal `AppState` suitable for integration tests.
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();
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.
///
/// Inserts `ConnectInfo<SocketAddr>` so the rate limiter and IP-allowlist
/// middleware can resolve the client IP.
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)
}
// ── Integration tests ───────────────────────────────────────────────────────
/// 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;
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");
}
/// 2. PUT /api/v1/settings with admin role → 200 + audit log
#[sqlx::test(migrations = "../../migrations")]
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");
}
/// 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")]
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"
);
}
/// 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")]
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"
);
}
/// 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")]
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"
);
}