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
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()));
|
|
}
|