diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0c727db..f040844 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -15,7 +50,9 @@ dependencies = [ name = "ai-synth-backend" version = "0.1.0" dependencies = [ + "aes-gcm", "anyhow", + "async-trait", "axum", "base64", "chrono", @@ -41,6 +78,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "zeroize", ] [[package]] @@ -114,6 +152,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atoi" version = "2.0.0" @@ -278,6 +327,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -411,6 +470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -437,6 +497,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -780,6 +849,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "h2" version = "0.4.13" @@ -1157,6 +1236,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1467,6 +1555,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.76" @@ -1652,6 +1746,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2810,6 +2916,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -3427,6 +3543,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7522485..88935c7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -37,6 +37,11 @@ sha2 = "0.10" rand = "0.8" base64 = "0.22" hex = "0.4" +aes-gcm = "0.10" +zeroize = { version = "1", features = ["derive"] } + +# Async trait (needed for trait objects) +async-trait = "0.1" # Logging tracing = "0.1" diff --git a/backend/migrations/20260321000008_create_user_api_keys.sql b/backend/migrations/20260321000008_create_user_api_keys.sql new file mode 100644 index 0000000..682f4dc --- /dev/null +++ b/backend/migrations/20260321000008_create_user_api_keys.sql @@ -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); diff --git a/backend/src/db/api_keys.rs b/backend/src/db/api_keys.rs new file mode 100644 index 0000000..4305b06 --- /dev/null +++ b/backend/src/db/api_keys.rs @@ -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, 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, 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 { + 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 { + 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) +} diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 5a60463..be7f0f1 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -1,3 +1,4 @@ +pub mod api_keys; pub mod audit; pub mod magic_links; pub mod providers; diff --git a/backend/src/handlers/api_keys.rs b/backend/src/handlers/api_keys.rs new file mode 100644 index 0000000..5c7bb71 --- /dev/null +++ b/backend/src/handlers/api_keys.rs @@ -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, +) -> Result { + let keys = db::api_keys::list_for_user(&state.pool, auth_user.id).await?; + let response: Vec = 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, + Json(body): Json, +) -> Result { + 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, + Path(provider): Path, +) -> Result { + 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, + Path(provider): Path, +) -> Result { + // 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 { + 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()) + } + } +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index c96ea1a..6d63468 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod api_keys; pub mod auth; pub mod config; pub mod health; diff --git a/backend/src/models/api_key.rs b/backend/src/models/api_key.rs new file mode 100644 index 0000000..14d9253 --- /dev/null +++ b/backend/src/models/api_key.rs @@ -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, + pub nonce: Vec, + pub key_prefix: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 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, + pub updated_at: DateTime, +} + +impl From 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); + } + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 056fc29..3d16601 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod api_key; pub mod audit; pub mod magic_link; pub mod provider; diff --git a/backend/src/router.rs b/backend/src/router.rs index b96efb2..f67de17 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -44,6 +44,11 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router { .route("/sources/bulk", post(handlers::sources::bulk_import)) .route("/sources/import-csv", post(handlers::sources::import_csv)) .route("/sources/export-csv", get(handlers::sources::export_csv)) + // User API key management + .route("/user/api-keys", get(handlers::api_keys::list)) + .route("/user/api-keys", post(handlers::api_keys::create)) + .route("/user/api-keys/{provider}", delete(handlers::api_keys::delete)) + .route("/user/api-keys/{provider}/test", post(handlers::api_keys::test_key)) // Public config (authenticated, non-admin) .route("/config/providers", get(handlers::config::list_enabled_providers)) // Admin routes diff --git a/backend/src/services/encryption.rs b/backend/src/services/encryption.rs new file mode 100644 index 0000000..a5fff7b --- /dev/null +++ b/backend/src/services/encryption.rs @@ -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, +} + +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 { + 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, Vec), AppError> { + let key = Key::::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 { + let key = Key::::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, ""); + } +} diff --git a/backend/src/services/llm/factory.rs b/backend/src/services/llm/factory.rs new file mode 100644 index 0000000..818d505 --- /dev/null +++ b/backend/src/services/llm/factory.rs @@ -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, 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"), + } + } +} diff --git a/backend/src/services/llm/gemini.rs b/backend/src/services/llm/gemini.rs new file mode 100644 index 0000000..5f3784a --- /dev/null +++ b/backend/src/services/llm/gemini.rs @@ -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 { + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/backend/src/services/llm/mod.rs b/backend/src/services/llm/mod.rs new file mode 100644 index 0000000..09dda88 --- /dev/null +++ b/backend/src/services/llm/mod.rs @@ -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; + + /// 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; + + /// Whether this provider supports native web search grounding. + fn supports_web_search(&self) -> bool; +} diff --git a/backend/src/services/llm/schema.rs b/backend/src/services/llm/schema.rs new file mode 100644 index 0000000..6f8bd35 --- /dev/null +++ b/backend/src/services/llm/schema.rs @@ -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 = (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 = 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"); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 1be8f11..d65e4aa 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,6 +1,8 @@ pub mod auth; pub mod csv; pub mod email; +pub mod encryption; +pub mod llm; pub mod rate_limiter; pub mod scraper; pub mod turnstile; diff --git a/backend/tests/api_keys_test.rs b/backend/tests/api_keys_test.rs new file mode 100644 index 0000000..634d521 --- /dev/null +++ b/backend/tests/api_keys_test.rs @@ -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,) = 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" + ); +} diff --git a/frontend/src/__tests__/api-keys.test.ts b/frontend/src/__tests__/api-keys.test.ts new file mode 100644 index 0000000..033350b --- /dev/null +++ b/frontend/src/__tests__/api-keys.test.ts @@ -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('...'); + }); +}); diff --git a/frontend/src/api/apiKeys.ts b/frontend/src/api/apiKeys.ts new file mode 100644 index 0000000..5a96f91 --- /dev/null +++ b/frontend/src/api/apiKeys.ts @@ -0,0 +1,16 @@ +import { api } from './client'; +import type { UserApiKey, CreateApiKeyRequest, TestApiKeyResponse } from '~/types'; + +export const apiKeysApi = { + list: (): Promise => + api.get('/user/api-keys'), + + create: (data: CreateApiKeyRequest): Promise => + api.post('/user/api-keys', data), + + remove: (provider: string): Promise => + api.delete(`/user/api-keys/${encodeURIComponent(provider)}`), + + test: (provider: string): Promise => + api.post(`/user/api-keys/${encodeURIComponent(provider)}/test`), +}; diff --git a/frontend/src/components/ApiKeyManager.tsx b/frontend/src/components/ApiKeyManager.tsx new file mode 100644 index 0000000..84488ac --- /dev/null +++ b/frontend/src/components/ApiKeyManager.tsx @@ -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 = (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 ( +
+
+
+ +

+ {t('settings.apiKeys.title')} +

+
+

+ {t('settings.apiKeys.description')} +

+
+ +
+ + {(provider) => ( + + )} + +
+
+ ); +}; + +interface ProviderKeyCardProps { + provider: ProviderConfig; + apiKey: UserApiKey | undefined; + onKeyChanged: () => void; +} + +const ProviderKeyCard: Component = (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 ( +
+ {/* Header: Provider name + status badge */} +
+
+ + {props.provider.display_name} + + + {t('settings.apiKeys.notConfigured')} + + } + > + + {t('settings.apiKeys.configured')} + + +
+ + {/* Action buttons for configured keys */} + +
+ + + + + +
+ } + > + +
+
+ +
+ + {/* Key prefix display */} + +

