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.

688 lines
23 KiB
Rust

//! 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_with_session("/api/v1/user/api-keys", "invalid-session-token").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<u8>,) = 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-...");
}