mod common; use axum::http::StatusCode; use common::*; /// Comprehensive permission matrix test. /// Verifies that each role can only access the endpoints they are authorized for. /// This is the single most important security test for the system. // ═══════════════════════════════════════════════════════════════════ // Permission matrix: super_admin can access everything // ═══════════════════════════════════════════════════════════════════ #[tokio::test] async fn super_admin_can_access_all_protected_endpoints() { let (app, pool) = build_test_app().await; let sa = super_admin_token(&app, &pool, "sa_matrix").await; let get_endpoints: Vec<&str> = vec![ "/api/v1/auth/me", "/api/v1/accounts", "/api/v1/providers", "/api/v1/models", "/api/v1/relay/models", "/api/v1/relay/tasks", "/api/v1/roles", "/api/v1/logs/operations", "/api/v1/stats/dashboard", "/api/v1/knowledge/categories", "/api/v1/knowledge/analytics/overview", "/api/v1/billing/subscription", "/api/v1/billing/plans", "/api/v1/usage", "/api/v1/tokens", "/api/v1/keys", "/api/v1/prompts", "/api/v1/config/items", "/api/v1/agent-templates", "/api/v1/scheduler/tasks", "/api/v1/devices", ]; for path in &get_endpoints { let (status, body) = send(&app, get(path, &sa)).await; assert!( status != StatusCode::UNAUTHORIZED && status != StatusCode::FORBIDDEN, "super_admin should access GET {path}: got {status}, body={body}" ); } // POST endpoints that should not be auth-rejected (may fail at validation) let post_endpoints: Vec<(&str, serde_json::Value)> = vec![ ("/api/v1/providers", serde_json::json!({ "name": "test-prov", "display_name": "Test", "base_url": "https://test.com/v1" })), ]; for (path, body_val) in &post_endpoints { let (status, _) = send(&app, post(path, &sa, body_val.clone())).await; assert!( status != StatusCode::UNAUTHORIZED && status != StatusCode::FORBIDDEN, "super_admin should access POST {path}: got {status}" ); } } // ═══════════════════════════════════════════════════════════════════ // Permission matrix: regular user restrictions // ═══════════════════════════════════════════════════════════════════ #[tokio::test] async fn user_can_access_own_endpoints() { let (app, _pool) = build_test_app().await; let token = register_token(&app, "user_allowed").await; // Endpoints that a regular user SHOULD be able to access let allowed: Vec<&str> = vec![ "/api/v1/auth/me", "/api/v1/relay/models", "/api/v1/relay/tasks", "/api/v1/billing/plans", "/api/v1/billing/subscription", "/api/v1/usage", "/api/v1/tokens", "/api/v1/keys", "/api/v1/devices", ]; for path in &allowed { let (status, body) = send(&app, get(*path, &token)).await; assert!( status != StatusCode::UNAUTHORIZED && status != StatusCode::FORBIDDEN, "user should access GET {path}: got {status}, body={body}" ); } } #[tokio::test] async fn user_cannot_access_admin_endpoints() { let (app, _pool) = build_test_app().await; let token = register_token(&app, "user_blocked").await; // Endpoints that a regular user should NOT be able to access // Note: prompts, config/items, agent-templates, scheduler/tasks // are readable by all users (only writes are admin-only) let forbidden_get: Vec<&str> = vec![ "/api/v1/accounts", "/api/v1/roles", "/api/v1/logs/operations", "/api/v1/stats/dashboard", "/api/v1/knowledge/categories", ]; for path in &forbidden_get { let (status, resp_body) = send(&app, get(*path, &token)).await; assert!( status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED, "user should NOT access GET {path}: got {status}, body={resp_body}" ); } let forbidden_post: Vec<(&str, serde_json::Value)> = vec![ ("/api/v1/providers", serde_json::json!({ "name": "x", "display_name": "X", "base_url": "https://x.com" })), ("/api/v1/models", serde_json::json!({ "provider_id": "x", "model_id": "y", "alias": "Z" })), ("/api/v1/roles", serde_json::json!({ "id": "x", "name": "X", "permissions": [] })), ("/api/v1/knowledge/categories", serde_json::json!({ "name": "test" })), ("/api/v1/relay/tasks/nonexistent/retry", serde_json::json!({})), ]; for (path, body_val) in &forbidden_post { let (status, resp_body) = send(&app, post(*path, &token, body_val.clone())).await; assert!( status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED, "user should NOT access POST {path}: got {status}, body={resp_body}" ); } } // ═══════════════════════════════════════════════════════════════════ // Permission matrix: unauthenticated access // ═══════════════════════════════════════════════════════════════════ #[tokio::test] async fn public_endpoints_accessible_without_auth() { let (app, _pool) = build_test_app().await; // Public endpoints that should work without any token let public = vec![ ("/api/health", "GET", None), ("/api/v1/auth/register", "POST", Some(serde_json::json!({ "username": "publictest", "email": "public@test.io", "password": DEFAULT_PASSWORD }))), ("/api/v1/billing/plans", "GET", None), ]; for (path, method, body) in &public { let req = match (*body).clone() { Some(b) => post_public(*path, b), None => axum::http::Request::builder() .method(*method) .uri(*path) .body(axum::body::Body::empty()) .unwrap(), }; let (status, resp_body) = send(&app, req).await; assert_ne!( status, StatusCode::UNAUTHORIZED, "public endpoint {method} {path} should not require auth: got {status}, body={resp_body}" ); } } #[tokio::test] async fn protected_endpoints_reject_unauthenticated() { let (app, _pool) = build_test_app().await; let protected: Vec<&str> = vec![ "/api/v1/auth/me", "/api/v1/accounts", "/api/v1/providers", "/api/v1/models", "/api/v1/relay/models", "/api/v1/relay/tasks", "/api/v1/roles", "/api/v1/knowledge/categories", "/api/v1/prompts", "/api/v1/config/items", "/api/v1/usage", "/api/v1/tokens", "/api/v1/keys", "/api/v1/devices", ]; for path in &protected { let req = axum::http::Request::builder() .uri(*path) .body(axum::body::Body::empty()) .unwrap(); let (status, _) = send(&app, req).await; assert_eq!( status, StatusCode::UNAUTHORIZED, "unauthenticated request to {path} should be rejected with 401" ); } } // ═══════════════════════════════════════════════════════════════════ // API Token authentication // ═══════════════════════════════════════════════════════════════════ #[tokio::test] async fn api_token_works_like_jwt() { let (app, _pool) = build_test_app().await; let token = register_token(&app, "apitokenuser").await; // Create API token let (_, body) = send( &app, post("/api/v1/tokens", &token, serde_json::json!({ "name": "test-api-token", "permissions": ["model:read", "relay:use"] }), ), ).await; let raw_token = body["token"].as_str().unwrap(); // API token should authenticate for /me let (status, me) = send(&app, get("/api/v1/auth/me", raw_token)).await; assert_eq!(status, StatusCode::OK, "API token should authenticate: {me}"); assert_eq!(me["username"], "apitokenuser"); } #[tokio::test] async fn api_token_revoked_no_longer_works() { let (app, _pool) = build_test_app().await; let token = register_token(&app, "revokeapitoken").await; // Create + revoke let (_, create_body) = send( &app, post("/api/v1/tokens", &token, serde_json::json!({ "name": "to-revoke", "permissions": ["model:read"] }), ), ).await; let raw_token = create_body["token"].as_str().unwrap(); let token_id = create_body["id"].as_str().unwrap(); // Verify it works let (status, _) = send(&app, get("/api/v1/auth/me", raw_token)).await; assert_eq!(status, StatusCode::OK); // Revoke send(&app, delete(&format!("/api/v1/tokens/{token_id}"), &token)).await; // Should no longer work let (status, _) = send(&app, get("/api/v1/auth/me", raw_token)).await; assert_eq!(status, StatusCode::UNAUTHORIZED, "revoked API token should not authenticate"); }