+ + {t('settings.apiKeys.keyPrefix', { prefix: props.apiKey!.key_prefix })} + +

+
+ + {/* Input form: shown when not configured or editing */} + +
+ +
+
+ setKeyInput(e.currentTarget.value)} + placeholder={t('settings.apiKeys.inputPlaceholder', { + provider: props.provider.display_name, + })} + /> + +
+ + + + +
+
+
+ + ); +}; + +export default ApiKeyManager; diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts index c01697e..b185ef7 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -117,6 +117,35 @@ const fr = { 'sources.urlRequired': "L'URL est requise.", 'sources.urlInvalid': "L'URL n'est pas valide.", + // Settings - API Keys + 'settings.apiKeys.title': 'Vos cles API', + 'settings.apiKeys.description': + 'Configurez vos cles API pour chaque fournisseur. Votre cle est chiffree et stockee de maniere securisee.', + 'settings.apiKeys.configured': 'Configuree', + 'settings.apiKeys.notConfigured': 'Non configuree', + 'settings.apiKeys.keyPrefix': 'Cle : {prefix}', + 'settings.apiKeys.inputLabel': 'Cle API', + 'settings.apiKeys.inputPlaceholder': 'Entrez votre cle API pour {provider}', + 'settings.apiKeys.showKey': 'Afficher la cle', + 'settings.apiKeys.hideKey': 'Masquer la cle', + 'settings.apiKeys.save': 'Enregistrer', + 'settings.apiKeys.saving': 'Enregistrement...', + 'settings.apiKeys.saved': 'Cle API enregistree avec succes.', + 'settings.apiKeys.saveError': "Erreur lors de l'enregistrement de la cle API.", + 'settings.apiKeys.update': 'Modifier', + 'settings.apiKeys.test': 'Tester', + 'settings.apiKeys.testing': 'Test en cours...', + 'settings.apiKeys.testSuccess': 'Cle API valide. Connexion reussie.', + 'settings.apiKeys.testFailure': 'Cle API invalide : {message}', + 'settings.apiKeys.delete': 'Supprimer', + 'settings.apiKeys.deleteConfirm': + 'Etes-vous sur de vouloir supprimer cette cle API ? Vous ne pourrez plus generer de syntheses avec ce fournisseur.', + 'settings.apiKeys.deleted': 'Cle API supprimee avec succes.', + 'settings.apiKeys.deleteError': 'Erreur lors de la suppression de la cle API.', + 'settings.apiKeys.loadError': 'Erreur lors du chargement des cles API.', + 'settings.apiKeys.noWebSearch': + 'Ce fournisseur ne supporte pas la recherche web native. Le scraping backend sera utilise.', + // Settings - Provider 'settings.provider': "Fournisseur d'IA", 'settings.providerHelp': diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index abf3806..887ce1a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -14,6 +14,7 @@ import { useI18n } from '~/i18n'; import { DEFAULT_SETTINGS, isApiError } from '~/types'; import type { UserSettings, ProviderConfig } from '~/types'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; +import ApiKeyManager from '~/components/ApiKeyManager'; const Settings: Component = () => { const { t } = useI18n(); @@ -447,6 +448,11 @@ const Settings: Component = () => { + + {/* API Key Management */} + 0}> + + ); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3900d1b..a15a783 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -168,6 +168,26 @@ export interface UpdateUserRoleRequest { role: 'user' | 'admin'; } +// ---- User API Keys ---- + +export interface UserApiKey { + id: string; + provider_name: string; + key_prefix: string; + created_at: string; + updated_at: string; +} + +export interface CreateApiKeyRequest { + provider_name: string; + api_key: string; +} + +export interface TestApiKeyResponse { + success: boolean; + message: string; +} + // ---- Public Config ---- export interface ProviderConfigModel {