451 lines
14 KiB
Rust
451 lines
14 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.
|
|
|
|
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"
|
|
);
|
|
}
|