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.
646 lines
21 KiB
Rust
646 lines
21 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("/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<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"
|
|
);
|
|
}
|