You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

926 lines
33 KiB
Rust

//! Integration tests for the admin module (Phase 3).
//!
//! Tests:
//! - Access control (admin vs non-admin vs unauthenticated)
//! - Provider CRUD (list, create, update, delete)
//! - Rate limit management (list, update)
//! - User management (list, promote, demote, self-demotion guard)
//! - Public config endpoint (only enabled providers)
//! - Audit logging (provider creation, role changes)
//!
//! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run.
mod common;
use axum::http::StatusCode;
fn require_test_db() -> bool {
std::env::var("TEST_DATABASE_URL").is_ok()
}
// ═══════════════════════════════════════════════════════════════════════════
// Access Control
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn non_admin_cannot_get_providers() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app.create_authenticated_user("regular@example.com").await;
let (status, body) = app
.get_with_session("/api/v1/admin/providers", &session)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "Non-admin GET /admin/providers should return 403");
assert_eq!(body["error"], "forbidden");
}
#[tokio::test]
async fn non_admin_cannot_post_providers() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app.create_authenticated_user("regular2@example.com").await;
let body = serde_json::json!({
"provider_name": "gemini",
"display_name": "Test",
"models": [{"model_id": "m1", "display_name": "Model 1"}]
});
let (status, resp) = app
.post_with_session("/api/v1/admin/providers", &body, &session)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "Non-admin POST /admin/providers should return 403");
assert_eq!(resp["error"], "forbidden");
}
#[tokio::test]
async fn non_admin_cannot_get_users() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app.create_authenticated_user("regular3@example.com").await;
let (status, body) = app
.get_with_session("/api/v1/admin/users", &session)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "Non-admin GET /admin/users should return 403");
assert_eq!(body["error"], "forbidden");
}
#[tokio::test]
async fn non_admin_cannot_update_user_role() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (user_id, session) = app.create_authenticated_user("regular4@example.com").await;
let body = serde_json::json!({ "role": "admin" });
let uri = format!("/api/v1/admin/users/{}/role", user_id);
let (status, resp) = app.put_with_session(&uri, &body, &session).await;
assert_eq!(status, StatusCode::FORBIDDEN, "Non-admin PUT /admin/users/:id/role should return 403");
assert_eq!(resp["error"], "forbidden");
}
#[tokio::test]
async fn unauthenticated_admin_endpoint_returns_401() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (status, body) = app.get("/api/v1/admin/providers").await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "Unauthenticated GET /admin/providers should return 401");
assert_eq!(body["error"], "unauthorized");
let (status2, body2) = app.get("/api/v1/admin/users").await;
assert_eq!(status2, StatusCode::UNAUTHORIZED, "Unauthenticated GET /admin/users should return 401");
assert_eq!(body2["error"], "unauthorized");
let (status3, body3) = app.get("/api/v1/admin/rate-limits").await;
assert_eq!(status3, StatusCode::UNAUTHORIZED, "Unauthenticated GET /admin/rate-limits should return 401");
assert_eq!(body3["error"], "unauthorized");
}
#[tokio::test]
async fn non_admin_cannot_get_rate_limits() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app.create_authenticated_user("regular5@example.com").await;
let (status, body) = app
.get_with_session("/api/v1/admin/rate-limits", &session)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "Non-admin GET /admin/rate-limits should return 403");
assert_eq!(body["error"], "forbidden");
}
// ═══════════════════════════════════════════════════════════════════════════
// Provider CRUD
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn list_providers_returns_seeded_providers() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-list@example.com").await;
let (status, body) = app
.get_with_session("/api/v1/admin/providers", &session)
.await;
assert_eq!(status, StatusCode::OK);
let providers = body.as_array().expect("Response should be an array");
assert!(providers.len() >= 3, "Should have at least 3 seeded providers (gemini, openai, anthropic)");
let names: Vec<&str> = providers
.iter()
.filter_map(|p| p["provider_name"].as_str())
.collect();
assert!(names.contains(&"gemini"), "Should contain gemini");
assert!(names.contains(&"openai"), "Should contain openai");
assert!(names.contains(&"anthropic"), "Should contain anthropic");
// Each provider should have an id, models, and is_enabled
for p in providers {
assert!(p["id"].is_string(), "Provider should have an id");
assert!(p["models"].is_array(), "Provider should have models array");
assert!(p["is_enabled"].is_boolean(), "Provider should have is_enabled flag");
}
}
#[tokio::test]
async fn create_provider_with_invalid_name_returns_422() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-invalid@example.com").await;
let body = serde_json::json!({
"provider_name": "unknown_provider",
"display_name": "Unknown",
"models": [{"model_id": "m1", "display_name": "Model 1"}]
});
let (status, resp) = app
.post_with_session("/api/v1/admin/providers", &body, &session)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "Invalid provider_name should return 422");
assert_eq!(resp["error"], "validation_error");
}
#[tokio::test]
async fn create_provider_with_duplicate_name_returns_error() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-dup@example.com").await;
// "gemini" already exists from seed data
let body = serde_json::json!({
"provider_name": "gemini",
"display_name": "Google Gemini Duplicate",
"models": [{"model_id": "gemini-test", "display_name": "Gemini Test"}]
});
let (status, resp) = app
.post_with_session("/api/v1/admin/providers", &body, &session)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "Duplicate provider_name should return 400");
assert!(
resp["message"].as_str().unwrap_or("").contains("already exists"),
"Error message should mention provider already exists"
);
}
#[tokio::test]
async fn update_provider_changes_display_name_and_models() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-update@example.com").await;
// Get the gemini provider ID
let (status, providers) = app
.get_with_session("/api/v1/admin/providers", &session)
.await;
assert_eq!(status, StatusCode::OK);
let gemini = providers
.as_array()
.unwrap()
.iter()
.find(|p| p["provider_name"].as_str() == Some("gemini"))
.expect("Should find gemini provider");
let gemini_id = gemini["id"].as_str().unwrap();
// Update display_name and models
let update_body = serde_json::json!({
"display_name": "Google Gemini Updated",
"models": [
{"model_id": "gemini-3.0-pro", "display_name": "Gemini 3.0 Pro", "is_default": true}
]
});
let uri = format!("/api/v1/admin/providers/{}", gemini_id);
let (status, resp) = app
.put_with_session(&uri, &update_body, &session)
.await;
assert_eq!(status, StatusCode::OK, "Update should return 200");
assert_eq!(resp["display_name"], "Google Gemini Updated");
assert_eq!(resp["models"].as_array().unwrap().len(), 1);
assert_eq!(resp["models"][0]["model_id"], "gemini-3.0-pro");
}
#[tokio::test]
async fn update_provider_with_invalid_data_returns_422() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-updinvalid@example.com").await;
// Get the gemini provider ID
let (status, providers) = app
.get_with_session("/api/v1/admin/providers", &session)
.await;
assert_eq!(status, StatusCode::OK);
let gemini = providers
.as_array()
.unwrap()
.iter()
.find(|p| p["provider_name"].as_str() == Some("gemini"))
.expect("Should find gemini provider");
let gemini_id = gemini["id"].as_str().unwrap();
// Empty display_name should be rejected
let update_body = serde_json::json!({
"display_name": ""
});
let uri = format!("/api/v1/admin/providers/{}", gemini_id);
let (status, resp) = app
.put_with_session(&uri, &update_body, &session)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "Empty display_name should return 422");
assert_eq!(resp["error"], "validation_error");
}
#[tokio::test]
async fn delete_provider_returns_204() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-delete@example.com").await;
// Get the anthropic provider ID
let (status, providers) = app
.get_with_session("/api/v1/admin/providers", &session)
.await;
assert_eq!(status, StatusCode::OK);
let anthropic = providers
.as_array()
.unwrap()
.iter()
.find(|p| p["provider_name"].as_str() == Some("anthropic"))
.expect("Should find anthropic provider");
let anthropic_id = anthropic["id"].as_str().unwrap();
// Delete
let uri = format!("/api/v1/admin/providers/{}", anthropic_id);
let (status, _) = app.delete_with_session(&uri, &session).await;
assert_eq!(status, StatusCode::NO_CONTENT, "Delete should return 204");
// Verify it's gone
let (status2, providers2) = app
.get_with_session("/api/v1/admin/providers", &session)
.await;
assert_eq!(status2, StatusCode::OK);
let names: Vec<&str> = providers2
.as_array()
.unwrap()
.iter()
.filter_map(|p| p["provider_name"].as_str())
.collect();
assert!(!names.contains(&"anthropic"), "Anthropic should be deleted");
}
#[tokio::test]
async fn delete_nonexistent_provider_returns_404() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-del404@example.com").await;
let fake_id = uuid::Uuid::new_v4();
let uri = format!("/api/v1/admin/providers/{}", fake_id);
let (status, body) = app.delete_with_session(&uri, &session).await;
assert_eq!(status, StatusCode::NOT_FOUND, "Delete non-existent provider should return 404");
assert_eq!(body["error"], "not_found");
}
#[tokio::test]
async fn create_provider_with_empty_models_returns_422() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-empty-models@example.com").await;
// First delete openai so we can re-create it
let (_, providers) = app
.get_with_session("/api/v1/admin/providers", &session)
.await;
let openai = providers
.as_array()
.unwrap()
.iter()
.find(|p| p["provider_name"].as_str() == Some("openai"))
.expect("Should find openai provider");
let openai_id = openai["id"].as_str().unwrap();
app.delete_with_session(&format!("/api/v1/admin/providers/{}", openai_id), &session)
.await;
// Now try to create openai with empty models
let body = serde_json::json!({
"provider_name": "openai",
"display_name": "OpenAI",
"models": []
});
let (status, resp) = app
.post_with_session("/api/v1/admin/providers", &body, &session)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "Empty models should return 422");
assert_eq!(resp["error"], "validation_error");
}
#[tokio::test]
async fn create_provider_returns_with_id() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-create-ok@example.com").await;
// First delete openai so we can re-create it
let (_, providers) = app
.get_with_session("/api/v1/admin/providers", &session)
.await;
let openai = providers
.as_array()
.unwrap()
.iter()
.find(|p| p["provider_name"].as_str() == Some("openai"))
.expect("Should find openai provider");
let openai_id = openai["id"].as_str().unwrap();
app.delete_with_session(&format!("/api/v1/admin/providers/{}", openai_id), &session)
.await;
// Create openai with fresh data
let body = serde_json::json!({
"provider_name": "openai",
"display_name": "OpenAI Fresh",
"models": [
{"model_id": "gpt-4o", "display_name": "GPT-4o", "is_default": true}
]
});
let (status, resp) = app
.post_with_session("/api/v1/admin/providers", &body, &session)
.await;
assert_eq!(status, StatusCode::CREATED, "Create should return 201");
assert!(resp["id"].is_string(), "Response should have an id");
assert_eq!(resp["provider_name"], "openai");
assert_eq!(resp["display_name"], "OpenAI Fresh");
assert_eq!(resp["models"].as_array().unwrap().len(), 1);
assert_eq!(resp["is_enabled"], true);
}
// ═══════════════════════════════════════════════════════════════════════════
// Rate Limits
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn list_rate_limits_returns_seeded_limits() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-rl-list@example.com").await;
let (status, body) = app
.get_with_session("/api/v1/admin/rate-limits", &session)
.await;
assert_eq!(status, StatusCode::OK);
let limits = body.as_array().expect("Response should be an array");
assert!(limits.len() >= 3, "Should have at least 3 seeded rate limits");
let names: Vec<&str> = limits
.iter()
.filter_map(|l| l["provider_name"].as_str())
.collect();
assert!(names.contains(&"gemini"), "Should contain gemini rate limit");
assert!(names.contains(&"openai"), "Should contain openai rate limit");
assert!(names.contains(&"anthropic"), "Should contain anthropic rate limit");
// Check gemini has the expected seeded values
let gemini_limit = limits
.iter()
.find(|l| l["provider_name"].as_str() == Some("gemini"))
.unwrap();
assert_eq!(gemini_limit["max_requests"], 29);
assert_eq!(gemini_limit["time_window_seconds"], 60);
}
#[tokio::test]
async fn update_rate_limit_succeeds() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-rl-update@example.com").await;
let body = serde_json::json!({
"max_requests": 100,
"time_window_seconds": 120
});
let (status, resp) = app
.put_with_session("/api/v1/admin/rate-limits/gemini", &body, &session)
.await;
assert_eq!(status, StatusCode::OK, "Update rate limit should return 200");
assert_eq!(resp["provider_name"], "gemini");
assert_eq!(resp["max_requests"], 100);
assert_eq!(resp["time_window_seconds"], 120);
}
#[tokio::test]
async fn update_rate_limit_with_invalid_values_returns_422() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-rl-invalid@example.com").await;
// max_requests = 0 is invalid (must be 1..=1000)
let body = serde_json::json!({
"max_requests": 0,
"time_window_seconds": 60
});
let (status, resp) = app
.put_with_session("/api/v1/admin/rate-limits/gemini", &body, &session)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "Invalid max_requests should return 422");
assert_eq!(resp["error"], "validation_error");
// time_window_seconds > 3600 is invalid
let body2 = serde_json::json!({
"max_requests": 30,
"time_window_seconds": 5000
});
let (status2, resp2) = app
.put_with_session("/api/v1/admin/rate-limits/gemini", &body2, &session)
.await;
assert_eq!(status2, StatusCode::UNPROCESSABLE_ENTITY, "Invalid time_window_seconds should return 422");
assert_eq!(resp2["error"], "validation_error");
}
#[tokio::test]
async fn updated_rate_limits_reflected_in_get() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-rl-reflect@example.com").await;
// Update openai rate limit
let body = serde_json::json!({
"max_requests": 75,
"time_window_seconds": 300
});
let (status, _) = app
.put_with_session("/api/v1/admin/rate-limits/openai", &body, &session)
.await;
assert_eq!(status, StatusCode::OK);
// Now list and verify
let (status2, limits) = app
.get_with_session("/api/v1/admin/rate-limits", &session)
.await;
assert_eq!(status2, StatusCode::OK);
let openai_limit = limits
.as_array()
.unwrap()
.iter()
.find(|l| l["provider_name"].as_str() == Some("openai"))
.expect("Should find openai rate limit");
assert_eq!(openai_limit["max_requests"], 75);
assert_eq!(openai_limit["time_window_seconds"], 300);
}
// ═══════════════════════════════════════════════════════════════════════════
// User Management
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn list_users_returns_user_list() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-users@example.com").await;
// Create a few regular users
app.create_test_user("user-a@example.com").await;
app.create_test_user("user-b@example.com").await;
let (status, body) = app
.get_with_session("/api/v1/admin/users", &session)
.await;
assert_eq!(status, StatusCode::OK);
let users = body.as_array().expect("Response should be an array");
assert!(users.len() >= 3, "Should have at least 3 users (1 admin + 2 regular)");
let emails: Vec<&str> = users
.iter()
.filter_map(|u| u["email"].as_str())
.collect();
assert!(emails.contains(&"admin-users@example.com"));
assert!(emails.contains(&"user-a@example.com"));
assert!(emails.contains(&"user-b@example.com"));
// Each user should have the expected fields
for u in users {
assert!(u["id"].is_string(), "User should have an id");
assert!(u["email"].is_string(), "User should have an email");
assert!(u["role"].is_string(), "User should have a role");
}
}
#[tokio::test]
async fn promote_user_to_admin() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, admin_session) = app.create_admin_user("admin-promote@example.com").await;
let (target_user_id, _) = app.create_authenticated_user("target-promote@example.com").await;
let body = serde_json::json!({ "role": "admin" });
let uri = format!("/api/v1/admin/users/{}/role", target_user_id);
let (status, resp) = app
.put_with_session(&uri, &body, &admin_session)
.await;
assert_eq!(status, StatusCode::OK, "Promote to admin should return 200");
assert_eq!(resp["role"], "admin");
assert_eq!(resp["id"], target_user_id.to_string());
}
#[tokio::test]
async fn demote_admin_to_user() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, admin_session) = app.create_admin_user("admin-demote@example.com").await;
let (target_admin_id, _) = app.create_admin_user("target-demote@example.com").await;
let body = serde_json::json!({ "role": "user" });
let uri = format!("/api/v1/admin/users/{}/role", target_admin_id);
let (status, resp) = app
.put_with_session(&uri, &body, &admin_session)
.await;
assert_eq!(status, StatusCode::OK, "Demote to user should return 200");
assert_eq!(resp["role"], "user");
}
#[tokio::test]
async fn cannot_demote_self() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (admin_id, admin_session) = app.create_admin_user("admin-selfdemote@example.com").await;
let body = serde_json::json!({ "role": "user" });
let uri = format!("/api/v1/admin/users/{}/role", admin_id);
let (status, resp) = app
.put_with_session(&uri, &body, &admin_session)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "Self-demotion should return 400");
assert!(
resp["message"].as_str().unwrap_or("").contains("Cannot demote yourself"),
"Error message should mention self-demotion"
);
}
#[tokio::test]
async fn update_user_role_with_invalid_role_returns_422() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, admin_session) = app.create_admin_user("admin-badrole@example.com").await;
let (target_id, _) = app.create_authenticated_user("target-badrole@example.com").await;
let body = serde_json::json!({ "role": "superadmin" });
let uri = format!("/api/v1/admin/users/{}/role", target_id);
let (status, resp) = app
.put_with_session(&uri, &body, &admin_session)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "Invalid role should return 422");
assert_eq!(resp["error"], "validation_error");
}
// ═══════════════════════════════════════════════════════════════════════════
// Public Config
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn config_providers_without_auth_returns_401() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (status, body) = app.get("/api/v1/config/providers").await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "GET /config/providers without auth should return 401");
assert_eq!(body["error"], "unauthorized");
}
#[tokio::test]
async fn config_providers_with_auth_returns_enabled() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app.create_authenticated_user("config-user@example.com").await;
let (status, body) = app
.get_with_session("/api/v1/config/providers", &session)
.await;
assert_eq!(status, StatusCode::OK);
let providers = body.as_array().expect("Response should be an array");
// All seeded providers are enabled by default
assert!(providers.len() >= 3, "Should have at least 3 enabled providers");
// Verify the structure: should NOT have admin-only fields like `id` at root
// (actually this endpoint returns provider_name, display_name, models)
for p in providers {
assert!(p["provider_name"].is_string());
assert!(p["display_name"].is_string());
assert!(p["models"].is_array());
}
}
#[tokio::test]
async fn config_providers_excludes_disabled() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, admin_session) = app.create_admin_user("admin-disable@example.com").await;
let (_user_id, user_session) = app.create_authenticated_user("config-disable-user@example.com").await;
// Get the openai provider ID and disable it
let (_, providers) = app
.get_with_session("/api/v1/admin/providers", &admin_session)
.await;
let openai = providers
.as_array()
.unwrap()
.iter()
.find(|p| p["provider_name"].as_str() == Some("openai"))
.expect("Should find openai provider");
let openai_id = openai["id"].as_str().unwrap();
let disable_body = serde_json::json!({ "is_enabled": false });
let uri = format!("/api/v1/admin/providers/{}", openai_id);
let (status, _) = app
.put_with_session(&uri, &disable_body, &admin_session)
.await;
assert_eq!(status, StatusCode::OK);
// Now check public config as a regular user
let (status2, body2) = app
.get_with_session("/api/v1/config/providers", &user_session)
.await;
assert_eq!(status2, StatusCode::OK);
let provider_names: Vec<&str> = body2
.as_array()
.unwrap()
.iter()
.filter_map(|p| p["provider_name"].as_str())
.collect();
assert!(
!provider_names.contains(&"openai"),
"Disabled provider 'openai' should NOT appear in public config"
);
assert!(
provider_names.contains(&"gemini"),
"Enabled provider 'gemini' should still appear"
);
}
#[tokio::test]
async fn config_providers_admin_user_also_works() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_admin_id, session) = app.create_admin_user("admin-config@example.com").await;
let (status, body) = app
.get_with_session("/api/v1/config/providers", &session)
.await;
assert_eq!(status, StatusCode::OK, "Admin should also be able to access public config");
assert!(body.as_array().unwrap().len() >= 3);
}
// ═══════════════════════════════════════════════════════════════════════════
// Audit Log
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn audit_log_created_on_provider_creation() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (admin_id, session) = app.create_admin_user("admin-audit1@example.com").await;
// Delete openai so we can create it fresh
let (_, providers) = app
.get_with_session("/api/v1/admin/providers", &session)
.await;
let openai = providers
.as_array()
.unwrap()
.iter()
.find(|p| p["provider_name"].as_str() == Some("openai"))
.expect("Should find openai");
let openai_id = openai["id"].as_str().unwrap();
app.delete_with_session(&format!("/api/v1/admin/providers/{}", openai_id), &session)
.await;
// Create a new provider
let body = serde_json::json!({
"provider_name": "openai",
"display_name": "OpenAI Audit Test",
"models": [{"model_id": "gpt-4o", "display_name": "GPT-4o"}]
});
let (status, _) = app
.post_with_session("/api/v1/admin/providers", &body, &session)
.await;
assert_eq!(status, StatusCode::CREATED);
// Query the audit log directly from the database
let audit_entries: Vec<(String, Option<uuid::Uuid>, Option<String>)> = sqlx::query_as(
"SELECT action, admin_user_id, target_type FROM audit_log WHERE action = 'create_provider' ORDER BY created_at DESC LIMIT 1"
)
.fetch_all(&app.pool)
.await
.expect("Failed to query audit log");
assert!(!audit_entries.is_empty(), "Should have at least one create_provider audit entry");
let (action, audit_admin_id, target_type) = &audit_entries[0];
assert_eq!(action, "create_provider");
assert_eq!(*audit_admin_id, Some(admin_id));
assert_eq!(target_type.as_deref(), Some("provider"));
}
#[tokio::test]
async fn audit_log_created_on_role_change() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (admin_id, admin_session) = app.create_admin_user("admin-audit2@example.com").await;
let (target_id, _) = app.create_authenticated_user("target-audit2@example.com").await;
// Promote user to admin
let body = serde_json::json!({ "role": "admin" });
let uri = format!("/api/v1/admin/users/{}/role", target_id);
let (status, _) = app
.put_with_session(&uri, &body, &admin_session)
.await;
assert_eq!(status, StatusCode::OK);
// Query the audit log
let audit_entries: Vec<(String, Option<uuid::Uuid>, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT action, admin_user_id, target_type, target_id FROM audit_log WHERE action = 'update_user_role' ORDER BY created_at DESC LIMIT 1"
)
.fetch_all(&app.pool)
.await
.expect("Failed to query audit log");
assert!(!audit_entries.is_empty(), "Should have at least one update_user_role audit entry");
let (action, audit_admin_id, target_type, target_id_str) = &audit_entries[0];
assert_eq!(action, "update_user_role");
assert_eq!(*audit_admin_id, Some(admin_id));
assert_eq!(target_type.as_deref(), Some("user"));
assert_eq!(target_id_str.as_deref(), Some(target_id.to_string().as_str()));
}