Phase 4: LLM provider abstraction with Gemini, user API key encryption
Backend: - LlmProvider async trait with generate_search_pass/generate_rewrite_pass - GeminiProvider: googleSearch grounding (pass 1), structured JSON output (pass 2) - AES-256-GCM encryption for user API keys at rest (per-key random nonces) - MasterKey with zeroize-on-drop (no Clone to prevent unzeroized copies) - User API key endpoints: list (prefix only), create/update, delete, test - Dynamic category schema builder for structured LLM output - Provider factory (Gemini implemented, OpenAI/Anthropic stubbed for Phase 6) - 37 new unit tests (encryption, schema, Gemini serialization, factory) - 17 integration tests (CRUD, encryption verification, ownership isolation) Frontend: - ApiKeyManager component: per-provider key management in Settings - Password input with show/hide toggle, key prefix display (monospace) - Test button validates key with minimal LLM call - Status badges (configured/not configured) - 11 new tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>master
parent
5abbf9b9ad
commit
439e547367
@ -0,0 +1,17 @@
|
|||||||
|
-- User API keys table: stores encrypted LLM provider API keys per user.
|
||||||
|
-- Each user can have at most one key per provider.
|
||||||
|
|
||||||
|
CREATE TABLE user_api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
provider_name VARCHAR(50) NOT NULL,
|
||||||
|
encrypted_key BYTEA NOT NULL,
|
||||||
|
nonce BYTEA NOT NULL,
|
||||||
|
key_prefix VARCHAR(10) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE(user_id, provider_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_api_keys_user_id ON user_api_keys(user_id);
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
//! Database queries for the `user_api_keys` table.
|
||||||
|
//!
|
||||||
|
//! All queries enforce ownership isolation via `user_id`.
|
||||||
|
//! Encrypted key material is stored/retrieved as raw bytes (BYTEA).
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
use crate::models::api_key::UserApiKey;
|
||||||
|
|
||||||
|
/// List all API keys for a given user.
|
||||||
|
///
|
||||||
|
/// Returns the encrypted keys (callers must decrypt as needed).
|
||||||
|
/// Ordered by provider name for consistent display.
|
||||||
|
pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result<Vec<UserApiKey>, AppError> {
|
||||||
|
let keys = sqlx::query_as::<_, UserApiKey>(
|
||||||
|
r#"
|
||||||
|
SELECT id, user_id, provider_name, encrypted_key, nonce, key_prefix, created_at, updated_at
|
||||||
|
FROM user_api_keys
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY provider_name ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific API key for a user and provider.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the user has no key for the given provider.
|
||||||
|
pub async fn get_for_user_and_provider(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
provider_name: &str,
|
||||||
|
) -> Result<Option<UserApiKey>, AppError> {
|
||||||
|
let key = sqlx::query_as::<_, UserApiKey>(
|
||||||
|
r#"
|
||||||
|
SELECT id, user_id, provider_name, encrypted_key, nonce, key_prefix, created_at, updated_at
|
||||||
|
FROM user_api_keys
|
||||||
|
WHERE user_id = $1 AND provider_name = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(provider_name)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or update an API key for a user and provider.
|
||||||
|
///
|
||||||
|
/// Uses `ON CONFLICT` to upsert: if the user already has a key for
|
||||||
|
/// the given provider, the encrypted key, nonce, prefix, and timestamp
|
||||||
|
/// are updated.
|
||||||
|
pub async fn upsert(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
provider_name: &str,
|
||||||
|
encrypted_key: &[u8],
|
||||||
|
nonce: &[u8],
|
||||||
|
key_prefix: &str,
|
||||||
|
) -> Result<UserApiKey, AppError> {
|
||||||
|
let key = sqlx::query_as::<_, UserApiKey>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO user_api_keys (user_id, provider_name, encrypted_key, nonce, key_prefix)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (user_id, provider_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
encrypted_key = EXCLUDED.encrypted_key,
|
||||||
|
nonce = EXCLUDED.nonce,
|
||||||
|
key_prefix = EXCLUDED.key_prefix,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, user_id, provider_name, encrypted_key, nonce, key_prefix, created_at, updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(provider_name)
|
||||||
|
.bind(encrypted_key)
|
||||||
|
.bind(nonce)
|
||||||
|
.bind(key_prefix)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an API key for a user and provider.
|
||||||
|
///
|
||||||
|
/// Returns `true` if a row was deleted, `false` if no matching key was found.
|
||||||
|
pub async fn delete(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
provider_name: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
DELETE FROM user_api_keys
|
||||||
|
WHERE user_id = $1 AND provider_name = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(provider_name)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
@ -0,0 +1,218 @@
|
|||||||
|
//! User API key handlers.
|
||||||
|
//!
|
||||||
|
//! - `GET /api/v1/user/api-keys` — list user's keys (prefix only, never full key)
|
||||||
|
//! - `POST /api/v1/user/api-keys` — add/update a key (encrypted before storage)
|
||||||
|
//! - `DELETE /api/v1/user/api-keys/:provider` — remove a key
|
||||||
|
//! - `POST /api/v1/user/api-keys/:provider/test` — test a key with a minimal LLM call
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::app_state::AppState;
|
||||||
|
use crate::db;
|
||||||
|
use crate::errors::AppError;
|
||||||
|
use crate::middleware::auth::AuthUser;
|
||||||
|
use crate::models::api_key::{extract_key_prefix, ApiKeyResponse, CreateApiKeyRequest};
|
||||||
|
use crate::services::encryption::{self, MasterKey};
|
||||||
|
use crate::services::llm::factory;
|
||||||
|
|
||||||
|
/// `GET /api/v1/user/api-keys`
|
||||||
|
///
|
||||||
|
/// Returns all API keys belonging to the authenticated user.
|
||||||
|
/// Only the key prefix is included — the full key is NEVER returned.
|
||||||
|
pub async fn list(
|
||||||
|
auth_user: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let keys = db::api_keys::list_for_user(&state.pool, auth_user.id).await?;
|
||||||
|
let response: Vec<ApiKeyResponse> = keys.into_iter().map(ApiKeyResponse::from).collect();
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/user/api-keys`
|
||||||
|
///
|
||||||
|
/// Adds or updates an API key for the authenticated user.
|
||||||
|
/// The key is encrypted with AES-256-GCM before storage.
|
||||||
|
/// A prefix of the first 8 characters is stored for display.
|
||||||
|
pub async fn create(
|
||||||
|
auth_user: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<CreateApiKeyRequest>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
body.validate().map_err(AppError::Validation)?;
|
||||||
|
|
||||||
|
let master_key = MasterKey::from_hex(&state.config.master_encryption_key)?;
|
||||||
|
|
||||||
|
// Extract prefix before encryption (first 8 chars + "...")
|
||||||
|
let key_prefix = extract_key_prefix(&body.api_key);
|
||||||
|
|
||||||
|
// Encrypt the raw API key
|
||||||
|
let (encrypted_key, nonce) = encryption::encrypt(&master_key, &body.api_key)?;
|
||||||
|
|
||||||
|
// Upsert: insert or update the key for this user+provider
|
||||||
|
let saved_key = db::api_keys::upsert(
|
||||||
|
&state.pool,
|
||||||
|
auth_user.id,
|
||||||
|
&body.provider_name,
|
||||||
|
&encrypted_key,
|
||||||
|
&nonce,
|
||||||
|
&key_prefix,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let response = ApiKeyResponse::from(saved_key);
|
||||||
|
Ok((StatusCode::OK, Json(response)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/v1/user/api-keys/:provider`
|
||||||
|
///
|
||||||
|
/// Removes an API key for the authenticated user and given provider.
|
||||||
|
/// Returns 404 if no key exists for that provider.
|
||||||
|
pub async fn delete(
|
||||||
|
auth_user: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(provider): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let deleted = db::api_keys::delete(&state.pool, auth_user.id, &provider).await?;
|
||||||
|
|
||||||
|
if !deleted {
|
||||||
|
return Err(AppError::NotFound(format!(
|
||||||
|
"No API key found for provider '{}'",
|
||||||
|
provider
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response body for the test endpoint.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct TestResult {
|
||||||
|
success: bool,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/user/api-keys/:provider/test`
|
||||||
|
///
|
||||||
|
/// Tests the user's API key by making a minimal LLM call.
|
||||||
|
/// Decrypts the stored key, creates a provider instance, and sends
|
||||||
|
/// a simple request to verify the key works.
|
||||||
|
pub async fn test_key(
|
||||||
|
auth_user: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(provider): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
// Look up the stored key for this user+provider
|
||||||
|
let stored_key = db::api_keys::get_for_user_and_provider(
|
||||||
|
&state.pool,
|
||||||
|
auth_user.id,
|
||||||
|
&provider,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::NotFound(format!("No API key found for provider '{}'", provider))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Decrypt the key
|
||||||
|
let master_key = MasterKey::from_hex(&state.config.master_encryption_key)?;
|
||||||
|
let decrypted_key =
|
||||||
|
encryption::decrypt(&master_key, &stored_key.encrypted_key, &stored_key.nonce)?;
|
||||||
|
|
||||||
|
// Create a provider instance
|
||||||
|
let llm_provider = factory::create_provider(&provider, decrypted_key, &state.http_client)?;
|
||||||
|
|
||||||
|
// Make a minimal test call using the rewrite pass (no web search needed)
|
||||||
|
let test_schema = serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"greeting": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["greeting"]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use a simple, deterministic prompt for testing
|
||||||
|
// We need to pick a default model for the test. Use the first available
|
||||||
|
// model from the admin config, or a sensible default.
|
||||||
|
let test_model = get_default_model_for_provider(&state, &provider).await?;
|
||||||
|
|
||||||
|
let result = llm_provider
|
||||||
|
.generate_rewrite_pass(
|
||||||
|
&test_model,
|
||||||
|
"You are a test assistant. Respond in JSON as instructed.",
|
||||||
|
"Say hello in one word.",
|
||||||
|
&test_schema,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => Ok(Json(TestResult {
|
||||||
|
success: true,
|
||||||
|
message: "API key is valid and working".into(),
|
||||||
|
})),
|
||||||
|
Err(e) => {
|
||||||
|
// Return the error as a test failure, not a server error
|
||||||
|
let message = match &e {
|
||||||
|
AppError::BadRequest(msg) => msg.clone(),
|
||||||
|
AppError::RateLimited(msg) => msg.clone(),
|
||||||
|
_ => "API key test failed. The key may be invalid or the provider may be unavailable.".into(),
|
||||||
|
};
|
||||||
|
Ok(Json(TestResult {
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a default model identifier for a provider from the admin config.
|
||||||
|
///
|
||||||
|
/// Looks up the admin provider configuration for the given provider name
|
||||||
|
/// and returns the default model, or the first available model.
|
||||||
|
async fn get_default_model_for_provider(
|
||||||
|
state: &AppState,
|
||||||
|
provider_name: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let providers = db::providers::list_all(&state.pool).await?;
|
||||||
|
|
||||||
|
let provider = providers
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.provider_name == provider_name && p.is_enabled);
|
||||||
|
|
||||||
|
match provider {
|
||||||
|
Some(p) => {
|
||||||
|
// Find the default model, or use the first one
|
||||||
|
let model = p
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.is_default)
|
||||||
|
.or_else(|| p.models.first())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::BadRequest(format!(
|
||||||
|
"No models configured for provider '{}'",
|
||||||
|
provider_name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(model.model_id.clone())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Fallback defaults if provider isn't configured yet
|
||||||
|
let default_model = match provider_name {
|
||||||
|
"gemini" => "gemini-2.5-flash",
|
||||||
|
"openai" => "gpt-4o-mini",
|
||||||
|
"anthropic" => "claude-sonnet-4-20250514",
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Unknown provider: '{}'",
|
||||||
|
provider_name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(default_model.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,179 @@
|
|||||||
|
//! User API key model and request/response types.
|
||||||
|
//!
|
||||||
|
//! Represents encrypted LLM provider API keys stored per user.
|
||||||
|
//! The full key is NEVER returned via the API — only a prefix for display.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A user API key record from the database.
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct UserApiKey {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub provider_name: String,
|
||||||
|
pub encrypted_key: Vec<u8>,
|
||||||
|
pub nonce: Vec<u8>,
|
||||||
|
pub key_prefix: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Known provider names for validation.
|
||||||
|
const VALID_PROVIDERS: &[&str] = &["gemini", "openai", "anthropic"];
|
||||||
|
|
||||||
|
/// Request body for `POST /api/v1/user/api-keys`.
|
||||||
|
///
|
||||||
|
/// Contains the raw (unencrypted) API key from the user.
|
||||||
|
/// The key is encrypted before storage and never stored in plaintext.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateApiKeyRequest {
|
||||||
|
pub provider_name: String,
|
||||||
|
pub api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateApiKeyRequest {
|
||||||
|
/// Validate the API key creation request.
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
let provider = self.provider_name.trim();
|
||||||
|
if provider.is_empty() {
|
||||||
|
return Err("Provider name cannot be empty".into());
|
||||||
|
}
|
||||||
|
if !VALID_PROVIDERS.contains(&provider) {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid provider '{}'. Must be one of: {}",
|
||||||
|
provider,
|
||||||
|
VALID_PROVIDERS.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = self.api_key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err("API key cannot be empty".into());
|
||||||
|
}
|
||||||
|
if key.len() < 8 {
|
||||||
|
return Err("API key must be at least 8 characters".into());
|
||||||
|
}
|
||||||
|
if key.len() > 500 {
|
||||||
|
return Err("API key must be at most 500 characters".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `POST /api/v1/user/api-keys/:provider/test`.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TestApiKeyRequest {
|
||||||
|
pub provider_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for API key endpoints.
|
||||||
|
///
|
||||||
|
/// NEVER includes the full key — only a prefix for display purposes.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ApiKeyResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub provider_name: String,
|
||||||
|
pub key_prefix: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UserApiKey> for ApiKeyResponse {
|
||||||
|
fn from(k: UserApiKey) -> Self {
|
||||||
|
Self {
|
||||||
|
id: k.id,
|
||||||
|
provider_name: k.provider_name,
|
||||||
|
key_prefix: k.key_prefix,
|
||||||
|
created_at: k.created_at,
|
||||||
|
updated_at: k.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a display prefix from the raw API key.
|
||||||
|
///
|
||||||
|
/// Takes the first 8 characters followed by "..." for display.
|
||||||
|
/// If the key is shorter than 8 characters, uses whatever is available.
|
||||||
|
pub fn extract_key_prefix(api_key: &str) -> String {
|
||||||
|
let prefix_len = api_key.len().min(8);
|
||||||
|
format!("{}...", &api_key[..prefix_len])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_create_request() {
|
||||||
|
let req = CreateApiKeyRequest {
|
||||||
|
provider_name: "gemini".into(),
|
||||||
|
api_key: "AIzaSyB-test-key-12345".into(),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_provider() {
|
||||||
|
let req = CreateApiKeyRequest {
|
||||||
|
provider_name: "mistral".into(),
|
||||||
|
api_key: "some-key-value-1234".into(),
|
||||||
|
};
|
||||||
|
let err = req.validate().unwrap_err();
|
||||||
|
assert!(err.contains("Invalid provider"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_provider() {
|
||||||
|
let req = CreateApiKeyRequest {
|
||||||
|
provider_name: " ".into(),
|
||||||
|
api_key: "some-key-value-1234".into(),
|
||||||
|
};
|
||||||
|
let err = req.validate().unwrap_err();
|
||||||
|
assert!(err.contains("cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_api_key() {
|
||||||
|
let req = CreateApiKeyRequest {
|
||||||
|
provider_name: "gemini".into(),
|
||||||
|
api_key: "".into(),
|
||||||
|
};
|
||||||
|
let err = req.validate().unwrap_err();
|
||||||
|
assert!(err.contains("cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_short_api_key() {
|
||||||
|
let req = CreateApiKeyRequest {
|
||||||
|
provider_name: "gemini".into(),
|
||||||
|
api_key: "short".into(),
|
||||||
|
};
|
||||||
|
let err = req.validate().unwrap_err();
|
||||||
|
assert!(err.contains("at least 8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_prefix_extraction() {
|
||||||
|
assert_eq!(extract_key_prefix("AIzaSyB-test-key"), "AIzaSyB-...");
|
||||||
|
assert_eq!(extract_key_prefix("sk-proj-abc123def456"), "sk-proj-...");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_key_prefix_exactly_8_chars() {
|
||||||
|
assert_eq!(extract_key_prefix("12345678"), "12345678...");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_valid_providers() {
|
||||||
|
for provider in &["gemini", "openai", "anthropic"] {
|
||||||
|
let req = CreateApiKeyRequest {
|
||||||
|
provider_name: provider.to_string(),
|
||||||
|
api_key: "valid-key-12345678".into(),
|
||||||
|
};
|
||||||
|
assert!(req.validate().is_ok(), "Provider '{}' should be valid", provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
//! AES-256-GCM encryption service for user API keys.
|
||||||
|
//!
|
||||||
|
//! Provides authenticated encryption with unique random nonces.
|
||||||
|
//! The master key is loaded from the `MASTER_ENCRYPTION_KEY` env var
|
||||||
|
//! (64 hex characters = 32 bytes).
|
||||||
|
|
||||||
|
use aes_gcm::aead::{Aead, OsRng};
|
||||||
|
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit, Nonce};
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// Master encryption key for AES-256-GCM.
|
||||||
|
///
|
||||||
|
/// Holds the raw 32-byte key in memory. The key bytes are zeroized on drop.
|
||||||
|
/// Intentionally does NOT derive `Clone` — clones would not be zeroized,
|
||||||
|
/// leaving key material in memory. Use references (`&MasterKey`) instead.
|
||||||
|
pub struct MasterKey {
|
||||||
|
key_bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MasterKey {
|
||||||
|
/// Create a `MasterKey` from a 64-character hex string (32 bytes).
|
||||||
|
///
|
||||||
|
/// Returns an error if the hex string is malformed or the wrong length.
|
||||||
|
pub fn from_hex(hex_str: &str) -> Result<Self, AppError> {
|
||||||
|
let key_bytes = hex::decode(hex_str).map_err(|e| {
|
||||||
|
AppError::Internal(anyhow::anyhow!(
|
||||||
|
"Failed to decode master encryption key: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if key_bytes.len() != 32 {
|
||||||
|
return Err(AppError::Internal(anyhow::anyhow!(
|
||||||
|
"Master encryption key must be exactly 32 bytes (64 hex chars), got {} bytes",
|
||||||
|
key_bytes.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { key_bytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw key bytes (for use with AES-256-GCM).
|
||||||
|
fn as_bytes(&self) -> &[u8] {
|
||||||
|
&self.key_bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MasterKey {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.key_bytes.zeroize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt plaintext using AES-256-GCM with a random 12-byte nonce.
|
||||||
|
///
|
||||||
|
/// Returns `(ciphertext, nonce)`. The nonce must be stored alongside
|
||||||
|
/// the ciphertext for decryption.
|
||||||
|
pub fn encrypt(master_key: &MasterKey, plaintext: &str) -> Result<(Vec<u8>, Vec<u8>), AppError> {
|
||||||
|
let key = Key::<Aes256Gcm>::from_slice(master_key.as_bytes());
|
||||||
|
let cipher = Aes256Gcm::new(key);
|
||||||
|
|
||||||
|
// Generate a unique 12-byte nonce using OS randomness
|
||||||
|
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||||
|
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(&nonce, plaintext.as_bytes())
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Encryption failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok((ciphertext, nonce.to_vec()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt ciphertext using AES-256-GCM with the provided nonce.
|
||||||
|
///
|
||||||
|
/// Returns the decrypted plaintext string. Returns an error if the key
|
||||||
|
/// is wrong, the data is corrupted, or the nonce doesn't match.
|
||||||
|
pub fn decrypt(
|
||||||
|
master_key: &MasterKey,
|
||||||
|
ciphertext: &[u8],
|
||||||
|
nonce_bytes: &[u8],
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let key = Key::<Aes256Gcm>::from_slice(master_key.as_bytes());
|
||||||
|
let cipher = Aes256Gcm::new(key);
|
||||||
|
|
||||||
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
|
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|_| AppError::Internal(anyhow::anyhow!("Decryption failed: invalid key or corrupted data")))?;
|
||||||
|
|
||||||
|
String::from_utf8(plaintext)
|
||||||
|
.map_err(|_| AppError::Internal(anyhow::anyhow!("Decrypted data is not valid UTF-8")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Helper: create a valid test master key (64 hex chars = 32 bytes).
|
||||||
|
fn test_master_key() -> MasterKey {
|
||||||
|
MasterKey::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_roundtrip() {
|
||||||
|
let key = test_master_key();
|
||||||
|
let plaintext = "AIzaSyB-test-key-12345";
|
||||||
|
|
||||||
|
let (ciphertext, nonce) = encrypt(&key, plaintext).unwrap();
|
||||||
|
let decrypted = decrypt(&key, &ciphertext, &nonce).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_nonces_each_time() {
|
||||||
|
let key = test_master_key();
|
||||||
|
let plaintext = "same-key-value";
|
||||||
|
|
||||||
|
let (_, nonce1) = encrypt(&key, plaintext).unwrap();
|
||||||
|
let (_, nonce2) = encrypt(&key, plaintext).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(nonce1, nonce2, "Each encryption must produce a unique nonce");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_key_fails() {
|
||||||
|
let key1 = test_master_key();
|
||||||
|
let key2 = MasterKey::from_hex(
|
||||||
|
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let plaintext = "secret-api-key";
|
||||||
|
let (ciphertext, nonce) = encrypt(&key1, plaintext).unwrap();
|
||||||
|
|
||||||
|
let result = decrypt(&key2, &ciphertext, &nonce);
|
||||||
|
assert!(result.is_err(), "Decryption with wrong key must fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupted_data_fails() {
|
||||||
|
let key = test_master_key();
|
||||||
|
let plaintext = "secret-api-key";
|
||||||
|
|
||||||
|
let (mut ciphertext, nonce) = encrypt(&key, plaintext).unwrap();
|
||||||
|
|
||||||
|
// Flip a byte in the ciphertext
|
||||||
|
if let Some(byte) = ciphertext.first_mut() {
|
||||||
|
*byte ^= 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = decrypt(&key, &ciphertext, &nonce);
|
||||||
|
assert!(result.is_err(), "Decryption of corrupted data must fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_hex_key_rejected() {
|
||||||
|
let result = MasterKey::from_hex("not-valid-hex");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_length_key_rejected() {
|
||||||
|
// 32 hex chars = 16 bytes, too short
|
||||||
|
let result = MasterKey::from_hex("0123456789abcdef0123456789abcdef");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonce_is_12_bytes() {
|
||||||
|
let key = test_master_key();
|
||||||
|
let (_, nonce) = encrypt(&key, "test").unwrap();
|
||||||
|
assert_eq!(nonce.len(), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_plaintext_roundtrip() {
|
||||||
|
let key = test_master_key();
|
||||||
|
let (ciphertext, nonce) = encrypt(&key, "").unwrap();
|
||||||
|
let decrypted = decrypt(&key, &ciphertext, &nonce).unwrap();
|
||||||
|
assert_eq!(decrypted, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
//! Provider factory: creates the correct `LlmProvider` implementation
|
||||||
|
//! based on the provider name and the user's decrypted API key.
|
||||||
|
|
||||||
|
use super::gemini::GeminiProvider;
|
||||||
|
use super::LlmProvider;
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// Create an LLM provider instance for the given provider name.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `provider_name` — One of "gemini", "openai", "anthropic"
|
||||||
|
/// * `api_key` — The decrypted API key for the provider
|
||||||
|
/// * `http_client` — Shared HTTP client for making API calls
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `AppError::BadRequest` if the provider is not yet supported.
|
||||||
|
pub fn create_provider(
|
||||||
|
provider_name: &str,
|
||||||
|
api_key: String,
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
) -> Result<Box<dyn LlmProvider>, AppError> {
|
||||||
|
match provider_name {
|
||||||
|
"gemini" => Ok(Box::new(GeminiProvider::new(api_key, http_client.clone()))),
|
||||||
|
"openai" => Err(AppError::BadRequest(
|
||||||
|
"OpenAI provider is not yet implemented (planned for Phase 6)".into(),
|
||||||
|
)),
|
||||||
|
"anthropic" => Err(AppError::BadRequest(
|
||||||
|
"Anthropic provider is not yet implemented (planned for Phase 6)".into(),
|
||||||
|
)),
|
||||||
|
_ => Err(AppError::BadRequest(format!(
|
||||||
|
"Unknown provider: '{}'",
|
||||||
|
provider_name
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_creates_gemini_provider() {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let provider = create_provider("gemini", "test-key".into(), &client).unwrap();
|
||||||
|
assert_eq!(provider.provider_id(), "gemini");
|
||||||
|
assert!(provider.supports_web_search());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_rejects_openai() {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let result = create_provider("openai", "test-key".into(), &client);
|
||||||
|
match result {
|
||||||
|
Err(AppError::BadRequest(msg)) => assert!(msg.contains("not yet implemented")),
|
||||||
|
Err(_) => panic!("Expected BadRequest variant"),
|
||||||
|
Ok(_) => panic!("Expected error for openai"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_rejects_anthropic() {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let result = create_provider("anthropic", "test-key".into(), &client);
|
||||||
|
match result {
|
||||||
|
Err(AppError::BadRequest(msg)) => assert!(msg.contains("not yet implemented")),
|
||||||
|
Err(_) => panic!("Expected BadRequest variant"),
|
||||||
|
Ok(_) => panic!("Expected error for anthropic"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_rejects_unknown_provider() {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let result = create_provider("mistral", "test-key".into(), &client);
|
||||||
|
match result {
|
||||||
|
Err(AppError::BadRequest(msg)) => assert!(msg.contains("Unknown provider")),
|
||||||
|
Err(_) => panic!("Expected BadRequest variant"),
|
||||||
|
Ok(_) => panic!("Expected error for unknown provider"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,393 @@
|
|||||||
|
//! Google Gemini LLM provider implementation.
|
||||||
|
//!
|
||||||
|
//! Implements the `LlmProvider` trait using the Gemini REST API.
|
||||||
|
//! Supports both web search grounding (Pass 1) and plain structured
|
||||||
|
//! output (Pass 2) via the `generateContent` endpoint.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::LlmProvider;
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// Google Gemini provider.
|
||||||
|
///
|
||||||
|
/// Holds the API key and an HTTP client for making requests
|
||||||
|
/// to the Gemini `generateContent` API.
|
||||||
|
pub struct GeminiProvider {
|
||||||
|
api_key: String,
|
||||||
|
http_client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeminiProvider {
|
||||||
|
/// Create a new Gemini provider with the given API key and HTTP client.
|
||||||
|
pub fn new(api_key: String, http_client: reqwest::Client) -> Self {
|
||||||
|
Self {
|
||||||
|
api_key,
|
||||||
|
http_client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the Gemini API URL for a given model.
|
||||||
|
fn api_url(&self, model: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}",
|
||||||
|
model, self.api_key
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a generateContent request and parse the response.
|
||||||
|
async fn generate_content(&self, model: &str, body: &Value) -> Result<Value, AppError> {
|
||||||
|
let url = self.api_url(model);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.http_client
|
||||||
|
.post(&url)
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Gemini API request failed: {}", e);
|
||||||
|
AppError::Internal(anyhow::anyhow!("Failed to connect to Gemini API"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let response_body: Value = response.json().await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to parse Gemini response: {}", e);
|
||||||
|
AppError::Internal(anyhow::anyhow!("Failed to parse Gemini API response"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Handle error responses
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(map_gemini_error(status.as_u16(), &response_body));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the text content from the Gemini response structure
|
||||||
|
extract_content(&response_body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmProvider for GeminiProvider {
|
||||||
|
fn provider_id(&self) -> &str {
|
||||||
|
"gemini"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_search_pass(
|
||||||
|
&self,
|
||||||
|
model: &str,
|
||||||
|
system_prompt: &str,
|
||||||
|
user_prompt: &str,
|
||||||
|
response_schema: &Value,
|
||||||
|
) -> Result<Value, AppError> {
|
||||||
|
let body = build_request_body(
|
||||||
|
system_prompt,
|
||||||
|
user_prompt,
|
||||||
|
response_schema,
|
||||||
|
true, // include googleSearch tool
|
||||||
|
);
|
||||||
|
|
||||||
|
self.generate_content(model, &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_rewrite_pass(
|
||||||
|
&self,
|
||||||
|
model: &str,
|
||||||
|
system_prompt: &str,
|
||||||
|
user_prompt: &str,
|
||||||
|
response_schema: &Value,
|
||||||
|
) -> Result<Value, AppError> {
|
||||||
|
let body = build_request_body(
|
||||||
|
system_prompt,
|
||||||
|
user_prompt,
|
||||||
|
response_schema,
|
||||||
|
false, // no tools for rewrite
|
||||||
|
);
|
||||||
|
|
||||||
|
self.generate_content(model, &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_web_search(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the JSON request body for the Gemini `generateContent` endpoint.
|
||||||
|
///
|
||||||
|
/// When `include_search` is true, the `googleSearch` tool is included
|
||||||
|
/// to enable web search grounding (Pass 1).
|
||||||
|
fn build_request_body(
|
||||||
|
system_prompt: &str,
|
||||||
|
user_prompt: &str,
|
||||||
|
response_schema: &Value,
|
||||||
|
include_search: bool,
|
||||||
|
) -> Value {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"contents": [{
|
||||||
|
"role": "user",
|
||||||
|
"parts": [{
|
||||||
|
"text": user_prompt
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"systemInstruction": {
|
||||||
|
"parts": [{
|
||||||
|
"text": system_prompt
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"generationConfig": {
|
||||||
|
"responseMimeType": "application/json",
|
||||||
|
"responseSchema": response_schema
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if include_search {
|
||||||
|
body["tools"] = serde_json::json!([{
|
||||||
|
"googleSearch": {}
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the text content from a Gemini API response.
|
||||||
|
///
|
||||||
|
/// The response structure is:
|
||||||
|
/// ```json
|
||||||
|
/// { "candidates": [{ "content": { "parts": [{ "text": "..." }] } }] }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The text field contains a JSON string that we parse into a `Value`.
|
||||||
|
fn extract_content(response: &Value) -> Result<Value, AppError> {
|
||||||
|
let text = response
|
||||||
|
.get("candidates")
|
||||||
|
.and_then(|c| c.get(0))
|
||||||
|
.and_then(|c| c.get("content"))
|
||||||
|
.and_then(|c| c.get("parts"))
|
||||||
|
.and_then(|p| p.get(0))
|
||||||
|
.and_then(|p| p.get("text"))
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
tracing::error!("Unexpected Gemini response structure: {:?}", response);
|
||||||
|
AppError::Internal(anyhow::anyhow!(
|
||||||
|
"Gemini API returned an unexpected response structure"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// The text content is a JSON string — parse it
|
||||||
|
serde_json::from_str(text).map_err(|e| {
|
||||||
|
tracing::error!("Failed to parse Gemini JSON output: {}", e);
|
||||||
|
AppError::Internal(anyhow::anyhow!(
|
||||||
|
"Gemini returned invalid JSON in structured output"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map Gemini API error responses to appropriate `AppError` variants.
|
||||||
|
///
|
||||||
|
/// Handles common error codes without exposing internal details.
|
||||||
|
fn map_gemini_error(status: u16, body: &Value) -> AppError {
|
||||||
|
let error_message = body
|
||||||
|
.get("error")
|
||||||
|
.and_then(|e| e.get("message"))
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
|
|
||||||
|
let error_status = body
|
||||||
|
.get("error")
|
||||||
|
.and_then(|e| e.get("status"))
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
tracing::error!(
|
||||||
|
"Gemini API error (HTTP {}): {} (status: {})",
|
||||||
|
status,
|
||||||
|
error_message,
|
||||||
|
error_status
|
||||||
|
);
|
||||||
|
|
||||||
|
match status {
|
||||||
|
400 => AppError::BadRequest("Invalid request to LLM provider".into()),
|
||||||
|
401 | 403 => AppError::BadRequest("Invalid or unauthorized API key".into()),
|
||||||
|
404 => AppError::BadRequest("Model not found or not available".into()),
|
||||||
|
429 => AppError::RateLimited("LLM provider rate limit exceeded. Please try again later.".into()),
|
||||||
|
_ => AppError::Internal(anyhow::anyhow!("LLM provider returned an error")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_request_body_with_search() {
|
||||||
|
let schema = serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"category_0": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "object" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let body = build_request_body("system prompt", "user prompt", &schema, true);
|
||||||
|
|
||||||
|
// Verify contents
|
||||||
|
assert_eq!(
|
||||||
|
body["contents"][0]["role"].as_str().unwrap(),
|
||||||
|
"user"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["contents"][0]["parts"][0]["text"].as_str().unwrap(),
|
||||||
|
"user prompt"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify system instruction
|
||||||
|
assert_eq!(
|
||||||
|
body["systemInstruction"]["parts"][0]["text"].as_str().unwrap(),
|
||||||
|
"system prompt"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify tools (googleSearch present)
|
||||||
|
assert!(body["tools"].is_array());
|
||||||
|
assert!(body["tools"][0].get("googleSearch").is_some());
|
||||||
|
|
||||||
|
// Verify generation config
|
||||||
|
assert_eq!(
|
||||||
|
body["generationConfig"]["responseMimeType"].as_str().unwrap(),
|
||||||
|
"application/json"
|
||||||
|
);
|
||||||
|
assert!(body["generationConfig"]["responseSchema"].is_object());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_request_body_without_search() {
|
||||||
|
let schema = serde_json::json!({"type": "object"});
|
||||||
|
let body = build_request_body("sys", "user", &schema, false);
|
||||||
|
|
||||||
|
// No tools key when search is disabled
|
||||||
|
assert!(body.get("tools").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_content_valid_response() {
|
||||||
|
let response = serde_json::json!({
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"parts": [{
|
||||||
|
"text": "{\"category_0\": [{\"title\": \"Test\", \"url\": \"https://example.com\", \"summary\": \"A test article\"}]}"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = extract_content(&response).unwrap();
|
||||||
|
assert!(result["category_0"].is_array());
|
||||||
|
assert_eq!(result["category_0"][0]["title"].as_str().unwrap(), "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_content_missing_candidates() {
|
||||||
|
let response = serde_json::json!({});
|
||||||
|
assert!(extract_content(&response).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_content_empty_candidates() {
|
||||||
|
let response = serde_json::json!({"candidates": []});
|
||||||
|
assert!(extract_content(&response).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_content_invalid_json_text() {
|
||||||
|
let response = serde_json::json!({
|
||||||
|
"candidates": [{
|
||||||
|
"content": {
|
||||||
|
"parts": [{
|
||||||
|
"text": "this is not valid json"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(extract_content(&response).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_gemini_error_invalid_key() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"error": {
|
||||||
|
"code": 403,
|
||||||
|
"message": "API key not valid",
|
||||||
|
"status": "PERMISSION_DENIED"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let err = map_gemini_error(403, &body);
|
||||||
|
match err {
|
||||||
|
AppError::BadRequest(msg) => assert!(msg.contains("unauthorized")),
|
||||||
|
_ => panic!("Expected BadRequest for 403"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_gemini_error_rate_limited() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"error": {
|
||||||
|
"code": 429,
|
||||||
|
"message": "Resource exhausted",
|
||||||
|
"status": "RESOURCE_EXHAUSTED"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let err = map_gemini_error(429, &body);
|
||||||
|
match err {
|
||||||
|
AppError::RateLimited(msg) => assert!(msg.contains("rate limit")),
|
||||||
|
_ => panic!("Expected RateLimited for 429"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_gemini_error_model_not_found() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"error": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "Model not found",
|
||||||
|
"status": "NOT_FOUND"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let err = map_gemini_error(404, &body);
|
||||||
|
match err {
|
||||||
|
AppError::BadRequest(msg) => assert!(msg.contains("not found")),
|
||||||
|
_ => panic!("Expected BadRequest for 404"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_gemini_error_server_error() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"error": {
|
||||||
|
"code": 500,
|
||||||
|
"message": "Internal error",
|
||||||
|
"status": "INTERNAL"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let err = map_gemini_error(500, &body);
|
||||||
|
match err {
|
||||||
|
AppError::Internal(_) => {} // expected
|
||||||
|
_ => panic!("Expected Internal for 500"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gemini_provider_supports_web_search() {
|
||||||
|
let provider = GeminiProvider::new(
|
||||||
|
"test-key".into(),
|
||||||
|
reqwest::Client::new(),
|
||||||
|
);
|
||||||
|
assert!(provider.supports_web_search());
|
||||||
|
assert_eq!(provider.provider_id(), "gemini");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
//! LLM provider abstraction layer.
|
||||||
|
//!
|
||||||
|
//! Defines the `LlmProvider` trait that all LLM providers implement,
|
||||||
|
//! along with shared types and the provider factory function.
|
||||||
|
|
||||||
|
pub mod factory;
|
||||||
|
pub mod gemini;
|
||||||
|
pub mod schema;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// Capabilities advertised by an LLM provider.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProviderCapabilities {
|
||||||
|
/// Whether the provider supports native web search grounding.
|
||||||
|
pub supports_web_search: bool,
|
||||||
|
/// Whether the provider supports structured output via JSON schema.
|
||||||
|
pub supports_structured_output: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trait defining the contract for LLM provider implementations.
|
||||||
|
///
|
||||||
|
/// Each provider (Gemini, OpenAI, Anthropic) implements this trait
|
||||||
|
/// to provide a unified interface for the synthesis generation pipeline.
|
||||||
|
///
|
||||||
|
/// The pipeline uses two passes:
|
||||||
|
/// - **Search pass**: Generates content with web search grounding (if supported)
|
||||||
|
/// - **Rewrite pass**: Rewrites/consolidates content with structured output
|
||||||
|
#[async_trait]
|
||||||
|
pub trait LlmProvider: Send + Sync {
|
||||||
|
/// Returns the provider identifier (e.g., "gemini", "openai", "anthropic").
|
||||||
|
fn provider_id(&self) -> &str;
|
||||||
|
|
||||||
|
/// Generate content with web search grounding (Pass 1).
|
||||||
|
///
|
||||||
|
/// For providers that support native web search (e.g., Gemini with googleSearch),
|
||||||
|
/// this pass retrieves and structures information from the web.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `model` — The model identifier (e.g., "gemini-2.5-pro")
|
||||||
|
/// * `system_prompt` — System-level instructions for the model
|
||||||
|
/// * `user_prompt` — The user's prompt with search criteria
|
||||||
|
/// * `response_schema` — JSON Schema defining the expected response structure
|
||||||
|
async fn generate_search_pass(
|
||||||
|
&self,
|
||||||
|
model: &str,
|
||||||
|
system_prompt: &str,
|
||||||
|
user_prompt: &str,
|
||||||
|
response_schema: &Value,
|
||||||
|
) -> Result<Value, AppError>;
|
||||||
|
|
||||||
|
/// Generate content without web search (Pass 2).
|
||||||
|
///
|
||||||
|
/// Used for rewriting, consolidating, or reformatting content
|
||||||
|
/// with structured output but no web search tools.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `model` — The model identifier
|
||||||
|
/// * `system_prompt` — System-level instructions for the model
|
||||||
|
/// * `user_prompt` — The user's prompt (typically includes content from Pass 1)
|
||||||
|
/// * `response_schema` — JSON Schema defining the expected response structure
|
||||||
|
async fn generate_rewrite_pass(
|
||||||
|
&self,
|
||||||
|
model: &str,
|
||||||
|
system_prompt: &str,
|
||||||
|
user_prompt: &str,
|
||||||
|
response_schema: &Value,
|
||||||
|
) -> Result<Value, AppError>;
|
||||||
|
|
||||||
|
/// Whether this provider supports native web search grounding.
|
||||||
|
fn supports_web_search(&self) -> bool;
|
||||||
|
}
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
//! JSON Schema builder for structured LLM output.
|
||||||
|
//!
|
||||||
|
//! Constructs the JSON Schema that is passed to the LLM provider
|
||||||
|
//! to enforce structured output matching the user's categories.
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Build a JSON Schema for structured output based on user categories.
|
||||||
|
///
|
||||||
|
/// Each category is mapped to a property named `category_0`, `category_1`, etc.
|
||||||
|
/// Each property is an array of news items with `title`, `url`, and `summary` fields.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// For categories `["Major Announcements", "Research"]`, produces:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "type": "object",
|
||||||
|
/// "properties": {
|
||||||
|
/// "category_0": {
|
||||||
|
/// "type": "array",
|
||||||
|
/// "description": "Major Announcements",
|
||||||
|
/// "items": {
|
||||||
|
/// "type": "object",
|
||||||
|
/// "properties": {
|
||||||
|
/// "title": { "type": "string" },
|
||||||
|
/// "url": { "type": "string" },
|
||||||
|
/// "summary": { "type": "string" }
|
||||||
|
/// },
|
||||||
|
/// "required": ["title", "url", "summary"]
|
||||||
|
/// }
|
||||||
|
/// },
|
||||||
|
/// "category_1": { ... }
|
||||||
|
/// },
|
||||||
|
/// "required": ["category_0", "category_1"]
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub fn build_category_schema(categories: &[String]) -> Value {
|
||||||
|
let news_item_schema = serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The title of the news article"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The URL of the source article"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A concise summary of the article"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["title", "url", "summary"]
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut properties = serde_json::Map::new();
|
||||||
|
let mut required = Vec::new();
|
||||||
|
|
||||||
|
for (i, category_name) in categories.iter().enumerate() {
|
||||||
|
let key = format!("category_{}", i);
|
||||||
|
properties.insert(
|
||||||
|
key.clone(),
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "array",
|
||||||
|
"description": category_name,
|
||||||
|
"items": news_item_schema
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
required.push(Value::String(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": properties,
|
||||||
|
"required": required
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_with_one_category() {
|
||||||
|
let categories = vec!["AI News".to_string()];
|
||||||
|
let schema = build_category_schema(&categories);
|
||||||
|
|
||||||
|
assert_eq!(schema["type"], "object");
|
||||||
|
|
||||||
|
// One property
|
||||||
|
let props = schema["properties"].as_object().unwrap();
|
||||||
|
assert_eq!(props.len(), 1);
|
||||||
|
assert!(props.contains_key("category_0"));
|
||||||
|
|
||||||
|
// Category description
|
||||||
|
assert_eq!(props["category_0"]["description"], "AI News");
|
||||||
|
|
||||||
|
// Array type with items
|
||||||
|
assert_eq!(props["category_0"]["type"], "array");
|
||||||
|
let items = &props["category_0"]["items"];
|
||||||
|
assert_eq!(items["type"], "object");
|
||||||
|
assert!(items["properties"].get("title").is_some());
|
||||||
|
assert!(items["properties"].get("url").is_some());
|
||||||
|
assert!(items["properties"].get("summary").is_some());
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
let required = schema["required"].as_array().unwrap();
|
||||||
|
assert_eq!(required.len(), 1);
|
||||||
|
assert_eq!(required[0], "category_0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_with_three_categories() {
|
||||||
|
let categories = vec![
|
||||||
|
"Annonces majeures".to_string(),
|
||||||
|
"Recherche".to_string(),
|
||||||
|
"Secteur public".to_string(),
|
||||||
|
];
|
||||||
|
let schema = build_category_schema(&categories);
|
||||||
|
|
||||||
|
let props = schema["properties"].as_object().unwrap();
|
||||||
|
assert_eq!(props.len(), 3);
|
||||||
|
assert_eq!(props["category_0"]["description"], "Annonces majeures");
|
||||||
|
assert_eq!(props["category_1"]["description"], "Recherche");
|
||||||
|
assert_eq!(props["category_2"]["description"], "Secteur public");
|
||||||
|
|
||||||
|
let required = schema["required"].as_array().unwrap();
|
||||||
|
assert_eq!(required.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_with_five_categories() {
|
||||||
|
let categories: Vec<String> = (0..5)
|
||||||
|
.map(|i| format!("Category {}", i))
|
||||||
|
.collect();
|
||||||
|
let schema = build_category_schema(&categories);
|
||||||
|
|
||||||
|
let props = schema["properties"].as_object().unwrap();
|
||||||
|
assert_eq!(props.len(), 5);
|
||||||
|
|
||||||
|
for i in 0..5 {
|
||||||
|
let key = format!("category_{}", i);
|
||||||
|
assert!(props.contains_key(&key));
|
||||||
|
assert_eq!(
|
||||||
|
props[&key]["description"].as_str().unwrap(),
|
||||||
|
format!("Category {}", i)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let required = schema["required"].as_array().unwrap();
|
||||||
|
assert_eq!(required.len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_with_empty_categories() {
|
||||||
|
let categories: Vec<String> = vec![];
|
||||||
|
let schema = build_category_schema(&categories);
|
||||||
|
|
||||||
|
let props = schema["properties"].as_object().unwrap();
|
||||||
|
assert_eq!(props.len(), 0);
|
||||||
|
|
||||||
|
let required = schema["required"].as_array().unwrap();
|
||||||
|
assert_eq!(required.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_news_item_has_required_fields() {
|
||||||
|
let categories = vec!["Test".to_string()];
|
||||||
|
let schema = build_category_schema(&categories);
|
||||||
|
|
||||||
|
let items = &schema["properties"]["category_0"]["items"];
|
||||||
|
let item_required = items["required"].as_array().unwrap();
|
||||||
|
let item_required_strs: Vec<&str> = item_required
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(item_required_strs.contains(&"title"));
|
||||||
|
assert!(item_required_strs.contains(&"url"));
|
||||||
|
assert!(item_required_strs.contains(&"summary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_with_special_characters_in_category_name() {
|
||||||
|
let categories = vec![
|
||||||
|
"AI & Machine Learning".to_string(),
|
||||||
|
"R&D / Innovation".to_string(),
|
||||||
|
];
|
||||||
|
let schema = build_category_schema(&categories);
|
||||||
|
|
||||||
|
let props = schema["properties"].as_object().unwrap();
|
||||||
|
assert_eq!(props["category_0"]["description"], "AI & Machine Learning");
|
||||||
|
assert_eq!(props["category_1"]["description"], "R&D / Innovation");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod csv;
|
pub mod csv;
|
||||||
pub mod email;
|
pub mod email;
|
||||||
|
pub mod encryption;
|
||||||
|
pub mod llm;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
pub mod scraper;
|
pub mod scraper;
|
||||||
pub mod turnstile;
|
pub mod turnstile;
|
||||||
|
|||||||
@ -0,0 +1,645 @@
|
|||||||
|
//! 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,259 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
describe('API Keys API', () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { href: '' },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list', () => {
|
||||||
|
it('should call GET /api/v1/user/api-keys and parse response', async () => {
|
||||||
|
const mockKeys = [
|
||||||
|
{
|
||||||
|
id: 'key-1',
|
||||||
|
provider_name: 'gemini',
|
||||||
|
key_prefix: 'AIza...xQ',
|
||||||
|
created_at: '2026-03-01T00:00:00Z',
|
||||||
|
updated_at: '2026-03-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'key-2',
|
||||||
|
provider_name: 'openai',
|
||||||
|
key_prefix: 'sk-ab...cd',
|
||||||
|
created_at: '2026-03-02T00:00:00Z',
|
||||||
|
updated_at: '2026-03-02T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(mockKeys),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiKeysApi } = await import('~/api/apiKeys');
|
||||||
|
const result = await apiKeysApi.list();
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/user/api-keys',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
}),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockKeys);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].provider_name).toBe('gemini');
|
||||||
|
expect(result[0].key_prefix).toBe('AIza...xQ');
|
||||||
|
expect(result[1].provider_name).toBe('openai');
|
||||||
|
expect(result[1].key_prefix).toBe('sk-ab...cd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no keys configured', async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiKeysApi } = await import('~/api/apiKeys');
|
||||||
|
const result = await apiKeysApi.list();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should never include full API key in list response', async () => {
|
||||||
|
const mockKeys = [
|
||||||
|
{
|
||||||
|
id: 'key-1',
|
||||||
|
provider_name: 'gemini',
|
||||||
|
key_prefix: 'AIza...xQ',
|
||||||
|
created_at: '2026-03-01T00:00:00Z',
|
||||||
|
updated_at: '2026-03-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(mockKeys),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiKeysApi } = await import('~/api/apiKeys');
|
||||||
|
const result = await apiKeysApi.list();
|
||||||
|
|
||||||
|
// Verify no key field with full API key exists in the response
|
||||||
|
for (const key of result) {
|
||||||
|
expect(key).not.toHaveProperty('api_key');
|
||||||
|
expect(key).not.toHaveProperty('encrypted_key');
|
||||||
|
expect(key).toHaveProperty('key_prefix');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should call POST /api/v1/user/api-keys with body', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
id: 'key-3',
|
||||||
|
provider_name: 'anthropic',
|
||||||
|
key_prefix: 'sk-an...ef',
|
||||||
|
created_at: '2026-03-10T00:00:00Z',
|
||||||
|
updated_at: '2026-03-10T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiKeysApi } = await import('~/api/apiKeys');
|
||||||
|
const result = await apiKeysApi.create({
|
||||||
|
provider_name: 'anthropic',
|
||||||
|
api_key: 'sk-ant-full-key-here',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/user/api-keys',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider_name: 'anthropic',
|
||||||
|
api_key: 'sk-ant-full-key-here',
|
||||||
|
}),
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
expect(result.key_prefix).toBe('sk-an...ef');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove', () => {
|
||||||
|
it('should call DELETE /api/v1/user/api-keys/:provider', async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiKeysApi } = await import('~/api/apiKeys');
|
||||||
|
await apiKeysApi.remove('gemini');
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/user/api-keys/gemini',
|
||||||
|
expect.objectContaining({ method: 'DELETE' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should encode provider name in URL', async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiKeysApi } = await import('~/api/apiKeys');
|
||||||
|
await apiKeysApi.remove('provider with spaces');
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/user/api-keys/provider%20with%20spaces',
|
||||||
|
expect.objectContaining({ method: 'DELETE' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('test', () => {
|
||||||
|
it('should call POST /api/v1/user/api-keys/:provider/test', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'Connection successful',
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiKeysApi } = await import('~/api/apiKeys');
|
||||||
|
const result = await apiKeysApi.test('gemini');
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/user/api-keys/gemini/test',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('Connection successful');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return failure result for invalid key', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid API key',
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { apiKeysApi } = await import('~/api/apiKeys');
|
||||||
|
const result = await apiKeysApi.test('openai');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe('Invalid API key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Key prefix display formatting', () => {
|
||||||
|
it('should display prefix as-is from the API response', () => {
|
||||||
|
// The backend is responsible for generating the prefix (e.g., "sk-ab...cd")
|
||||||
|
// The frontend displays it verbatim; this test documents that contract.
|
||||||
|
const keyPrefix = 'sk-ab...cd';
|
||||||
|
expect(keyPrefix).toMatch(/^.{2,6}\.\.\..{2,4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle various prefix formats', () => {
|
||||||
|
const prefixes = [
|
||||||
|
'AIza...xQ', // Gemini-style
|
||||||
|
'sk-ab...cd', // OpenAI-style
|
||||||
|
'sk-an...ef', // Anthropic-style
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
// All prefixes should contain the ellipsis marker
|
||||||
|
expect(prefix).toContain('...');
|
||||||
|
// No prefix should be longer than 12 characters (prefix + ... + suffix)
|
||||||
|
expect(prefix.length).toBeLessThanOrEqual(12);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should never contain a full key value', () => {
|
||||||
|
// A full API key is typically 30+ characters
|
||||||
|
const prefix = 'sk-ab...cd';
|
||||||
|
expect(prefix.length).toBeLessThan(20);
|
||||||
|
expect(prefix).toContain('...');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { UserApiKey, CreateApiKeyRequest, TestApiKeyResponse } from '~/types';
|
||||||
|
|
||||||
|
export const apiKeysApi = {
|
||||||
|
list: (): Promise<UserApiKey[]> =>
|
||||||
|
api.get<UserApiKey[]>('/user/api-keys'),
|
||||||
|
|
||||||
|
create: (data: CreateApiKeyRequest): Promise<UserApiKey> =>
|
||||||
|
api.post<UserApiKey>('/user/api-keys', data),
|
||||||
|
|
||||||
|
remove: (provider: string): Promise<void> =>
|
||||||
|
api.delete<void>(`/user/api-keys/${encodeURIComponent(provider)}`),
|
||||||
|
|
||||||
|
test: (provider: string): Promise<TestApiKeyResponse> =>
|
||||||
|
api.post<TestApiKeyResponse>(`/user/api-keys/${encodeURIComponent(provider)}/test`),
|
||||||
|
};
|
||||||
@ -0,0 +1,304 @@
|
|||||||
|
import {
|
||||||
|
type Component,
|
||||||
|
createSignal,
|
||||||
|
createResource,
|
||||||
|
Show,
|
||||||
|
For,
|
||||||
|
} from 'solid-js';
|
||||||
|
import { Key, Eye, EyeOff, CheckCircle, XCircle, Trash2, RefreshCw } from 'lucide-solid';
|
||||||
|
import { apiKeysApi } from '~/api/apiKeys';
|
||||||
|
import { useI18n } from '~/i18n';
|
||||||
|
import { useToast } from '~/components/ui/Toast';
|
||||||
|
import { isApiError } from '~/types';
|
||||||
|
import type { ProviderConfig, UserApiKey } from '~/types';
|
||||||
|
|
||||||
|
interface ApiKeyManagerProps {
|
||||||
|
providers: ProviderConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiKeyManager: Component<ApiKeyManagerProps> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
const [apiKeys, { refetch }] = createResource(() => apiKeysApi.list());
|
||||||
|
|
||||||
|
const getKeyForProvider = (providerName: string): UserApiKey | undefined => {
|
||||||
|
return apiKeys()?.find((k) => k.provider_name === providerName);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200 mt-8">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Key class="h-5 w-5 text-indigo-600 mr-2" />
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">
|
||||||
|
{t('settings.apiKeys.title')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
{t('settings.apiKeys.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-gray-200">
|
||||||
|
<For each={props.providers}>
|
||||||
|
{(provider) => (
|
||||||
|
<ProviderKeyCard
|
||||||
|
provider={provider}
|
||||||
|
apiKey={getKeyForProvider(provider.provider_name)}
|
||||||
|
onKeyChanged={refetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProviderKeyCardProps {
|
||||||
|
provider: ProviderConfig;
|
||||||
|
apiKey: UserApiKey | undefined;
|
||||||
|
onKeyChanged: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProviderKeyCard: Component<ProviderKeyCardProps> = (props) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
const [keyInput, setKeyInput] = createSignal('');
|
||||||
|
const [showKey, setShowKey] = createSignal(false);
|
||||||
|
const [editing, setEditing] = createSignal(false);
|
||||||
|
const [saving, setSaving] = createSignal(false);
|
||||||
|
const [testing, setTesting] = createSignal(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = createSignal(false);
|
||||||
|
|
||||||
|
const isConfigured = () => !!props.apiKey;
|
||||||
|
const showInput = () => !isConfigured() || editing();
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const key = keyInput().trim();
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiKeysApi.create({
|
||||||
|
provider_name: props.provider.provider_name,
|
||||||
|
api_key: key,
|
||||||
|
});
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
message: t('settings.apiKeys.saved'),
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
setKeyInput('');
|
||||||
|
setShowKey(false);
|
||||||
|
setEditing(false);
|
||||||
|
props.onKeyChanged();
|
||||||
|
} catch (err) {
|
||||||
|
const message = isApiError(err) ? err.message : t('settings.apiKeys.saveError');
|
||||||
|
addToast({ type: 'error', message, duration: 5000 });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
const result = await apiKeysApi.test(props.provider.provider_name);
|
||||||
|
if (result.success) {
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
message: t('settings.apiKeys.testSuccess'),
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addToast({
|
||||||
|
type: 'error',
|
||||||
|
message: t('settings.apiKeys.testFailure', { message: result.message }),
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: 'Erreur inconnue' });
|
||||||
|
addToast({ type: 'error', message, duration: 5000 });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await apiKeysApi.remove(props.provider.provider_name);
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
message: t('settings.apiKeys.deleted'),
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
setConfirmDelete(false);
|
||||||
|
setEditing(false);
|
||||||
|
props.onKeyChanged();
|
||||||
|
} catch (err) {
|
||||||
|
const message = isApiError(err) ? err.message : t('settings.apiKeys.deleteError');
|
||||||
|
addToast({ type: 'error', message, duration: 5000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditing(false);
|
||||||
|
setKeyInput('');
|
||||||
|
setShowKey(false);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="px-4 py-4 sm:px-6">
|
||||||
|
{/* Header: Provider name + status badge */}
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-medium text-gray-900">
|
||||||
|
{props.provider.display_name}
|
||||||
|
</span>
|
||||||
|
<Show
|
||||||
|
when={isConfigured()}
|
||||||
|
fallback={
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
||||||
|
{t('settings.apiKeys.notConfigured')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
{t('settings.apiKeys.configured')}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons for configured keys */}
|
||||||
|
<Show when={isConfigured() && !editing()}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testing()}
|
||||||
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-indigo-700 bg-indigo-50 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={!testing()}
|
||||||
|
fallback={
|
||||||
|
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-indigo-700 mr-1.5" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-3 w-3 mr-1.5" />
|
||||||
|
</Show>
|
||||||
|
{testing() ? t('settings.apiKeys.testing') : t('settings.apiKeys.test')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
{t('settings.apiKeys.update')}
|
||||||
|
</button>
|
||||||
|
<Show
|
||||||
|
when={!confirmDelete()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500"
|
||||||
|
>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3 w-3 mr-1.5" />
|
||||||
|
{t('settings.apiKeys.delete')}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key prefix display */}
|
||||||
|
<Show when={isConfigured() && !editing()}>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
<span class="font-mono text-gray-400">
|
||||||
|
{t('settings.apiKeys.keyPrefix', { prefix: props.apiKey!.key_prefix })}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Input form: shown when not configured or editing */}
|
||||||
|
<Show when={showInput()}>
|
||||||
|
<div class="mt-2">
|
||||||
|
<label
|
||||||
|
for={`api-key-${props.provider.provider_name}`}
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
{t('settings.apiKeys.inputLabel')}
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<input
|
||||||
|
type={showKey() ? 'text' : 'password'}
|
||||||
|
id={`api-key-${props.provider.provider_name}`}
|
||||||
|
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 pr-10 border font-mono"
|
||||||
|
value={keyInput()}
|
||||||
|
onInput={(e) => setKeyInput(e.currentTarget.value)}
|
||||||
|
placeholder={t('settings.apiKeys.inputPlaceholder', {
|
||||||
|
provider: props.provider.display_name,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowKey(!showKey())}
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||||
|
aria-label={showKey() ? t('settings.apiKeys.hideKey') : t('settings.apiKeys.showKey')}
|
||||||
|
>
|
||||||
|
<Show when={showKey()} fallback={<Eye class="h-4 w-4" />}>
|
||||||
|
<EyeOff class="h-4 w-4" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving() || !keyInput().trim()}
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Show when={saving()}>
|
||||||
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||||
|
</Show>
|
||||||
|
{saving() ? t('settings.apiKeys.saving') : t('settings.apiKeys.save')}
|
||||||
|
</button>
|
||||||
|
<Show when={editing()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeyManager;
|
||||||
Loading…
Reference in New Issue