//! Integration tests for user API key management endpoints (Phase 4). //! //! Tests: //! - GET /api/v1/user/api-keys — list user's API keys //! - POST /api/v1/user/api-keys — add/update an API key (encrypted) //! - DELETE /api/v1/user/api-keys/:provider — remove an API key //! - POST /api/v1/user/api-keys/:provider/test — test an API key //! //! Covers authentication, CRUD, encryption verification, ownership //! isolation, and key testing. //! //! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run. mod common; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; fn require_test_db() -> bool { std::env::var("TEST_DATABASE_URL").is_ok() } // ═══════════════════════════════════════════════════════════════════════════ // Authentication // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn get_api_keys_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/user/api-keys").await; assert_eq!( status, StatusCode::UNAUTHORIZED, "GET /user/api-keys without auth should return 401" ); assert_eq!(body["error"], "unauthorized"); } #[tokio::test] async fn post_api_keys_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let body = serde_json::json!({ "provider_name": "gemini", "api_key": "AIzaSyB-test-key-12345" }); let (status, resp) = app.post("/api/v1/user/api-keys", &body).await; assert_eq!( status, StatusCode::UNAUTHORIZED, "POST /user/api-keys without auth should return 401" ); assert_eq!(resp["error"], "unauthorized"); } #[tokio::test] async fn delete_api_key_without_auth_returns_401() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let req = Request::builder() .method(Method::DELETE) .uri("/api/v1/user/api-keys/gemini") .header("X-Requested-With", "XMLHttpRequest") .body(Body::empty()) .unwrap(); let response = app.raw_request(req).await; assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "DELETE /user/api-keys/:provider without auth should return 401" ); } // ═══════════════════════════════════════════════════════════════════════════ // CRUD — Basic operations // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn get_api_keys_returns_empty_list_initially() { 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("apikeys-empty@example.com") .await; let (status, body) = app .get_with_session("/api/v1/user/api-keys", &session) .await; assert_eq!(status, StatusCode::OK, "GET /user/api-keys should return 200"); let keys = body.as_array().expect("Response should be an array"); assert!(keys.is_empty(), "New user should have no API keys"); } #[tokio::test] async fn post_api_key_with_valid_gemini_key_succeeds() { 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("apikeys-create@example.com") .await; let body = serde_json::json!({ "provider_name": "gemini", "api_key": "AIzaSyB-test-key-12345" }); let (status, resp) = app .post_with_session("/api/v1/user/api-keys", &body, &session) .await; assert_eq!( status, StatusCode::OK, "POST /user/api-keys with valid gemini key should return 200" ); assert_eq!(resp["provider_name"], "gemini"); assert!(resp["id"].as_str().is_some(), "Response should contain an id"); assert!( resp["key_prefix"].as_str().is_some(), "Response should contain a key_prefix" ); assert!( resp["created_at"].as_str().is_some(), "Response should contain created_at" ); assert!( resp["updated_at"].as_str().is_some(), "Response should contain updated_at" ); } #[tokio::test] async fn post_then_get_shows_key_with_prefix_only() { 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("apikeys-prefix@example.com") .await; let raw_key = "AIzaSyB-test-key-12345"; let body = serde_json::json!({ "provider_name": "gemini", "api_key": raw_key }); let (create_status, _) = app .post_with_session("/api/v1/user/api-keys", &body, &session) .await; assert_eq!(create_status, StatusCode::OK); // List should show the key with prefix only let (list_status, list_body) = app .get_with_session("/api/v1/user/api-keys", &session) .await; assert_eq!(list_status, StatusCode::OK); let keys = list_body.as_array().expect("Response should be an array"); assert_eq!(keys.len(), 1, "Should have exactly one API key"); assert_eq!(keys[0]["provider_name"], "gemini"); let prefix = keys[0]["key_prefix"].as_str().unwrap(); assert_eq!(prefix, "AIzaSyB-...", "Prefix should be first 8 chars + '...'"); // The full key must NOT appear anywhere in the response let response_str = serde_json::to_string(&list_body).unwrap(); assert!( !response_str.contains(raw_key), "Full API key must NEVER be returned in the response" ); } #[tokio::test] async fn post_api_key_with_invalid_provider_returns_422() { 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("apikeys-invalid-provider@example.com") .await; let body = serde_json::json!({ "provider_name": "mistral", "api_key": "some-valid-length-key-1234" }); let (status, resp) = app .post_with_session("/api/v1/user/api-keys", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "Invalid provider should return 422" ); assert_eq!(resp["error"], "validation_error"); } #[tokio::test] async fn post_api_key_with_empty_api_key_returns_422() { 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("apikeys-empty-key@example.com") .await; let body = serde_json::json!({ "provider_name": "gemini", "api_key": "" }); let (status, resp) = app .post_with_session("/api/v1/user/api-keys", &body, &session) .await; assert_eq!( status, StatusCode::UNPROCESSABLE_ENTITY, "Empty api_key should return 422" ); assert_eq!(resp["error"], "validation_error"); } #[tokio::test] async fn post_api_key_upserts_existing_key() { 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("apikeys-upsert@example.com") .await; // Create the initial key let body1 = serde_json::json!({ "provider_name": "gemini", "api_key": "AIzaSyB-first-key-12345" }); let (status1, resp1) = app .post_with_session("/api/v1/user/api-keys", &body1, &session) .await; assert_eq!(status1, StatusCode::OK); let first_prefix = resp1["key_prefix"].as_str().unwrap().to_string(); // Upsert with a different key for the same provider let body2 = serde_json::json!({ "provider_name": "gemini", "api_key": "XYZnewky-second-key-67890" }); let (status2, resp2) = app .post_with_session("/api/v1/user/api-keys", &body2, &session) .await; assert_eq!(status2, StatusCode::OK); let second_prefix = resp2["key_prefix"].as_str().unwrap().to_string(); // Prefix should have changed assert_ne!( first_prefix, second_prefix, "After upsert, key_prefix should change" ); assert_eq!(second_prefix, "XYZnewky..."); // List should still have exactly one key (upsert, not duplicate) let (list_status, list_body) = app .get_with_session("/api/v1/user/api-keys", &session) .await; assert_eq!(list_status, StatusCode::OK); let keys = list_body.as_array().unwrap(); assert_eq!( keys.len(), 1, "Upsert should not create a duplicate; still one key" ); assert_eq!(keys[0]["key_prefix"], "XYZnewky..."); } #[tokio::test] async fn delete_api_key_removes_key_returns_204() { 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("apikeys-delete@example.com") .await; // Create a key let body = serde_json::json!({ "provider_name": "gemini", "api_key": "AIzaSyB-delete-key-12345" }); let (create_status, _) = app .post_with_session("/api/v1/user/api-keys", &body, &session) .await; assert_eq!(create_status, StatusCode::OK); // Delete it let (del_status, _) = app .delete_with_session("/api/v1/user/api-keys/gemini", &session) .await; assert_eq!( del_status, StatusCode::NO_CONTENT, "DELETE /user/api-keys/:provider should return 204" ); } #[tokio::test] async fn delete_then_get_shows_empty_list() { 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("apikeys-delete-list@example.com") .await; // Create a key let body = serde_json::json!({ "provider_name": "gemini", "api_key": "AIzaSyB-dellist-key-12345" }); let (create_status, _) = app .post_with_session("/api/v1/user/api-keys", &body, &session) .await; assert_eq!(create_status, StatusCode::OK); // Delete it let (del_status, _) = app .delete_with_session("/api/v1/user/api-keys/gemini", &session) .await; assert_eq!(del_status, StatusCode::NO_CONTENT); // List should be empty again let (list_status, list_body) = app .get_with_session("/api/v1/user/api-keys", &session) .await; assert_eq!(list_status, StatusCode::OK); let keys = list_body.as_array().unwrap(); assert!(keys.is_empty(), "After deleting, API key list should be empty"); } // ═══════════════════════════════════════════════════════════════════════════ // Encryption Verification // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn stored_encrypted_key_is_not_plaintext() { 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("apikeys-encryption@example.com") .await; let raw_key = "AIzaSyB-plaintext-check-12345"; let body = serde_json::json!({ "provider_name": "gemini", "api_key": raw_key }); let (create_status, _) = app .post_with_session("/api/v1/user/api-keys", &body, &session) .await; assert_eq!(create_status, StatusCode::OK); // Query the database directly to inspect the stored encrypted_key let row: (Vec,) = sqlx::query_as( "SELECT encrypted_key FROM user_api_keys WHERE user_id = $1 AND provider_name = 'gemini'", ) .bind(user_id) .fetch_one(&app.pool) .await .expect("Should find the stored key in the database"); let stored_bytes = row.0; // The stored bytes must NOT equal the raw plaintext bytes assert_ne!( stored_bytes, raw_key.as_bytes(), "The encrypted_key stored in DB must NOT be the plaintext API key" ); // Extra check: the stored ciphertext should not contain the plaintext as a substring let stored_str = String::from_utf8_lossy(&stored_bytes); assert!( !stored_str.contains(raw_key), "The encrypted_key must not contain the plaintext key as a substring" ); } #[tokio::test] async fn key_prefix_matches_first_8_chars_of_original_key() { 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("apikeys-prefix-check@example.com") .await; let raw_key = "sk-proj-abc123def456ghi789"; let body = serde_json::json!({ "provider_name": "openai", "api_key": raw_key }); let (create_status, resp) = app .post_with_session("/api/v1/user/api-keys", &body, &session) .await; assert_eq!(create_status, StatusCode::OK); let prefix = resp["key_prefix"].as_str().unwrap(); let expected_prefix = format!("{}...", &raw_key[..8]); assert_eq!( prefix, expected_prefix, "key_prefix should be the first 8 chars of the original key followed by '...'" ); } // ═══════════════════════════════════════════════════════════════════════════ // Ownership Isolation // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn user_a_keys_not_visible_to_user_b() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_a_id, session_a) = app .create_authenticated_user("user-a-keys@example.com") .await; let (_user_b_id, session_b) = app .create_authenticated_user("user-b-keys@example.com") .await; // User A creates an API key let body = serde_json::json!({ "provider_name": "gemini", "api_key": "AIzaSyB-user-a-key-12345" }); let (status, _) = app .post_with_session("/api/v1/user/api-keys", &body, &session_a) .await; assert_eq!(status, StatusCode::OK); // User B lists API keys -> should be empty let (list_status, list_body) = app .get_with_session("/api/v1/user/api-keys", &session_b) .await; assert_eq!(list_status, StatusCode::OK); let keys = list_body.as_array().unwrap(); assert!( keys.is_empty(), "User B should NOT see User A's API keys" ); // User A lists API keys -> should see their key let (_, list_body_a) = app .get_with_session("/api/v1/user/api-keys", &session_a) .await; let keys_a = list_body_a.as_array().unwrap(); assert_eq!(keys_a.len(), 1, "User A should see their own API key"); } #[tokio::test] async fn user_b_cannot_delete_user_a_key() { if !require_test_db() { eprintln!("SKIPPED: TEST_DATABASE_URL not set"); return; } let app = common::TestApp::new().await; let (_user_a_id, session_a) = app .create_authenticated_user("owner-a-keys@example.com") .await; let (_user_b_id, session_b) = app .create_authenticated_user("attacker-b-keys@example.com") .await; // User A creates a gemini API key let body = serde_json::json!({ "provider_name": "gemini", "api_key": "AIzaSyB-owner-a-key-12345" }); let (status, _) = app .post_with_session("/api/v1/user/api-keys", &body, &session_a) .await; assert_eq!(status, StatusCode::OK); // User B tries to delete User A's gemini key -> should get 404 let (del_status, del_body) = app .delete_with_session("/api/v1/user/api-keys/gemini", &session_b) .await; assert_eq!( del_status, StatusCode::NOT_FOUND, "Deleting another user's API key should return 404, not 403" ); assert_eq!(del_body["error"], "not_found"); // Verify User A's key is still there let (_, list_body) = app .get_with_session("/api/v1/user/api-keys", &session_a) .await; let keys = list_body.as_array().unwrap(); assert_eq!( keys.len(), 1, "User A's API key should NOT have been deleted" ); } // ═══════════════════════════════════════════════════════════════════════════ // Test Endpoint // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn test_key_without_configured_key_returns_error() { 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("apikeys-test-nokey@example.com") .await; // Try to test a key that hasn't been configured let empty_body = serde_json::json!({}); let (status, resp) = app .post_with_session("/api/v1/user/api-keys/gemini/test", &empty_body, &session) .await; assert_eq!( status, StatusCode::NOT_FOUND, "Testing a non-existent key should return 404" ); assert_eq!(resp["error"], "not_found"); } #[tokio::test] async fn test_key_with_configured_key_returns_test_result() { 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("apikeys-test-withkey@example.com") .await; // First, store an API key (it won't be a real key, so the test will fail // but the endpoint should NOT crash -- it should return {success, message}) let body = serde_json::json!({ "provider_name": "gemini", "api_key": "AIzaSyB-fake-test-key-12345" }); let (create_status, _) = app .post_with_session("/api/v1/user/api-keys", &body, &session) .await; assert_eq!(create_status, StatusCode::OK); // Now test the key -- the call will likely fail (invalid key) but the // endpoint should return 200 with a JSON body containing success + message let empty_body = serde_json::json!({}); let (status, resp) = app .post_with_session("/api/v1/user/api-keys/gemini/test", &empty_body, &session) .await; assert_eq!( status, StatusCode::OK, "Test endpoint should return 200 even if the key is invalid (failure is in the JSON body)" ); // The response must have "success" and "message" fields assert!( resp.get("success").is_some(), "Test response should contain a 'success' field" ); assert!( resp.get("message").is_some(), "Test response should contain a 'message' field" ); assert!( resp["success"].is_boolean(), "'success' field should be a boolean" ); assert!( resp["message"].is_string(), "'message' field should be a string" ); } // ═══════════════════════════════════════════════════════════════════════════ // Key Prefix DB Constraint // ═══════════════════════════════════════════════════════════════════════════ #[tokio::test] async fn key_prefix_full_length_stored_in_db() { 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("apikeys-prefix-db@example.com") .await; // Use a key long enough to produce max prefix length (8 chars + "..." = 11 chars) let body = serde_json::json!({ "provider_name": "openai", "api_key": "sk-proj-abcdefghijklmnopqrstuvwxyz123456" }); let (status, resp) = app .post_with_session("/api/v1/user/api-keys", &body, &session) .await; assert_eq!( status, StatusCode::OK, "Storing a key with 11-char prefix (8+...) must not violate DB constraints" ); let prefix = resp["key_prefix"].as_str().unwrap(); assert_eq!(prefix, "sk-proj-...", "key_prefix should be first 8 chars + '...'"); // Verify it round-trips through GET let (list_status, list_resp) = app .get_with_session("/api/v1/user/api-keys", &session) .await; assert_eq!(list_status, StatusCode::OK); let keys = list_resp.as_array().unwrap(); assert_eq!(keys[0]["key_prefix"], "sk-proj-..."); }