Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,27 @@
[package]
name = "nomifun-system"
version.workspace = true
edition.workspace = true
[dependencies]
nomifun-auth.workspace = true
nomifun-common.workspace = true
nomifun-db.workspace = true
nomifun-api-types.workspace = true
axum.workspace = true
tower.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
reqwest.workspace = true
tokio.workspace = true
aws-config.workspace = true
aws-sdk-bedrock.workspace = true
semver.workspace = true
dirs.workspace = true
[dev-dependencies]
tower = { workspace = true }
http-body-util.workspace = true
reqwest.workspace = true
wiremock = "0.6"
@@ -0,0 +1,5 @@
pub mod routes;
pub mod service;
pub use routes::{ConnectionTestRouterState, connection_test_routes};
pub use service::ConnectionTestService;
@@ -0,0 +1,64 @@
use axum::Router;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Extension, Json, State};
use axum::routing::post;
use nomifun_api_types::{ApiResponse, TestBedrockConnectionRequest};
use nomifun_auth::CurrentUser;
use nomifun_common::AppError;
use super::service::ConnectionTestService;
/// Router state for connection test routes.
#[derive(Clone)]
pub struct ConnectionTestRouterState {
pub service: ConnectionTestService,
}
/// Build the connection test router.
///
/// Routes:
/// - `POST /api/bedrock/test-connection` — test AWS Bedrock credentials
///
/// All routes require authentication (applied by the caller).
pub fn connection_test_routes(state: ConnectionTestRouterState) -> Router {
Router::new()
.route("/api/bedrock/test-connection", post(test_bedrock))
.with_state(state)
}
/// POST /api/bedrock/test-connection
///
/// Test AWS Bedrock credentials with a lightweight API call.
/// Returns 200 on success, 400 for validation errors, 422-equivalent for
/// invalid credentials (mapped to 400 with descriptive message).
async fn test_bedrock(
State(state): State<ConnectionTestRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<TestBedrockConnectionRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.service.test_bedrock_connection(req.bedrock_config).await?;
Ok(Json(ApiResponse::message("Connection successful")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_router_state_clone() {
let state = ConnectionTestRouterState {
service: ConnectionTestService::new(reqwest::Client::new()),
};
let _cloned = state.clone();
}
#[test]
fn test_router_construction() {
let state = ConnectionTestRouterState {
service: ConnectionTestService::new(reqwest::Client::new()),
};
let _router = connection_test_routes(state);
}
}
@@ -0,0 +1,241 @@
use std::time::Duration;
use aws_sdk_bedrock::config::Credentials;
use nomifun_api_types::{BedrockAuthMethod, BedrockConfig};
use nomifun_common::AppError;
use tracing::{info, warn};
/// Default Bedrock model for lightweight connection testing.
const DEFAULT_BEDROCK_TEST_MODEL: &str = "anthropic.claude-sonnet-4-5-20250929-v1:0";
/// Timeout for Bedrock connection test.
const BEDROCK_TEST_TIMEOUT: Duration = Duration::from_secs(10);
/// Service for external connection testing (Bedrock credentials).
#[derive(Clone, Default)]
pub struct ConnectionTestService;
impl ConnectionTestService {
/// Create a new `ConnectionTestService`.
///
/// The `_http_client` parameter is retained for API compatibility but is
/// currently unused — Bedrock uses its own AWS SDK HTTP client and no
/// other connection types live on this service.
pub fn new(_http_client: reqwest::Client) -> Self {
Self
}
/// Test AWS Bedrock credentials by performing a lightweight API call.
///
/// Constructs an isolated credential provider (no global env pollution)
/// and calls `get_foundation_model` as a zero-cost validation.
pub async fn test_bedrock_connection(&self, config: BedrockConfig) -> Result<(), AppError> {
validate_bedrock_config(&config)?;
let aws_config = build_aws_config(&config).await;
let bedrock_config = aws_sdk_bedrock::config::Builder::from(&aws_config)
.timeout_config(
aws_config::timeout::TimeoutConfig::builder()
.operation_timeout(BEDROCK_TEST_TIMEOUT)
.build(),
)
.build();
let client = aws_sdk_bedrock::Client::from_conf(bedrock_config);
client
.get_foundation_model()
.model_identifier(DEFAULT_BEDROCK_TEST_MODEL)
.send()
.await
.map_err(|e| {
warn!(error = %e, "Bedrock connection test failed");
AppError::UnprocessableEntity(format!("Bedrock credentials invalid: {e}"))
})?;
info!("Bedrock connection test passed");
Ok(())
}
}
/// Validate required fields in BedrockConfig based on auth method.
fn validate_bedrock_config(config: &BedrockConfig) -> Result<(), AppError> {
if config.region.is_empty() {
return Err(AppError::BadRequest("region is required".into()));
}
match config.auth_method {
BedrockAuthMethod::AccessKey => {
if config.access_key_id.as_deref().unwrap_or("").is_empty() {
return Err(AppError::BadRequest(
"accessKeyId is required for accessKey auth method".into(),
));
}
if config.secret_access_key.as_deref().unwrap_or("").is_empty() {
return Err(AppError::BadRequest(
"secretAccessKey is required for accessKey auth method".into(),
));
}
}
BedrockAuthMethod::Profile => {
if config.profile.as_deref().unwrap_or("").is_empty() {
return Err(AppError::BadRequest(
"profile is required for profile auth method".into(),
));
}
}
}
Ok(())
}
/// Build AWS SDK config from BedrockConfig without polluting global environment.
async fn build_aws_config(config: &BedrockConfig) -> aws_config::SdkConfig {
let region = aws_config::Region::new(config.region.clone());
match config.auth_method {
BedrockAuthMethod::AccessKey => {
let credentials = Credentials::new(
config.access_key_id.as_deref().unwrap_or_default(),
config.secret_access_key.as_deref().unwrap_or_default(),
None,
None,
"nomifun-bedrock-test",
);
aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(region)
.credentials_provider(credentials)
.load()
.await
}
BedrockAuthMethod::Profile => {
aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(region)
.profile_name(config.profile.as_deref().unwrap_or_default())
.load()
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_api_types::BedrockConfig;
// -- validate_bedrock_config --
#[test]
fn test_validate_access_key_ok() {
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "us-east-1".into(),
access_key_id: Some("AKIAIOSFODNN7".into()),
secret_access_key: Some("wJalrXUtnFEMI".into()),
profile: None,
};
assert!(validate_bedrock_config(&config).is_ok());
}
#[test]
fn test_validate_profile_ok() {
let config = BedrockConfig {
auth_method: BedrockAuthMethod::Profile,
region: "eu-west-1".into(),
access_key_id: None,
secret_access_key: None,
profile: Some("my-profile".into()),
};
assert!(validate_bedrock_config(&config).is_ok());
}
#[test]
fn test_validate_empty_region() {
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "".into(),
access_key_id: Some("AKIA".into()),
secret_access_key: Some("secret".into()),
profile: None,
};
let err = validate_bedrock_config(&config).unwrap_err();
assert!(err.to_string().contains("region"));
}
#[test]
fn test_validate_access_key_missing_key_id() {
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "us-east-1".into(),
access_key_id: None,
secret_access_key: Some("secret".into()),
profile: None,
};
let err = validate_bedrock_config(&config).unwrap_err();
assert!(err.to_string().contains("accessKeyId"));
}
#[test]
fn test_validate_access_key_missing_secret() {
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "us-east-1".into(),
access_key_id: Some("AKIA".into()),
secret_access_key: None,
profile: None,
};
let err = validate_bedrock_config(&config).unwrap_err();
assert!(err.to_string().contains("secretAccessKey"));
}
#[test]
fn test_validate_access_key_empty_key_id() {
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "us-east-1".into(),
access_key_id: Some("".into()),
secret_access_key: Some("secret".into()),
profile: None,
};
let err = validate_bedrock_config(&config).unwrap_err();
assert!(err.to_string().contains("accessKeyId"));
}
#[test]
fn test_validate_profile_missing() {
let config = BedrockConfig {
auth_method: BedrockAuthMethod::Profile,
region: "us-east-1".into(),
access_key_id: None,
secret_access_key: None,
profile: None,
};
let err = validate_bedrock_config(&config).unwrap_err();
assert!(err.to_string().contains("profile"));
}
#[test]
fn test_validate_profile_empty() {
let config = BedrockConfig {
auth_method: BedrockAuthMethod::Profile,
region: "us-east-1".into(),
access_key_id: None,
secret_access_key: None,
profile: Some("".into()),
};
let err = validate_bedrock_config(&config).unwrap_err();
assert!(err.to_string().contains("profile"));
}
#[test]
fn test_default_bedrock_test_model() {
assert!(DEFAULT_BEDROCK_TEST_MODEL.starts_with("anthropic.claude"));
}
// -- ConnectionTestService construction --
#[test]
fn test_service_construction() {
let client = reqwest::Client::new();
let _service = ConnectionTestService::new(client);
}
}
@@ -0,0 +1,56 @@
//! Branding configuration service — converts DB rows to API types.
use nomifun_api_types::{
BrandingConfigResponse, PresetColorsResponse, ThemePresetResponse,
};
use nomifun_db::models::{BrandingConfigRow, ThemePreset};
/// Convert `BrandingConfigRow` to `BrandingConfigResponse`.
pub fn branding_config_row_to_response(row: BrandingConfigRow) -> BrandingConfigResponse {
BrandingConfigResponse {
id: row.id,
logo_light: row.logo_light,
logo_dark: row.logo_dark,
logo_favicon: row.logo_favicon,
primary_color: row.primary_color,
secondary_color: row.secondary_color,
accent_color: row.accent_color,
background_light: row.background_light,
background_dark: row.background_dark,
surface_light: row.surface_light,
surface_dark: row.surface_dark,
text_primary_light: row.text_primary_light,
text_primary_dark: row.text_primary_dark,
text_secondary_light: row.text_secondary_light,
text_secondary_dark: row.text_secondary_dark,
border_light: row.border_light,
border_dark: row.border_dark,
active_preset: row.active_preset,
custom_css: row.custom_css,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
/// Convert `ThemePreset` to `ThemePresetResponse`.
pub fn theme_preset_to_response(preset: ThemePreset) -> ThemePresetResponse {
ThemePresetResponse {
id: preset.id,
name: preset.name,
description: preset.description,
colors: PresetColorsResponse {
primary_color: preset.colors.primary_color,
secondary_color: preset.colors.secondary_color,
accent_color: preset.colors.accent_color,
background_light: preset.colors.background_light,
background_dark: preset.colors.background_dark,
surface_light: preset.colors.surface_light,
surface_dark: preset.colors.surface_dark,
text_primary_light: preset.colors.text_primary_light,
text_primary_dark: preset.colors.text_primary_dark,
text_secondary_light: preset.colors.text_secondary_light,
text_secondary_dark: preset.colors.text_secondary_dark,
border_light: preset.colors.border_light,
border_dark: preset.colors.border_dark,
},
}
}
@@ -0,0 +1,245 @@
use std::sync::Arc;
use nomifun_api_types::{ClientPreferencesResponse, UpdateClientPreferencesRequest};
use nomifun_common::AppError;
use nomifun_db::IClientPreferenceRepository;
/// Maximum allowed key length for client preferences.
const MAX_KEY_LENGTH: usize = 255;
/// Business logic for client preferences (generic key-value store).
#[derive(Clone)]
pub struct ClientPrefService {
repo: Arc<dyn IClientPreferenceRepository>,
}
impl ClientPrefService {
pub fn new(repo: Arc<dyn IClientPreferenceRepository>) -> Self {
Self { repo }
}
/// Get all client preferences, or only the specified keys.
pub async fn get_preferences(&self, keys: Option<&[&str]>) -> Result<ClientPreferencesResponse, AppError> {
let rows = match keys {
Some(k) if !k.is_empty() => self.repo.get_by_keys(k).await,
_ => self.repo.get_all().await,
}
.map_err(|e| AppError::Internal(format!("Failed to get preferences: {e}")))?;
let mut map = ClientPreferencesResponse::new();
for row in rows {
let value: serde_json::Value =
serde_json::from_str(&row.value).unwrap_or(serde_json::Value::String(row.value));
map.insert(row.key, value);
}
Ok(map)
}
/// Batch update client preferences. Null values delete the key.
pub async fn update_preferences(&self, req: UpdateClientPreferencesRequest) -> Result<(), AppError> {
let mut upserts: Vec<(String, String)> = Vec::new();
let mut deletes: Vec<String> = Vec::new();
for (key, value) in req {
validate_key(&key)?;
if value.is_null() {
deletes.push(key);
} else {
upserts.push((
key,
serde_json::to_string(&value)
.map_err(|e| AppError::Internal(format!("Failed to serialize value: {e}")))?,
));
}
}
if !upserts.is_empty() {
let entries: Vec<(&str, &str)> = upserts.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
self.repo
.upsert_batch(&entries)
.await
.map_err(|e| AppError::Internal(format!("Failed to upsert preferences: {e}")))?;
}
if !deletes.is_empty() {
let keys: Vec<&str> = deletes.iter().map(|k| k.as_str()).collect();
self.repo
.delete_keys(&keys)
.await
.map_err(|e| AppError::Internal(format!("Failed to delete preferences: {e}")))?;
}
Ok(())
}
}
fn validate_key(key: &str) -> Result<(), AppError> {
if key.is_empty() {
return Err(AppError::BadRequest("Preference key must not be empty".into()));
}
if key.len() > MAX_KEY_LENGTH {
return Err(AppError::BadRequest(format!(
"Preference key exceeds maximum length of {MAX_KEY_LENGTH} characters"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_db::{SqliteClientPreferenceRepository, init_database_memory};
use serde_json::json;
async fn setup() -> ClientPrefService {
let db = init_database_memory().await.unwrap();
let repo = Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()));
std::mem::forget(db);
ClientPrefService::new(repo)
}
#[test]
fn validate_key_accepts_valid() {
assert!(validate_key("theme").is_ok());
assert!(validate_key("system.closeToTray").is_ok());
assert!(validate_key("a").is_ok());
}
#[test]
fn validate_key_rejects_empty() {
assert!(validate_key("").is_err());
}
#[test]
fn validate_key_rejects_too_long() {
let long_key = "x".repeat(MAX_KEY_LENGTH + 1);
assert!(validate_key(&long_key).is_err());
}
#[tokio::test]
async fn get_empty_returns_empty_map() {
let svc = setup().await;
let prefs = svc.get_preferences(None).await.unwrap();
assert!(prefs.is_empty());
}
#[tokio::test]
async fn update_and_get_boolean() {
let svc = setup().await;
let mut req = UpdateClientPreferencesRequest::new();
req.insert("system.closeToTray".into(), json!(true));
svc.update_preferences(req).await.unwrap();
let prefs = svc.get_preferences(None).await.unwrap();
assert_eq!(prefs["system.closeToTray"], json!(true));
}
#[tokio::test]
async fn update_and_get_number() {
let svc = setup().await;
let mut req = UpdateClientPreferencesRequest::new();
req.insert("companion.size".into(), json!(360));
svc.update_preferences(req).await.unwrap();
let prefs = svc.get_preferences(None).await.unwrap();
assert_eq!(prefs["companion.size"], json!(360));
}
#[tokio::test]
async fn update_and_get_string() {
let svc = setup().await;
let mut req = UpdateClientPreferencesRequest::new();
req.insert("theme".into(), json!("dark"));
svc.update_preferences(req).await.unwrap();
let prefs = svc.get_preferences(None).await.unwrap();
assert_eq!(prefs["theme"], json!("dark"));
}
#[tokio::test]
async fn null_deletes_key() {
let svc = setup().await;
let mut req = UpdateClientPreferencesRequest::new();
req.insert("theme".into(), json!("dark"));
svc.update_preferences(req).await.unwrap();
let mut req2 = UpdateClientPreferencesRequest::new();
req2.insert("theme".into(), json!(null));
svc.update_preferences(req2).await.unwrap();
let prefs = svc.get_preferences(None).await.unwrap();
assert!(!prefs.contains_key("theme"));
}
#[tokio::test]
async fn get_by_keys_filters() {
let svc = setup().await;
let mut req = UpdateClientPreferencesRequest::new();
req.insert("a".into(), json!(1));
req.insert("b".into(), json!(2));
req.insert("c".into(), json!(3));
svc.update_preferences(req).await.unwrap();
let prefs = svc.get_preferences(Some(&["a", "c"])).await.unwrap();
assert_eq!(prefs.len(), 2);
assert_eq!(prefs["a"], json!(1));
assert_eq!(prefs["c"], json!(3));
}
#[tokio::test]
async fn overwrite_existing_value() {
let svc = setup().await;
let mut req1 = UpdateClientPreferencesRequest::new();
req1.insert("k".into(), json!("v1"));
svc.update_preferences(req1).await.unwrap();
let mut req2 = UpdateClientPreferencesRequest::new();
req2.insert("k".into(), json!("v2"));
svc.update_preferences(req2).await.unwrap();
let prefs = svc.get_preferences(None).await.unwrap();
assert_eq!(prefs["k"], json!("v2"));
}
#[tokio::test]
async fn empty_key_rejected() {
let svc = setup().await;
let mut req = UpdateClientPreferencesRequest::new();
req.insert("".into(), json!(true));
let err = svc.update_preferences(req).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn long_key_rejected() {
let svc = setup().await;
let mut req = UpdateClientPreferencesRequest::new();
req.insert("x".repeat(256), json!(true));
let err = svc.update_preferences(req).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn batch_mixed_upsert_and_delete() {
let svc = setup().await;
let mut setup_req = UpdateClientPreferencesRequest::new();
setup_req.insert("keep".into(), json!(1));
setup_req.insert("remove".into(), json!(2));
svc.update_preferences(setup_req).await.unwrap();
let mut req = UpdateClientPreferencesRequest::new();
req.insert("remove".into(), json!(null));
req.insert("new".into(), json!(3));
svc.update_preferences(req).await.unwrap();
let prefs = svc.get_preferences(None).await.unwrap();
assert_eq!(prefs.len(), 2);
assert_eq!(prefs["keep"], json!(1));
assert_eq!(prefs["new"], json!(3));
}
}
@@ -0,0 +1,21 @@
//! System services: provider management, model fetching, settings, and version checks.
pub mod bedrock_probe;
pub mod branding;
pub mod client_pref;
pub mod model_fetcher;
pub mod protocol;
pub mod provider;
pub mod routes;
pub mod settings;
pub mod sysinfo;
pub mod version;
pub use bedrock_probe::{ConnectionTestRouterState, ConnectionTestService, connection_test_routes};
pub use branding::{branding_config_row_to_response, theme_preset_to_response};
pub use client_pref::ClientPrefService;
pub use model_fetcher::ModelFetchService;
pub use protocol::ProtocolDetectionService;
pub use provider::ProviderService;
pub use routes::{SystemRouterState, settings_routes, system_routes};
pub use settings::SettingsService;
pub use version::VersionCheckService;
@@ -0,0 +1,385 @@
use std::time::Duration;
use nomifun_api_types::ModelInfo;
use nomifun_common::AppError;
use serde::Deserialize;
use tracing::warn;
use super::FetchConfig;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Dispatch to the appropriate platform-specific fetcher.
pub(crate) async fn fetch_for_platform(
client: &reqwest::Client,
config: &FetchConfig,
) -> Result<Vec<ModelInfo>, AppError> {
match config.platform.as_str() {
"anthropic" | "claude" => fetch_anthropic(client, &config.base_url, &config.api_key).await,
"gemini" => fetch_gemini(client, &config.base_url, &config.api_key).await,
"bedrock" => fetch_bedrock(config).await,
"vertex-ai" => Ok(vertex_ai_models()),
"new-api" => fetch_new_api(client, &config.base_url, &config.api_key).await,
"minimax" => Ok(minimax_models()),
"dashscope-coding" => fetch_dashscope_coding(client, &config.base_url, &config.api_key).await,
_ => fetch_openai_compatible(client, &config.base_url, &config.api_key).await,
}
}
// ---------------------------------------------------------------------------
// OpenAI-compatible (default)
// ---------------------------------------------------------------------------
/// Response shape for OpenAI `/models` endpoint.
#[derive(Deserialize)]
struct OpenAiModelsResponse {
data: Vec<OpenAiModel>,
}
#[derive(Deserialize)]
struct OpenAiModel {
id: String,
}
/// Fetch models from an OpenAI-compatible `/models` endpoint.
pub(super) async fn fetch_openai_compatible(
client: &reqwest::Client,
base_url: &str,
api_key: &str,
) -> Result<Vec<ModelInfo>, AppError> {
let url = format!("{}/models", base_url.trim_end_matches('/'));
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {api_key}"))
.timeout(REQUEST_TIMEOUT)
.send()
.await
.map_err(|e| remote_error(&e))?;
check_response_status(&resp)?;
let body: OpenAiModelsResponse = resp
.json()
.await
.map_err(|e| AppError::BadGateway(format!("Failed to parse models response: {e}")))?;
Ok(body.data.into_iter().map(|m| ModelInfo::Id(m.id)).collect())
}
// ---------------------------------------------------------------------------
// Anthropic
// ---------------------------------------------------------------------------
/// Response shape for Anthropic `/v1/models`.
#[derive(Deserialize)]
struct AnthropicModelsResponse {
data: Vec<AnthropicModel>,
}
#[derive(Deserialize)]
struct AnthropicModel {
id: String,
}
const ANTHROPIC_FALLBACK_MODELS: &[&str] = &[
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"claude-3-7-sonnet-20250219",
];
async fn fetch_anthropic(client: &reqwest::Client, base_url: &str, api_key: &str) -> Result<Vec<ModelInfo>, AppError> {
let url = format!("{}/v1/models", base_url.trim_end_matches('/'));
let result = client
.get(&url)
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.timeout(REQUEST_TIMEOUT)
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => {
let body: AnthropicModelsResponse = resp
.json()
.await
.map_err(|e| AppError::BadGateway(format!("Failed to parse Anthropic response: {e}")))?;
Ok(body.data.into_iter().map(|m| ModelInfo::Id(m.id)).collect())
}
Ok(resp) => {
warn!(
status = %resp.status(),
"Anthropic models API failed, using fallback list"
);
Ok(fallback_models(ANTHROPIC_FALLBACK_MODELS))
}
Err(e) => {
warn!(error = %e, "Anthropic models API unreachable, using fallback list");
Ok(fallback_models(ANTHROPIC_FALLBACK_MODELS))
}
}
}
// ---------------------------------------------------------------------------
// Gemini
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct GeminiModelsResponse {
models: Vec<GeminiModel>,
}
#[derive(Deserialize)]
struct GeminiModel {
name: String,
}
const GEMINI_FALLBACK_MODELS: &[&str] = &["gemini-2.5-pro", "gemini-2.5-flash"];
async fn fetch_gemini(client: &reqwest::Client, base_url: &str, api_key: &str) -> Result<Vec<ModelInfo>, AppError> {
let url = format!("{}/v1beta/models?key={api_key}", base_url.trim_end_matches('/'));
let result = client.get(&url).timeout(REQUEST_TIMEOUT).send().await;
match result {
Ok(resp) if resp.status().is_success() => {
let body: GeminiModelsResponse = resp
.json()
.await
.map_err(|e| AppError::BadGateway(format!("Failed to parse Gemini response: {e}")))?;
let models = body
.models
.into_iter()
.map(|m| {
// Strip "models/" prefix: "models/gemini-2.5-pro" -> "gemini-2.5-pro"
let id = m.name.strip_prefix("models/").unwrap_or(&m.name).to_owned();
ModelInfo::Id(id)
})
.collect();
Ok(models)
}
Ok(resp) => {
warn!(
status = %resp.status(),
"Gemini models API failed, using fallback list"
);
Ok(fallback_models(GEMINI_FALLBACK_MODELS))
}
Err(e) => {
warn!(error = %e, "Gemini models API unreachable, using fallback list");
Ok(fallback_models(GEMINI_FALLBACK_MODELS))
}
}
}
// ---------------------------------------------------------------------------
// Bedrock (AWS SDK)
// ---------------------------------------------------------------------------
async fn fetch_bedrock(config: &FetchConfig) -> Result<Vec<ModelInfo>, AppError> {
let bedrock_cfg = config
.bedrock_config
.as_ref()
.ok_or_else(|| AppError::BadRequest("Bedrock requires bedrockConfig".into()))?;
let region = aws_sdk_bedrock::config::Region::new(bedrock_cfg.region.clone());
let sdk_config = match bedrock_cfg.auth_method {
nomifun_api_types::BedrockAuthMethod::AccessKey => {
let key_id = bedrock_cfg
.access_key_id
.as_deref()
.ok_or_else(|| AppError::BadRequest("accessKeyId is required".into()))?;
let secret = bedrock_cfg
.secret_access_key
.as_deref()
.ok_or_else(|| AppError::BadRequest("secretAccessKey is required".into()))?;
let creds = aws_sdk_bedrock::config::Credentials::new(
key_id, secret, None, // session token
None, // expiry
"nomifun",
);
aws_sdk_bedrock::Config::builder()
.region(region)
.credentials_provider(creds)
.build()
}
nomifun_api_types::BedrockAuthMethod::Profile => {
let profile = bedrock_cfg.profile.as_deref().unwrap_or("default");
let aws_cfg = aws_config::from_env()
.profile_name(profile)
.region(aws_config::Region::new(bedrock_cfg.region.clone()))
.load()
.await;
aws_sdk_bedrock::Config::new(&aws_cfg)
}
};
let client = aws_sdk_bedrock::Client::from_conf(sdk_config);
let resp = client
.list_inference_profiles()
.send()
.await
.map_err(|e| AppError::BadGateway(format!("Bedrock API error: {e}")))?;
let profiles = resp.inference_profile_summaries();
// Filter to only anthropic.claude models per API Spec
let models: Vec<ModelInfo> = profiles
.iter()
.filter(|p| p.inference_profile_id().starts_with("anthropic.claude"))
.map(|p| ModelInfo::Id(p.inference_profile_id().to_string()))
.collect();
Ok(models)
}
// ---------------------------------------------------------------------------
// Hardcoded platforms
// ---------------------------------------------------------------------------
fn vertex_ai_models() -> Vec<ModelInfo> {
vec![
ModelInfo::Id("gemini-2.5-pro".into()),
ModelInfo::Id("gemini-2.5-flash".into()),
]
}
fn minimax_models() -> Vec<ModelInfo> {
vec![
ModelInfo::Id("MiniMax-Text-01".into()),
ModelInfo::Id("abab6.5s-chat".into()),
ModelInfo::Id("abab6.5-chat".into()),
]
}
// ---------------------------------------------------------------------------
// new-api (OpenAI-compatible with /v1 enforcement)
// ---------------------------------------------------------------------------
async fn fetch_new_api(client: &reqwest::Client, base_url: &str, api_key: &str) -> Result<Vec<ModelInfo>, AppError> {
let normalized = ensure_v1_path(base_url);
fetch_openai_compatible(client, &normalized, api_key).await
}
/// Ensure the URL path ends with `/v1`.
fn ensure_v1_path(base_url: &str) -> String {
let trimmed = base_url.trim_end_matches('/');
if trimmed.ends_with("/v1") {
trimmed.to_string()
} else {
format!("{trimmed}/v1")
}
}
// ---------------------------------------------------------------------------
// dashscope-coding (hardcoded + key validation)
// ---------------------------------------------------------------------------
const DASHSCOPE_MODELS: &[&str] = &["qwen-coder-plus", "qwen-coder-turbo"];
async fn fetch_dashscope_coding(
client: &reqwest::Client,
base_url: &str,
api_key: &str,
) -> Result<Vec<ModelInfo>, AppError> {
// Validate key by sending a minimal chat completion request
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": DASHSCOPE_MODELS[0],
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 1
});
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {api_key}"))
.json(&body)
.timeout(REQUEST_TIMEOUT)
.send()
.await
.map_err(|e| remote_error(&e))?;
if resp.status().is_client_error() {
return Err(AppError::BadGateway(format!(
"Dashscope API key validation failed: {}",
resp.status()
)));
}
Ok(fallback_models(DASHSCOPE_MODELS))
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
fn fallback_models(ids: &[&str]) -> Vec<ModelInfo> {
ids.iter().map(|id| ModelInfo::Id((*id).to_string())).collect()
}
fn check_response_status(resp: &reqwest::Response) -> Result<(), AppError> {
if resp.status().is_success() {
return Ok(());
}
Err(AppError::BadGateway(format!("Remote API returned {}", resp.status())))
}
fn remote_error(e: &reqwest::Error) -> AppError {
if e.is_timeout() {
AppError::Timeout("Remote API request timed out".into())
} else {
AppError::BadGateway(format!("Remote API request failed: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_v1_path_already_present() {
assert_eq!(
ensure_v1_path("https://api.example.com/v1"),
"https://api.example.com/v1"
);
}
#[test]
fn ensure_v1_path_missing() {
assert_eq!(ensure_v1_path("https://api.example.com"), "https://api.example.com/v1");
}
#[test]
fn ensure_v1_path_trailing_slash() {
assert_eq!(ensure_v1_path("https://api.example.com/"), "https://api.example.com/v1");
}
#[test]
fn ensure_v1_path_with_v1_and_trailing_slash() {
assert_eq!(
ensure_v1_path("https://api.example.com/v1/"),
"https://api.example.com/v1"
);
}
#[test]
fn vertex_ai_returns_expected_models() {
let models = vertex_ai_models();
assert_eq!(models.len(), 2);
assert_eq!(models[0], ModelInfo::Id("gemini-2.5-pro".into()));
assert_eq!(models[1], ModelInfo::Id("gemini-2.5-flash".into()));
}
#[test]
fn minimax_returns_expected_models() {
let models = minimax_models();
assert_eq!(models.len(), 3);
assert_eq!(models[0], ModelInfo::Id("MiniMax-Text-01".into()));
}
#[test]
fn fallback_models_builds_model_info_list() {
let models = fallback_models(&["a", "b", "c"]);
assert_eq!(models.len(), 3);
assert_eq!(models[0], ModelInfo::Id("a".into()));
}
}
@@ -0,0 +1,299 @@
mod fetchers;
mod url_fixer;
use std::sync::Arc;
use nomifun_api_types::{BedrockConfig, FetchModelsAnonymousRequest, FetchModelsRequest, FetchModelsResponse};
use nomifun_common::{AppError, decrypt_string};
use nomifun_db::IProviderRepository;
use crate::provider::deserialize_opt;
/// Internal configuration extracted from a provider row for model fetching.
#[derive(Debug)]
pub(crate) struct FetchConfig {
pub platform: String,
pub base_url: String,
pub api_key: String,
pub bedrock_config: Option<BedrockConfig>,
}
/// Service for fetching model lists from remote provider APIs.
#[derive(Clone)]
pub struct ModelFetchService {
repo: Arc<dyn IProviderRepository>,
encryption_key: [u8; 32],
http_client: reqwest::Client,
}
impl ModelFetchService {
pub fn new(repo: Arc<dyn IProviderRepository>, encryption_key: [u8; 32], http_client: reqwest::Client) -> Self {
Self {
repo,
encryption_key,
http_client,
}
}
/// Fetch models for a provider by ID. If `try_fix` is true and the
/// initial request fails on an OpenAI-compatible platform, attempt
/// URL auto-correction with parallel probing.
pub async fn fetch_models(
&self,
provider_id: &str,
req: &FetchModelsRequest,
) -> Result<FetchModelsResponse, AppError> {
let config = self.load_provider_config(provider_id).await?;
self.fetch_with_config(&config, req.try_fix).await
}
/// Fetch models using credentials supplied in the request, without a
/// persisted provider row. Powers the pre-create "Fetch Models" preview
/// in the Add-Platform form.
pub async fn fetch_models_anonymous(
&self,
req: &FetchModelsAnonymousRequest,
) -> Result<FetchModelsResponse, AppError> {
validate_anonymous_request(req)?;
let config = FetchConfig {
platform: req.platform.clone(),
base_url: req.base_url.clone(),
api_key: req.api_key.clone(),
bedrock_config: req.bedrock_config.clone(),
};
self.fetch_with_config(&config, req.try_fix).await
}
/// Shared fetch+try_fix branch used by both the by-id and anonymous
/// entry points.
async fn fetch_with_config(&self, config: &FetchConfig, try_fix: bool) -> Result<FetchModelsResponse, AppError> {
match fetchers::fetch_for_platform(&self.http_client, config).await {
Ok(models) => Ok(FetchModelsResponse {
models,
fixed_base_url: None,
}),
Err(err) if try_fix && supports_url_fix(&config.platform) => {
url_fixer::try_fix_url(&self.http_client, config).await.map_err(|_| err)
}
Err(err) => Err(err),
}
}
/// Extract and decrypt provider configuration from DB.
async fn load_provider_config(&self, provider_id: &str) -> Result<FetchConfig, AppError> {
let row = self
.repo
.find_by_id(provider_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Provider {provider_id} not found")))?;
let api_key = decrypt_string(&row.api_key_encrypted, &self.encryption_key)?;
if api_key.trim().is_empty() {
return Err(AppError::BadRequest("API key is empty".into()));
}
let bedrock_config: Option<BedrockConfig> = deserialize_opt(&row.bedrock_config, "bedrock_config")?;
Ok(FetchConfig {
platform: row.platform,
base_url: row.base_url,
api_key,
bedrock_config,
})
}
}
/// Validate a `FetchModelsAnonymousRequest` — platform / base_url / api_key
/// must all be non-empty after trim.
fn validate_anonymous_request(req: &FetchModelsAnonymousRequest) -> Result<(), AppError> {
if req.platform.trim().is_empty() {
return Err(AppError::BadRequest("platform is required".into()));
}
if req.base_url.trim().is_empty() {
return Err(AppError::BadRequest("baseUrl is required".into()));
}
// Bedrock uses bedrock_config for credentials; empty api_key is allowed there.
if req.platform != "bedrock" && req.api_key.trim().is_empty() {
return Err(AppError::BadRequest("apiKey is required".into()));
}
Ok(())
}
/// Platforms that support URL auto-fix (OpenAI-compatible).
fn supports_url_fix(platform: &str) -> bool {
!matches!(
platform,
"anthropic" | "claude" | "gemini" | "bedrock" | "vertex-ai" | "minimax" | "dashscope-coding"
)
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_common::encrypt_string;
use nomifun_db::{CreateProviderParams, SqliteProviderRepository, init_database_memory};
const TEST_KEY: [u8; 32] = [0x42; 32];
async fn setup() -> (ModelFetchService, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let svc = ModelFetchService::new(repo, TEST_KEY, reqwest::Client::new());
(svc, db)
}
async fn create_provider(db: &nomifun_db::Database, platform: &str, base_url: &str, api_key: &str) -> String {
let repo = SqliteProviderRepository::new(db.pool().clone());
let encrypted = encrypt_string(api_key, &TEST_KEY).unwrap();
let row = repo
.create(CreateProviderParams {
id: None,
platform,
name: "Test",
base_url,
api_key_encrypted: &encrypted,
models: "[]",
enabled: true,
capabilities: "[]",
context_limit: None,
model_protocols: None,
model_enabled: None,
model_health: None,
bedrock_config: None,
is_full_url: false,
})
.await
.unwrap();
row.id
}
#[test]
fn supports_url_fix_openai_compatible() {
assert!(supports_url_fix("openai"));
assert!(supports_url_fix("new-api"));
assert!(supports_url_fix("some-custom-provider"));
}
#[test]
fn supports_url_fix_non_openai() {
assert!(!supports_url_fix("anthropic"));
assert!(!supports_url_fix("claude"));
assert!(!supports_url_fix("gemini"));
assert!(!supports_url_fix("bedrock"));
assert!(!supports_url_fix("vertex-ai"));
assert!(!supports_url_fix("minimax"));
assert!(!supports_url_fix("dashscope-coding"));
}
#[tokio::test]
async fn load_config_nonexistent_provider_returns_not_found() {
let (svc, _db) = setup().await;
let err = svc.load_provider_config("no_such_id").await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn load_config_empty_api_key_returns_bad_request() {
let (svc, db) = setup().await;
let id = create_provider(&db, "openai", "https://api.openai.com", " ").await;
let err = svc.load_provider_config(&id).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn load_config_decrypts_api_key() {
let (svc, db) = setup().await;
let id = create_provider(&db, "openai", "https://api.openai.com", "sk-test-key").await;
let config = svc.load_provider_config(&id).await.unwrap();
assert_eq!(config.api_key, "sk-test-key");
assert_eq!(config.platform, "openai");
assert_eq!(config.base_url, "https://api.openai.com");
assert!(config.bedrock_config.is_none());
}
#[tokio::test]
async fn fetch_models_vertex_ai_returns_hardcoded() {
let (svc, db) = setup().await;
let id = create_provider(&db, "vertex-ai", "https://unused", "fake-key").await;
let req = FetchModelsRequest { try_fix: false };
let resp = svc.fetch_models(&id, &req).await.unwrap();
assert_eq!(resp.models.len(), 2);
assert!(resp.fixed_base_url.is_none());
}
#[tokio::test]
async fn fetch_models_minimax_returns_hardcoded() {
let (svc, db) = setup().await;
let id = create_provider(&db, "minimax", "https://unused", "fake-key").await;
let req = FetchModelsRequest { try_fix: false };
let resp = svc.fetch_models(&id, &req).await.unwrap();
assert_eq!(resp.models.len(), 3);
}
#[tokio::test]
async fn fetch_models_nonexistent_provider() {
let (svc, _db) = setup().await;
let req = FetchModelsRequest { try_fix: false };
let err = svc.fetch_models("no_such_id", &req).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn fetch_models_anonymous_minimax_returns_hardcoded() {
let (svc, _db) = setup().await;
let req = FetchModelsAnonymousRequest {
platform: "minimax".into(),
base_url: "https://unused".into(),
api_key: "fake-key".into(),
bedrock_config: None,
try_fix: false,
};
let resp = svc.fetch_models_anonymous(&req).await.unwrap();
assert_eq!(resp.models.len(), 3);
assert!(resp.fixed_base_url.is_none());
}
#[tokio::test]
async fn fetch_models_anonymous_rejects_empty_api_key() {
let (svc, _db) = setup().await;
let req = FetchModelsAnonymousRequest {
platform: "openai".into(),
base_url: "https://api.openai.com".into(),
api_key: " ".into(),
bedrock_config: None,
try_fix: false,
};
let err = svc.fetch_models_anonymous(&req).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn fetch_models_anonymous_rejects_empty_platform() {
let (svc, _db) = setup().await;
let req = FetchModelsAnonymousRequest {
platform: "".into(),
base_url: "https://api.openai.com".into(),
api_key: "sk-test".into(),
bedrock_config: None,
try_fix: false,
};
let err = svc.fetch_models_anonymous(&req).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn fetch_models_anonymous_bedrock_allows_empty_api_key() {
// Bedrock uses bedrock_config for credentials, not api_key.
// With no bedrock_config attached the fetcher itself will fail,
// but validate_anonymous_request must not reject up-front.
let (_svc, _db) = setup().await;
let req = FetchModelsAnonymousRequest {
platform: "bedrock".into(),
base_url: "https://bedrock.example".into(),
api_key: "".into(),
bedrock_config: None,
try_fix: false,
};
assert!(validate_anonymous_request(&req).is_ok());
}
}
@@ -0,0 +1,87 @@
use nomifun_api_types::{FetchModelsResponse, ModelInfo};
use nomifun_common::AppError;
use tokio::task::JoinSet;
use tracing::debug;
use super::FetchConfig;
use super::fetchers::fetch_openai_compatible;
/// URL path suffixes to probe when auto-fixing.
const URL_VARIANTS: &[&str] = &[
"/v1",
"/api/v1",
"/openai/v1",
"/compatible-mode/v1",
"/v2",
"/api/v3",
"/api/paas/v4",
"/compatibility/v1",
];
/// Try multiple URL variants in parallel and return the first successful
/// result along with its corrected base URL.
pub(crate) async fn try_fix_url(
client: &reqwest::Client,
config: &FetchConfig,
) -> Result<FetchModelsResponse, AppError> {
let base = config.base_url.trim_end_matches('/');
let candidates = build_candidates(base);
debug!(
base_url = base,
candidate_count = candidates.len(),
"Starting URL auto-fix probe"
);
let mut set = JoinSet::new();
for candidate in candidates {
let client = client.clone();
let api_key = config.api_key.clone();
set.spawn(async move {
let models = fetch_openai_compatible(&client, &candidate, &api_key).await?;
Ok::<(Vec<ModelInfo>, String), AppError>((models, candidate))
});
}
while let Some(result) = set.join_next().await {
if let Ok(Ok((models, fixed_url))) = result {
set.abort_all();
debug!(fixed_url = %fixed_url, "URL auto-fix succeeded");
return Ok(FetchModelsResponse {
models,
fixed_base_url: Some(fixed_url),
});
}
}
Err(AppError::BadGateway("All URL variants failed during auto-fix".into()))
}
/// Build candidate URLs from the base URL and standard path suffixes.
fn build_candidates(base: &str) -> Vec<String> {
URL_VARIANTS.iter().map(|suffix| format!("{base}{suffix}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_candidates_generates_expected_urls() {
let candidates = build_candidates("https://api.example.com");
assert_eq!(candidates.len(), URL_VARIANTS.len());
assert!(candidates.contains(&"https://api.example.com/v1".to_string()));
assert!(candidates.contains(&"https://api.example.com/api/v1".to_string()));
assert!(candidates.contains(&"https://api.example.com/openai/v1".to_string()));
}
#[test]
fn build_candidates_no_double_slash() {
let candidates = build_candidates("https://api.example.com");
for c in &candidates {
// After scheme, no double slashes
let after_scheme = c.strip_prefix("https://").unwrap();
assert!(!after_scheme.contains("//"), "Double slash found in: {c}");
}
}
}
@@ -0,0 +1,781 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use nomifun_api_types::{
DetectProtocolRequest, DetectedProtocol, DetectionSuggestion, KeyTestResult, MultiKeyResult,
ProtocolDetectionResponse, SuggestionType,
};
use nomifun_common::{AppError, ProtocolType};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tracing::debug;
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
const MAX_CONCURRENT_KEY_TESTS: usize = 5;
/// Mask an API key for display in multi-key probe results: preserve the
/// prefix up to the last dash before the secret part and the last 4
/// characters, replacing the middle with `***`.
///
/// Only used for diagnostic output of the protocol-detection endpoint;
/// provider responses now return plaintext keys.
fn mask_api_key(key: &str) -> String {
if key.is_empty() {
return "***".to_string();
}
let tail_len = 4;
let prefix_end = key
.rmatch_indices('-')
.find(|(i, _)| key.len() - i > tail_len)
.map(|(i, _)| i + 1);
match prefix_end {
Some(pe) => {
let suffix_start = key.len().saturating_sub(tail_len);
let prefix = &key[..pe];
let suffix = &key[suffix_start..];
format!("{prefix}***{suffix}")
}
None => {
let suffix_start = key.len().saturating_sub(tail_len);
let suffix = &key[suffix_start..];
format!("***{suffix}")
}
}
}
// -- Shared response structs for probing --
#[derive(serde::Deserialize)]
struct DataResponse {
data: Vec<IdEntry>,
}
#[derive(serde::Deserialize)]
struct IdEntry {
id: String,
}
#[derive(serde::Deserialize)]
struct GeminiResponse {
models: Vec<NameEntry>,
}
#[derive(serde::Deserialize)]
struct NameEntry {
name: String,
}
// -- Probe outcome --
/// Outcome of probing a single protocol endpoint.
enum ProbeOutcome {
/// Protocol confirmed, models returned successfully.
Success {
models: Vec<String>,
fixed_base_url: Option<String>,
confidence: u8,
},
/// Protocol likely correct but authentication failed.
AuthFailure { fixed_base_url: Option<String> },
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
/// Service for detecting API endpoint protocol type.
#[derive(Clone)]
pub struct ProtocolDetectionService {
http_client: reqwest::Client,
}
impl ProtocolDetectionService {
pub fn new(http_client: reqwest::Client) -> Self {
Self { http_client }
}
pub async fn detect_protocol(&self, req: &DetectProtocolRequest) -> Result<ProtocolDetectionResponse, AppError> {
validate_request(req)?;
let keys = parse_keys(&req.api_key);
let primary_key = &keys[0];
let timeout = Duration::from_millis(req.timeout.unwrap_or(DEFAULT_TIMEOUT_MS));
let url_inferred = infer_from_url(&req.base_url);
let key_inferred = infer_from_key(primary_key);
let test_order = build_test_order(req.preferred_protocol, url_inferred, key_inferred);
debug!(
?url_inferred,
?key_inferred,
?test_order,
"Protocol detection: built test order"
);
// Probe each protocol in priority order, collecting all successes
let mut auth_failure: Option<(ProtocolType, Option<String>)> = None;
let mut successes: Vec<(ProtocolType, Vec<String>, Option<String>, u8)> = Vec::new();
for protocol in &test_order {
match self
.probe_protocol(*protocol, &req.base_url, primary_key, timeout)
.await
{
Ok(ProbeOutcome::Success {
models,
fixed_base_url,
confidence,
}) => {
successes.push((*protocol, models, fixed_base_url, confidence));
}
Ok(ProbeOutcome::AuthFailure { fixed_base_url }) => {
if auth_failure.is_none() {
auth_failure = Some((*protocol, fixed_base_url));
}
}
Err(e) => {
debug!(?protocol, error = %e, "Protocol probe failed");
}
}
}
// If we have at least one success, use the first (highest priority) as primary
if let Some((protocol, models, fixed_base_url, confidence)) = successes.first().cloned() {
let suggestion = success_suggestion(protocol, req.preferred_protocol);
let multi_key_result = if req.test_all_keys && keys.len() > 1 {
let effective = fixed_base_url.as_deref().unwrap_or(&req.base_url);
Some(self.test_all_keys(&keys, protocol, effective, timeout).await)
} else {
None
};
// Build detected_protocols from all successes (dedup by protocol, preserving order)
let mut seen = std::collections::HashSet::new();
let detected_protocols: Vec<DetectedProtocol> = successes
.iter()
.filter(|(p, _, _, _)| seen.insert(*p))
.map(|(p, m, _, c)| DetectedProtocol {
protocol: *p,
confidence: *c,
models: if m.is_empty() { None } else { Some(m.clone()) },
})
.collect();
return Ok(ProtocolDetectionResponse {
protocol,
confidence,
success: true,
fixed_base_url,
models: Some(models),
suggestion: Some(suggestion),
multi_key_result,
detected_protocols,
});
}
// No success — use auth failure result if available
if let Some((protocol, fixed_base_url)) = auth_failure {
let multi_key_result = if req.test_all_keys && keys.len() > 1 {
let effective = fixed_base_url.as_deref().unwrap_or(&req.base_url);
Some(self.test_all_keys(&keys, protocol, effective, timeout).await)
} else {
None
};
return Ok(ProtocolDetectionResponse {
protocol,
confidence: 50,
success: false,
fixed_base_url,
models: None,
suggestion: Some(check_key_suggestion()),
multi_key_result,
detected_protocols: vec![],
});
}
// All probes failed
Ok(ProtocolDetectionResponse {
protocol: ProtocolType::Unknown,
confidence: 0,
success: false,
fixed_base_url: None,
models: None,
suggestion: Some(check_key_suggestion()),
multi_key_result: None,
detected_protocols: vec![],
})
}
// -- Per-protocol probing --
async fn probe_protocol(
&self,
protocol: ProtocolType,
base_url: &str,
api_key: &str,
timeout: Duration,
) -> Result<ProbeOutcome, AppError> {
let base = base_url.trim_end_matches('/');
match protocol {
ProtocolType::OpenAI => self.probe_openai(base, api_key, timeout).await,
ProtocolType::Anthropic => self.probe_anthropic(base, api_key, timeout).await,
ProtocolType::Gemini => self.probe_gemini(base, api_key, timeout).await,
ProtocolType::Unknown => Err(AppError::Internal("Cannot probe unknown".into())),
}
}
async fn probe_openai(&self, base: &str, api_key: &str, timeout: Duration) -> Result<ProbeOutcome, AppError> {
let urls = [
(format!("{base}/models"), None),
(format!("{base}/v1/models"), Some(format!("{base}/v1"))),
];
let mut last_auth_failure: Option<Option<String>> = None;
for (url, fixed) in &urls {
let resp = self
.http_client
.get(url)
.header("Authorization", format!("Bearer {api_key}"))
.timeout(timeout)
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => {
let body: DataResponse = r
.json()
.await
.map_err(|e| AppError::BadGateway(format!("Parse failed: {e}")))?;
let confidence = if fixed.is_some() { 80 } else { 90 };
return Ok(ProbeOutcome::Success {
models: body.data.into_iter().map(|m| m.id).collect(),
fixed_base_url: fixed.clone(),
confidence,
});
}
Ok(r) if is_auth_error(r.status()) => {
last_auth_failure = Some(fixed.clone());
}
_ => {}
}
}
if let Some(fixed) = last_auth_failure {
return Ok(ProbeOutcome::AuthFailure { fixed_base_url: fixed });
}
Err(AppError::BadGateway("OpenAI probe failed".into()))
}
async fn probe_anthropic(&self, base: &str, api_key: &str, timeout: Duration) -> Result<ProbeOutcome, AppError> {
let url = format!("{base}/v1/models");
let resp = self
.http_client
.get(&url)
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.timeout(timeout)
.send()
.await
.map_err(|e| AppError::BadGateway(format!("Anthropic probe failed: {e}")))?;
if resp.status().is_success() {
let body: DataResponse = resp
.json()
.await
.map_err(|e| AppError::BadGateway(format!("Parse failed: {e}")))?;
return Ok(ProbeOutcome::Success {
models: body.data.into_iter().map(|m| m.id).collect(),
fixed_base_url: None,
confidence: 95,
});
}
if is_auth_error(resp.status()) {
return Ok(ProbeOutcome::AuthFailure { fixed_base_url: None });
}
Err(AppError::BadGateway(format!("Anthropic returned {}", resp.status())))
}
async fn probe_gemini(&self, base: &str, api_key: &str, timeout: Duration) -> Result<ProbeOutcome, AppError> {
let url = format!("{base}/v1beta/models?key={api_key}");
let resp = self
.http_client
.get(&url)
.timeout(timeout)
.send()
.await
.map_err(|e| AppError::BadGateway(format!("Gemini probe failed: {e}")))?;
if resp.status().is_success() {
let body: GeminiResponse = resp
.json()
.await
.map_err(|e| AppError::BadGateway(format!("Parse failed: {e}")))?;
let models = body
.models
.into_iter()
.map(|m| m.name.strip_prefix("models/").unwrap_or(&m.name).to_owned())
.collect();
return Ok(ProbeOutcome::Success {
models,
fixed_base_url: None,
confidence: 90,
});
}
if is_auth_error(resp.status()) {
return Ok(ProbeOutcome::AuthFailure { fixed_base_url: None });
}
Err(AppError::BadGateway(format!("Gemini returned {}", resp.status())))
}
// -- Multi-key testing --
async fn test_all_keys(
&self,
keys: &[String],
protocol: ProtocolType,
effective_base: &str,
timeout: Duration,
) -> MultiKeyResult {
let base = effective_base.trim_end_matches('/').to_owned();
let sem = Arc::new(Semaphore::new(MAX_CONCURRENT_KEY_TESTS));
let mut set = JoinSet::new();
for (i, key) in keys.iter().enumerate() {
let client = self.http_client.clone();
let key = key.clone();
let base = base.clone();
let sem = sem.clone();
set.spawn(async move {
let _permit = sem.acquire().await;
let start = Instant::now();
let ok = test_single_key(&client, protocol, &base, &key, timeout).await;
let latency = start.elapsed().as_millis() as i64;
KeyTestResult {
index: i,
masked_key: mask_api_key(&key),
valid: ok.is_ok(),
latency: Some(latency),
error: ok.err().map(|e| e.to_string()),
}
});
}
let mut details = Vec::with_capacity(keys.len());
while let Some(result) = set.join_next().await {
if let Ok(kr) = result {
details.push(kr);
}
}
details.sort_by_key(|r| r.index);
let valid = details.iter().filter(|r| r.valid).count();
MultiKeyResult {
total: keys.len(),
valid,
invalid: keys.len() - valid,
details,
}
}
}
// ---------------------------------------------------------------------------
// Free functions
// ---------------------------------------------------------------------------
fn validate_request(req: &DetectProtocolRequest) -> Result<(), AppError> {
if req.base_url.trim().is_empty() {
return Err(AppError::BadRequest("baseUrl is required".into()));
}
if req.api_key.trim().is_empty() {
return Err(AppError::BadRequest("apiKey is required".into()));
}
Ok(())
}
fn parse_keys(raw: &str) -> Vec<String> {
raw.split([',', '\n'])
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect()
}
fn infer_from_url(url: &str) -> Option<ProtocolType> {
let lower = url.to_lowercase();
if lower.contains("anthropic") {
Some(ProtocolType::Anthropic)
} else if lower.contains("generativelanguage.googleapis.com") {
Some(ProtocolType::Gemini)
} else if lower.contains("openai") {
Some(ProtocolType::OpenAI)
} else {
None
}
}
fn infer_from_key(key: &str) -> Option<ProtocolType> {
if key.starts_with("sk-ant-") {
Some(ProtocolType::Anthropic)
} else if key.starts_with("AIza") {
Some(ProtocolType::Gemini)
} else {
None
}
}
/// Build ordered list of protocols to test.
/// Priority: preferred > URL inference > Key inference > default order.
fn build_test_order(
preferred: Option<ProtocolType>,
url_inferred: Option<ProtocolType>,
key_inferred: Option<ProtocolType>,
) -> Vec<ProtocolType> {
let defaults = [ProtocolType::OpenAI, ProtocolType::Anthropic, ProtocolType::Gemini];
let mut order = Vec::with_capacity(3);
for p in [preferred, url_inferred, key_inferred].into_iter().flatten() {
if p != ProtocolType::Unknown && !order.contains(&p) {
order.push(p);
}
}
for p in defaults {
if !order.contains(&p) {
order.push(p);
}
}
order
}
fn is_auth_error(status: reqwest::StatusCode) -> bool {
status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN
}
fn protocol_display_name(protocol: ProtocolType) -> &'static str {
match protocol {
ProtocolType::OpenAI => "OpenAI",
ProtocolType::Anthropic => "Anthropic",
ProtocolType::Gemini => "Gemini",
ProtocolType::Unknown => "Unknown",
}
}
fn success_suggestion(detected: ProtocolType, preferred: Option<ProtocolType>) -> DetectionSuggestion {
let should_switch = matches!(preferred, Some(p) if p != ProtocolType::Unknown && p != detected);
if should_switch {
DetectionSuggestion {
suggestion_type: SuggestionType::SwitchPlatform,
message: format!(
"Detected {} protocol, but preferred was {}",
protocol_display_name(detected),
protocol_display_name(preferred.unwrap_or(ProtocolType::Unknown)),
),
i18n_key: Some("settings.protocolMismatch".into()),
}
} else {
DetectionSuggestion {
suggestion_type: SuggestionType::None,
message: format!("Detected {} protocol", protocol_display_name(detected)),
i18n_key: Some("settings.protocolDetected".into()),
}
}
}
fn check_key_suggestion() -> DetectionSuggestion {
DetectionSuggestion {
suggestion_type: SuggestionType::CheckKey,
message: "Could not detect protocol. Please check your API key and URL.".into(),
i18n_key: Some("settings.protocolDetectionFailed".into()),
}
}
/// Test a single key against the detected protocol endpoint.
async fn test_single_key(
client: &reqwest::Client,
protocol: ProtocolType,
base: &str,
api_key: &str,
timeout: Duration,
) -> Result<(), AppError> {
let (url, headers) = match protocol {
ProtocolType::OpenAI => (
format!("{base}/models"),
vec![("Authorization", format!("Bearer {api_key}"))],
),
ProtocolType::Anthropic => (
format!("{base}/v1/models"),
vec![
("x-api-key", api_key.to_owned()),
("anthropic-version", "2023-06-01".to_owned()),
],
),
ProtocolType::Gemini => (format!("{base}/v1beta/models?key={api_key}"), vec![]),
ProtocolType::Unknown => {
return Err(AppError::Internal("Cannot test unknown protocol".into()));
}
};
let mut req = client.get(&url).timeout(timeout);
for (k, v) in &headers {
req = req.header(*k, v);
}
let resp = req
.send()
.await
.map_err(|e| AppError::BadGateway(format!("Request failed: {e}")))?;
if resp.status().is_success() {
Ok(())
} else {
Err(AppError::BadGateway(format!("Status: {}", resp.status())))
}
}
#[cfg(test)]
mod tests {
use super::*;
// -- parse_keys --
#[test]
fn parse_keys_single() {
let keys = parse_keys("sk-test-key");
assert_eq!(keys, vec!["sk-test-key"]);
}
#[test]
fn parse_keys_comma_separated() {
let keys = parse_keys("key1,key2,key3");
assert_eq!(keys, vec!["key1", "key2", "key3"]);
}
#[test]
fn parse_keys_newline_separated() {
let keys = parse_keys("key1\nkey2\nkey3");
assert_eq!(keys, vec!["key1", "key2", "key3"]);
}
#[test]
fn parse_keys_mixed_with_whitespace() {
let keys = parse_keys(" key1 , key2 \n key3 ");
assert_eq!(keys, vec!["key1", "key2", "key3"]);
}
#[test]
fn parse_keys_filters_empty() {
let keys = parse_keys("key1,,key2,");
assert_eq!(keys, vec!["key1", "key2"]);
}
// -- infer_from_url --
#[test]
fn infer_url_anthropic() {
assert_eq!(
infer_from_url("https://api.anthropic.com"),
Some(ProtocolType::Anthropic)
);
}
#[test]
fn infer_url_gemini() {
assert_eq!(
infer_from_url("https://generativelanguage.googleapis.com"),
Some(ProtocolType::Gemini)
);
}
#[test]
fn infer_url_openai() {
assert_eq!(infer_from_url("https://api.openai.com/v1"), Some(ProtocolType::OpenAI));
}
#[test]
fn infer_url_unknown() {
assert_eq!(infer_from_url("https://my-custom-api.com"), None);
}
#[test]
fn infer_url_case_insensitive() {
assert_eq!(
infer_from_url("https://API.ANTHROPIC.COM"),
Some(ProtocolType::Anthropic)
);
}
// -- infer_from_key --
#[test]
fn infer_key_anthropic() {
assert_eq!(infer_from_key("sk-ant-api03-test1234"), Some(ProtocolType::Anthropic));
}
#[test]
fn infer_key_gemini() {
assert_eq!(infer_from_key("AIzaSyBxxxxxx"), Some(ProtocolType::Gemini));
}
#[test]
fn infer_key_generic() {
assert_eq!(infer_from_key("sk-proj-abc123"), None);
}
// -- build_test_order --
#[test]
fn test_order_default() {
let order = build_test_order(None, None, None);
assert_eq!(
order,
vec![ProtocolType::OpenAI, ProtocolType::Anthropic, ProtocolType::Gemini]
);
}
#[test]
fn test_order_preferred_first() {
let order = build_test_order(Some(ProtocolType::Gemini), None, None);
assert_eq!(order[0], ProtocolType::Gemini);
assert_eq!(order.len(), 3);
}
#[test]
fn test_order_url_inferred() {
let order = build_test_order(None, Some(ProtocolType::Anthropic), None);
assert_eq!(order[0], ProtocolType::Anthropic);
}
#[test]
fn test_order_key_inferred() {
let order = build_test_order(None, None, Some(ProtocolType::Gemini));
assert_eq!(order[0], ProtocolType::Gemini);
assert_eq!(order[1], ProtocolType::OpenAI);
assert_eq!(order[2], ProtocolType::Anthropic);
}
#[test]
fn test_order_preferred_overrides() {
let order = build_test_order(
Some(ProtocolType::Gemini),
Some(ProtocolType::Anthropic),
Some(ProtocolType::OpenAI),
);
assert_eq!(
order,
vec![ProtocolType::Gemini, ProtocolType::Anthropic, ProtocolType::OpenAI]
);
}
#[test]
fn test_order_no_duplicates() {
let order = build_test_order(
Some(ProtocolType::OpenAI),
Some(ProtocolType::OpenAI),
Some(ProtocolType::OpenAI),
);
assert_eq!(order.len(), 3);
// Each protocol appears exactly once
assert!(order.contains(&ProtocolType::OpenAI));
assert!(order.contains(&ProtocolType::Anthropic));
assert!(order.contains(&ProtocolType::Gemini));
}
#[test]
fn test_order_unknown_preferred_ignored() {
let order = build_test_order(Some(ProtocolType::Unknown), None, None);
assert_eq!(
order,
vec![ProtocolType::OpenAI, ProtocolType::Anthropic, ProtocolType::Gemini]
);
}
// -- validate_request --
#[test]
fn validate_empty_base_url() {
let req = DetectProtocolRequest {
base_url: " ".into(),
api_key: "sk-test".into(),
timeout: None,
test_all_keys: false,
preferred_protocol: None,
};
assert!(validate_request(&req).is_err());
}
#[test]
fn validate_empty_api_key() {
let req = DetectProtocolRequest {
base_url: "https://api.example.com".into(),
api_key: " ".into(),
timeout: None,
test_all_keys: false,
preferred_protocol: None,
};
assert!(validate_request(&req).is_err());
}
#[test]
fn validate_ok() {
let req = DetectProtocolRequest {
base_url: "https://api.example.com".into(),
api_key: "sk-test".into(),
timeout: None,
test_all_keys: false,
preferred_protocol: None,
};
assert!(validate_request(&req).is_ok());
}
// -- suggestion helpers --
#[test]
fn success_suggestion_no_preferred() {
let s = success_suggestion(ProtocolType::Anthropic, None);
assert_eq!(s.suggestion_type, SuggestionType::None);
assert!(s.message.contains("Anthropic"));
}
#[test]
fn success_suggestion_same_preferred() {
let s = success_suggestion(ProtocolType::Anthropic, Some(ProtocolType::Anthropic));
assert_eq!(s.suggestion_type, SuggestionType::None);
}
#[test]
fn success_suggestion_different_preferred_returns_switch() {
let s = success_suggestion(ProtocolType::OpenAI, Some(ProtocolType::Anthropic));
assert_eq!(s.suggestion_type, SuggestionType::SwitchPlatform);
assert!(s.message.contains("OpenAI"));
assert!(s.message.contains("Anthropic"));
}
#[test]
fn success_suggestion_unknown_preferred_is_ignored() {
let s = success_suggestion(ProtocolType::Anthropic, Some(ProtocolType::Unknown));
assert_eq!(s.suggestion_type, SuggestionType::None);
}
#[test]
fn check_key_suggestion_has_check_key_type() {
let s = check_key_suggestion();
assert_eq!(s.suggestion_type, SuggestionType::CheckKey);
}
// -- is_auth_error --
#[test]
fn auth_error_detection() {
assert!(is_auth_error(reqwest::StatusCode::UNAUTHORIZED));
assert!(is_auth_error(reqwest::StatusCode::FORBIDDEN));
assert!(!is_auth_error(reqwest::StatusCode::OK));
assert!(!is_auth_error(reqwest::StatusCode::NOT_FOUND));
}
}
@@ -0,0 +1,653 @@
use std::collections::HashMap;
use std::sync::Arc;
use nomifun_api_types::{CreateProviderRequest, ProviderResponse, UpdateProviderRequest};
use nomifun_common::{AppError, decrypt_string, encrypt_string};
use nomifun_db::{CreateProviderParams, IProviderRepository, UpdateProviderParams, models::Provider};
use serde::de::DeserializeOwned;
/// Business logic for model provider CRUD with API key encryption/masking.
#[derive(Clone)]
pub struct ProviderService {
repo: Arc<dyn IProviderRepository>,
encryption_key: [u8; 32],
}
impl ProviderService {
pub fn new(repo: Arc<dyn IProviderRepository>, encryption_key: [u8; 32]) -> Self {
Self { repo, encryption_key }
}
/// List all providers with masked API keys.
pub async fn list(&self) -> Result<Vec<ProviderResponse>, AppError> {
let rows = self.repo.list().await?;
rows.into_iter().map(|row| self.row_to_response(row)).collect()
}
/// Create a new provider. The API key is encrypted before storage.
///
/// If `req.id` is `Some`, the caller-supplied id is used (after validation);
/// otherwise a fresh id is generated by the repository. This supports the
/// frontend-local-store → backend migration path where existing provider
/// ids must be preserved.
pub async fn create(&self, req: CreateProviderRequest) -> Result<ProviderResponse, AppError> {
validate_create_request(&req)?;
let encrypted_key = encrypt_string(&req.api_key, &self.encryption_key)?;
let models_json = serialize_json(&req.models, "models")?;
let capabilities_json = serialize_json(&req.capabilities, "capabilities")?;
let model_protocols_json = serialize_opt(&req.model_protocols, "model_protocols")?;
let model_enabled_json = serialize_opt(&req.model_enabled, "model_enabled")?;
let model_health_json = serialize_opt(&req.model_health, "model_health")?;
let bedrock_json = serialize_opt(&req.bedrock_config, "bedrock_config")?;
let trimmed_id = req.id.as_deref().map(str::trim);
let params = CreateProviderParams {
id: trimmed_id,
platform: &req.platform,
name: &req.name,
base_url: &req.base_url,
api_key_encrypted: &encrypted_key,
models: &models_json,
enabled: req.enabled,
capabilities: &capabilities_json,
context_limit: req.context_limit,
model_protocols: model_protocols_json.as_deref(),
model_enabled: model_enabled_json.as_deref(),
model_health: model_health_json.as_deref(),
bedrock_config: bedrock_json.as_deref(),
is_full_url: req.is_full_url,
};
let row = self.repo.create(params).await?;
self.row_to_response(row)
}
/// Update an existing provider. Only provided fields are changed.
pub async fn update(&self, id: &str, req: UpdateProviderRequest) -> Result<ProviderResponse, AppError> {
validate_update_request(&req)?;
let encrypted_key = req
.api_key
.as_deref()
.map(|k| encrypt_string(k, &self.encryption_key))
.transpose()?;
let models_json = serialize_opt(&req.models, "models")?;
let capabilities_json = serialize_opt(&req.capabilities, "capabilities")?;
let model_protocols_json = serialize_opt(&req.model_protocols, "model_protocols")?;
let model_enabled_json = serialize_opt(&req.model_enabled, "model_enabled")?;
let model_health_json = serialize_opt(&req.model_health, "model_health")?;
let bedrock_json = serialize_opt(&req.bedrock_config, "bedrock_config")?;
let params = UpdateProviderParams {
platform: req.platform.as_deref(),
name: req.name.as_deref(),
base_url: req.base_url.as_deref(),
api_key_encrypted: encrypted_key.as_deref(),
models: models_json.as_deref(),
enabled: req.enabled,
capabilities: capabilities_json.as_deref(),
context_limit: req.context_limit.map(Some),
model_protocols: model_protocols_json.as_ref().map(|s| Some(s.as_str())),
model_enabled: model_enabled_json.as_ref().map(|s| Some(s.as_str())),
model_health: model_health_json.as_ref().map(|s| Some(s.as_str())),
bedrock_config: bedrock_json.as_ref().map(|s| Some(s.as_str())),
is_full_url: req.is_full_url,
};
let row = self.repo.update(id, params).await?;
self.row_to_response(row)
}
/// Delete a provider by ID.
pub async fn delete(&self, id: &str) -> Result<(), AppError> {
self.repo.delete(id).await?;
Ok(())
}
// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
/// Convert a DB row into a response DTO with the plaintext API key
/// (decrypted) and deserialized JSON fields.
///
/// Pre-launch: the response returns the API key in plaintext so the
/// frontend can migrate its local store to the backend without losing
/// the key on re-read. Storage remains encrypted at rest.
fn row_to_response(&self, row: Provider) -> Result<ProviderResponse, AppError> {
let api_key = decrypt_string(&row.api_key_encrypted, &self.encryption_key)?;
let models: Vec<String> = serde_json::from_str(&row.models)
.map_err(|e| AppError::Internal(format!("Failed to parse models JSON: {e}")))?;
let capabilities = serde_json::from_str(&row.capabilities)
.map_err(|e| AppError::Internal(format!("Failed to parse capabilities JSON: {e}")))?;
let model_protocols: Option<HashMap<String, String>> =
deserialize_opt(&row.model_protocols, "model_protocols")?;
let model_enabled: Option<HashMap<String, bool>> = deserialize_opt(&row.model_enabled, "model_enabled")?;
let model_health = deserialize_opt(&row.model_health, "model_health")?;
let bedrock_config = deserialize_opt(&row.bedrock_config, "bedrock_config")?;
Ok(ProviderResponse {
id: row.id,
platform: row.platform,
name: row.name,
base_url: row.base_url,
api_key,
models,
enabled: row.enabled,
capabilities,
context_limit: row.context_limit,
model_protocols,
model_enabled,
model_health,
bedrock_config,
is_full_url: row.is_full_url,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
}
// ---------------------------------------------------------------------------
// JSON helpers (M-1 / M-2 refactor)
// ---------------------------------------------------------------------------
/// Serialize an optional value to JSON string.
fn serialize_opt<T: serde::Serialize>(val: &Option<T>, field: &str) -> Result<Option<String>, AppError> {
val.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|e| AppError::Internal(format!("Failed to serialize {field}: {e}")))
}
/// Serialize a value to JSON string.
fn serialize_json<T: serde::Serialize>(val: &T, field: &str) -> Result<String, AppError> {
serde_json::to_string(val).map_err(|e| AppError::Internal(format!("Failed to serialize {field}: {e}")))
}
/// Deserialize an optional JSON string into a typed value.
pub(crate) fn deserialize_opt<T: DeserializeOwned>(json: &Option<String>, field: &str) -> Result<Option<T>, AppError> {
json.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| AppError::Internal(format!("Failed to parse {field} JSON: {e}")))
}
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
fn validate_create_request(req: &CreateProviderRequest) -> Result<(), AppError> {
if let Some(ref id) = req.id {
validate_id(id)?;
}
if req.platform.trim().is_empty() {
return Err(AppError::BadRequest("platform is required".into()));
}
if req.name.trim().is_empty() {
return Err(AppError::BadRequest("name is required".into()));
}
// Bedrock auths via bedrock_config (IAM profile / static keys) rather than
// an HTTP endpoint + bearer key, so baseUrl and apiKey may be empty.
if req.platform == "bedrock" {
if req.bedrock_config.is_none() {
return Err(AppError::BadRequest(
"bedrockConfig is required for bedrock platform".into(),
));
}
if !req.base_url.trim().is_empty() {
validate_base_url(&req.base_url)?;
}
} else {
validate_base_url(&req.base_url)?;
if req.api_key.trim().is_empty() {
return Err(AppError::BadRequest("apiKey is required".into()));
}
}
Ok(())
}
/// Validate a caller-supplied provider id.
///
/// Accepts any non-empty string up to 128 chars consisting of alphanumerics,
/// dash, or underscore. This is deliberately permissive to accommodate both
/// UUID v4/v7 and legacy short hex ids from the pre-migration frontend.
fn validate_id(id: &str) -> Result<(), AppError> {
let trimmed = id.trim();
if trimmed.is_empty() {
return Err(AppError::BadRequest("id must not be empty".into()));
}
if trimmed.len() > 128 {
return Err(AppError::BadRequest("id must be at most 128 characters".into()));
}
if !trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(AppError::BadRequest(
"id may only contain alphanumerics, '-', or '_'".into(),
));
}
Ok(())
}
fn validate_update_request(req: &UpdateProviderRequest) -> Result<(), AppError> {
if let Some(ref platform) = req.platform
&& platform.trim().is_empty()
{
return Err(AppError::BadRequest("platform cannot be empty".into()));
}
if let Some(ref name) = req.name
&& name.trim().is_empty()
{
return Err(AppError::BadRequest("name cannot be empty".into()));
}
if let Some(ref url) = req.base_url
&& !url.trim().is_empty()
{
validate_base_url(url)?;
}
Ok(())
}
fn validate_base_url(url: &str) -> Result<(), AppError> {
if url.trim().is_empty() {
return Err(AppError::BadRequest("baseUrl is required".into()));
}
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(AppError::BadRequest(
"baseUrl must start with http:// or https://".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_db::{SqliteProviderRepository, init_database_memory};
// A fixed 32-byte key for testing
const TEST_KEY: [u8; 32] = [0x42; 32];
async fn setup() -> ProviderService {
let db = init_database_memory().await.unwrap();
let repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
std::mem::forget(db);
ProviderService::new(repo, TEST_KEY)
}
fn sample_create_request() -> CreateProviderRequest {
CreateProviderRequest {
id: None,
platform: "anthropic".into(),
name: "Anthropic".into(),
base_url: "https://api.anthropic.com".into(),
api_key: "sk-ant-api03-test1234".into(),
models: vec!["claude-sonnet-4-20250514".into()],
enabled: true,
capabilities: vec![],
context_limit: None,
model_protocols: None,
model_enabled: None,
model_health: None,
bedrock_config: None,
is_full_url: false,
}
}
// -- id validation tests --
#[test]
fn validate_id_accepts_uuid_v4() {
assert!(validate_id("11111111-1111-4111-8111-111111111111").is_ok());
}
#[test]
fn validate_id_accepts_short_hex() {
// Frontend's default uuid() yields an 8-char hex string.
assert!(validate_id("a1b2c3d4").is_ok());
}
#[test]
fn validate_id_accepts_underscore_and_dash() {
assert!(validate_id("prov_01-foo-bar").is_ok());
}
#[test]
fn validate_id_rejects_empty() {
assert!(validate_id("").is_err());
assert!(validate_id(" ").is_err());
}
#[test]
fn validate_id_rejects_too_long() {
let long = "a".repeat(129);
assert!(validate_id(&long).is_err());
}
#[test]
fn validate_id_rejects_disallowed_chars() {
assert!(validate_id("bad/slash").is_err());
assert!(validate_id("bad space").is_err());
assert!(validate_id("bad.dot").is_err());
}
// -- validation tests --
#[test]
fn validate_create_missing_platform() {
let req = CreateProviderRequest {
platform: "".into(),
..sample_create_request()
};
assert!(validate_create_request(&req).is_err());
}
#[test]
fn validate_create_missing_name() {
let req = CreateProviderRequest {
name: " ".into(),
..sample_create_request()
};
assert!(validate_create_request(&req).is_err());
}
#[test]
fn validate_create_missing_base_url() {
let req = CreateProviderRequest {
base_url: "".into(),
..sample_create_request()
};
assert!(validate_create_request(&req).is_err());
}
#[test]
fn validate_create_invalid_url() {
let req = CreateProviderRequest {
base_url: "not-a-url".into(),
..sample_create_request()
};
assert!(validate_create_request(&req).is_err());
}
#[test]
fn validate_create_missing_api_key() {
let req = CreateProviderRequest {
api_key: " ".into(),
..sample_create_request()
};
assert!(validate_create_request(&req).is_err());
}
#[test]
fn validate_create_valid() {
assert!(validate_create_request(&sample_create_request()).is_ok());
}
#[test]
fn validate_create_bedrock_allows_empty_base_url_and_api_key() {
let req = CreateProviderRequest {
platform: "bedrock".into(),
name: "AWS Bedrock".into(),
base_url: "".into(),
api_key: "".into(),
bedrock_config: Some(nomifun_api_types::BedrockConfig {
auth_method: nomifun_api_types::BedrockAuthMethod::Profile,
region: "us-west-2".into(),
profile: Some("ai".into()),
access_key_id: None,
secret_access_key: None,
}),
..sample_create_request()
};
assert!(validate_create_request(&req).is_ok());
}
#[test]
fn validate_create_bedrock_requires_bedrock_config() {
let req = CreateProviderRequest {
platform: "bedrock".into(),
name: "AWS Bedrock".into(),
base_url: "".into(),
api_key: "".into(),
bedrock_config: None,
..sample_create_request()
};
assert!(validate_create_request(&req).is_err());
}
#[test]
fn validate_update_empty_name_rejected() {
let req = UpdateProviderRequest {
name: Some("".into()),
..Default::default()
};
assert!(validate_update_request(&req).is_err());
}
#[test]
fn validate_update_empty_request_ok() {
assert!(validate_update_request(&UpdateProviderRequest::default()).is_ok());
}
#[test]
fn validate_update_empty_base_url_ok() {
let req = UpdateProviderRequest {
base_url: Some("".into()),
..Default::default()
};
assert!(validate_update_request(&req).is_ok());
}
#[test]
fn validate_update_invalid_base_url_rejected() {
let req = UpdateProviderRequest {
base_url: Some("not-a-url".into()),
..Default::default()
};
assert!(validate_update_request(&req).is_err());
}
#[test]
fn validate_base_url_http() {
assert!(validate_base_url("http://localhost:8080").is_ok());
}
#[test]
fn validate_base_url_https() {
assert!(validate_base_url("https://api.example.com").is_ok());
}
#[test]
fn validate_base_url_ftp_rejected() {
assert!(validate_base_url("ftp://files.example.com").is_err());
}
// -- service integration tests --
#[tokio::test]
async fn list_empty() {
let svc = setup().await;
let result = svc.list().await.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn create_and_list() {
let svc = setup().await;
let created = svc.create(sample_create_request()).await.unwrap();
assert!(created.id.starts_with("prov_"));
assert_eq!(created.platform, "anthropic");
assert_eq!(created.name, "Anthropic");
assert_eq!(created.base_url, "https://api.anthropic.com");
// API key is returned in plaintext (pre-launch; encrypted at rest).
assert_eq!(created.api_key, "sk-ant-api03-test1234");
assert_eq!(created.models, vec!["claude-sonnet-4-20250514"]);
assert!(created.enabled);
let all = svc.list().await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, created.id);
assert_eq!(all[0].api_key, "sk-ant-api03-test1234");
}
#[tokio::test]
async fn create_with_provided_id() {
let svc = setup().await;
let req = CreateProviderRequest {
id: Some("caller-id-xyz".into()),
..sample_create_request()
};
let created = svc.create(req).await.unwrap();
assert_eq!(created.id, "caller-id-xyz");
}
#[tokio::test]
async fn create_with_provided_id_rejects_invalid() {
let svc = setup().await;
let req = CreateProviderRequest {
id: Some(" ".into()),
..sample_create_request()
};
let err = svc.create(req).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_with_duplicate_id_returns_conflict() {
let svc = setup().await;
let req1 = CreateProviderRequest {
id: Some("dup-id".into()),
..sample_create_request()
};
svc.create(req1).await.unwrap();
let req2 = CreateProviderRequest {
id: Some("dup-id".into()),
..sample_create_request()
};
let err = svc.create(req2).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::CONFLICT);
}
#[tokio::test]
async fn create_persists_per_model_fields() {
use std::collections::HashMap;
let svc = setup().await;
let req = CreateProviderRequest {
model_protocols: Some(HashMap::from([("gpt-4".into(), "openai".into())])),
model_enabled: Some(HashMap::from([("gpt-4".into(), true), ("gpt-3.5".into(), false)])),
..sample_create_request()
};
let created = svc.create(req).await.unwrap();
assert_eq!(
created.model_protocols.as_ref().and_then(|m| m.get("gpt-4")),
Some(&"openai".to_string())
);
assert_eq!(created.model_enabled.as_ref().and_then(|m| m.get("gpt-4")), Some(&true));
assert_eq!(
created.model_enabled.as_ref().and_then(|m| m.get("gpt-3.5")),
Some(&false)
);
// And persist through a fresh read.
let all = svc.list().await.unwrap();
assert_eq!(all[0].model_enabled.as_ref().and_then(|m| m.get("gpt-4")), Some(&true));
}
#[tokio::test]
async fn provider_response_api_key_plaintext_matches_input() {
// Replaces the masking test: api_key on the response is the
// encrypted-then-decrypted plaintext (equal to the input).
let svc = setup().await;
let req = CreateProviderRequest {
api_key: "sk-secret-original-value".into(),
..sample_create_request()
};
let created = svc.create(req).await.unwrap();
assert_eq!(created.api_key, "sk-secret-original-value");
assert!(!created.api_key.contains("***"));
}
#[tokio::test]
async fn create_invalid_request_rejected() {
let svc = setup().await;
let req = CreateProviderRequest {
platform: "".into(),
..sample_create_request()
};
let err = svc.create(req).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn update_name() {
let svc = setup().await;
let created = svc.create(sample_create_request()).await.unwrap();
let updated = svc
.update(
&created.id,
UpdateProviderRequest {
name: Some("New Name".into()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.name, "New Name");
assert_eq!(updated.platform, "anthropic");
}
#[tokio::test]
async fn update_api_key_re_encrypts() {
let svc = setup().await;
let created = svc.create(sample_create_request()).await.unwrap();
let updated = svc
.update(
&created.id,
UpdateProviderRequest {
api_key: Some("new-key-abcdefgh".into()),
..Default::default()
},
)
.await
.unwrap();
// Response carries the new plaintext key (encrypted at rest).
assert_eq!(updated.api_key, "new-key-abcdefgh");
}
#[tokio::test]
async fn update_nonexistent_returns_not_found() {
let svc = setup().await;
let err = svc
.update("no_such_id", UpdateProviderRequest::default())
.await
.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn delete_existing() {
let svc = setup().await;
let created = svc.create(sample_create_request()).await.unwrap();
svc.delete(&created.id).await.unwrap();
let all = svc.list().await.unwrap();
assert!(all.is_empty());
}
#[tokio::test]
async fn delete_nonexistent_returns_not_found() {
let svc = setup().await;
let err = svc.delete("no_such_id").await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::NOT_FOUND);
}
}
@@ -0,0 +1,888 @@
use std::sync::Arc;
use axum::extract::{Multipart, Path, Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::Json;
use axum::Router;
use axum::extract::rejection::JsonRejection;
use nomifun_api_types::{
ApiResponse, AuditLogResponse, BrandingConfigResponse, ClientPreferencesResponse,
CreateAuditLogRequest, CreateProviderRequest, DetectProtocolRequest, DomainConfigResponse,
DomainPresetResponse, FetchModelsAnonymousRequest, FetchModelsRequest, FetchModelsResponse,
InitSystemRequest, PaginatedAuditLogsResponse, ProtocolDetectionResponse, ProviderResponse,
QueryAuditLogRequest, SystemInfoResponse, SystemInitializedResponse, SystemSettingsResponse,
ThemePresetResponse, UpdateBrandingRequest, UpdateCheckRequest, UpdateCheckResult,
UpdateClientPreferencesRequest, UpdateDomainConfigRequest, UpdateProviderRequest,
UpdateSettingsRequest,
};
use nomifun_common::AppError;
use nomifun_db::{
IAuditLogRepository, IBrandingConfigRepository, IDomainConfigRepository, ISystemConfigRepository,
};
use crate::branding::{branding_config_row_to_response, theme_preset_to_response};
use crate::client_pref::ClientPrefService;
use crate::model_fetcher::ModelFetchService;
use crate::protocol::ProtocolDetectionService;
use crate::provider::ProviderService;
use crate::settings::SettingsService;
use crate::version::VersionCheckService;
/// Shared state for system route handlers.
#[derive(Clone)]
pub struct SystemRouterState {
pub settings_service: SettingsService,
pub client_pref_service: ClientPrefService,
pub provider_service: ProviderService,
pub model_fetch_service: ModelFetchService,
pub protocol_detection_service: ProtocolDetectionService,
pub version_check_service: VersionCheckService,
/// Data directory root — used to arm a factory reset (write the marker that
/// the next boot consumes). See `nomifun_common::factory_reset`.
pub data_dir: std::path::PathBuf,
/// System configuration repository (organization name, domain type, initialization state).
pub system_config_repo: Arc<dyn ISystemConfigRepository>,
/// Branding configuration repository (colors, logos, presets).
pub branding_config_repo: Arc<dyn IBrandingConfigRepository>,
/// Audit log repository (append-only security/compliance trail).
pub audit_log_repo: Arc<dyn IAuditLogRepository>,
/// Domain configuration repository (domain type, presets, features, departments).
pub domain_config_repo: Arc<dyn IDomainConfigRepository>,
}
/// Build the system router (settings + client prefs + providers + system).
///
/// All routes require authentication (applied by the caller).
///
/// Endpoints:
/// - `GET /api/settings` — get all backend settings
/// - `PATCH /api/settings` — partial update backend settings
/// - `GET /api/settings/client` — get client preferences
/// - `PUT /api/settings/client` — batch update client preferences
/// - `GET /api/providers` — list all providers
/// - `POST /api/providers` — create a provider
/// - `PUT /api/providers/:id` — update a provider
/// - `DELETE /api/providers/:id` — delete a provider
/// - `POST /api/providers/:id/models` — fetch models from remote API
/// - `POST /api/providers/fetch-models` — fetch models anonymously (pre-create preview)
/// - `POST /api/providers/detect-protocol` — detect API protocol
/// - `GET /api/system/info` — system directory & platform info
/// - `POST /api/system/check-update` — check GitHub for new versions
/// - `POST /api/system/factory-reset` — arm a factory reset (wipes on next boot)
pub fn system_routes(state: SystemRouterState) -> Router {
Router::new()
.route("/api/settings", get(get_settings).patch(update_settings))
.route(
"/api/settings/client",
get(get_client_preferences).put(update_client_preferences),
)
.route("/api/providers", get(list_providers).post(create_provider))
// Literal-segment routes must register BEFORE the `/{id}` routes so
// axum matches the literals instead of treating "detect-protocol" /
// "fetch-models" as a provider id.
.route("/api/providers/detect-protocol", post(detect_protocol))
.route("/api/providers/fetch-models", post(fetch_models_anonymous))
.route("/api/providers/{id}", delete(delete_provider).put(update_provider))
.route("/api/providers/{id}/models", post(fetch_models))
.route("/api/system/info", get(get_system_info))
.route("/api/system/check-update", post(check_update))
.route("/api/system/factory-reset", post(factory_reset))
.route("/api/system/initialized", get(get_system_initialized))
.route("/api/system/init", post(init_system))
.route("/api/system/branding", get(get_branding_config).patch(update_branding_config))
.route(
"/api/system/branding/presets",
get(list_branding_presets),
)
.route(
"/api/system/branding/presets/{preset_id}",
post(apply_branding_preset),
)
.route("/api/system/branding/logo", post(upload_branding_logo))
.route(
"/api/system/audit-logs",
get(list_audit_logs).post(create_audit_log),
)
.route("/api/system/audit-logs/export", get(export_audit_logs_csv))
.route("/api/system/audit-logs/{id}", get(get_audit_log))
.route("/api/system/domain-config", get(get_domain_config).patch(update_domain_config))
.route("/api/system/domain-config/presets", get(list_domain_presets))
.route("/api/system/domain-config/presets/{preset_id}", post(apply_domain_preset))
.with_state(state)
}
/// Backwards-compatible alias — delegates to `system_routes`.
pub fn settings_routes(state: SystemRouterState) -> Router {
system_routes(state)
}
// ===========================================================================
// Settings handlers
// ===========================================================================
async fn get_settings(
State(state): State<SystemRouterState>,
) -> Result<Json<ApiResponse<SystemSettingsResponse>>, AppError> {
let settings = state.settings_service.get_settings().await?;
Ok(Json(ApiResponse::ok(settings)))
}
async fn update_settings(
State(state): State<SystemRouterState>,
body: Result<Json<UpdateSettingsRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<SystemSettingsResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let settings = state.settings_service.update_settings(req).await?;
Ok(Json(ApiResponse::ok(settings)))
}
// ===========================================================================
// Client preferences handlers
// ===========================================================================
#[derive(Debug, serde::Deserialize, Default)]
struct ClientPrefQuery {
keys: Option<String>,
}
async fn get_client_preferences(
State(state): State<SystemRouterState>,
Query(query): Query<ClientPrefQuery>,
) -> Result<Json<ApiResponse<ClientPreferencesResponse>>, AppError> {
let keys_filter: Option<Vec<String>> = query.keys.map(|k| {
k.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
});
let key_refs: Option<Vec<&str>> = keys_filter.as_ref().map(|v| v.iter().map(|s| s.as_str()).collect());
let prefs = state.client_pref_service.get_preferences(key_refs.as_deref()).await?;
Ok(Json(ApiResponse::ok(prefs)))
}
async fn update_client_preferences(
State(state): State<SystemRouterState>,
body: Result<Json<UpdateClientPreferencesRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.client_pref_service.update_preferences(req).await?;
Ok(Json(ApiResponse::success()))
}
// ===========================================================================
// Provider handlers
// ===========================================================================
async fn list_providers(
State(state): State<SystemRouterState>,
) -> Result<Json<ApiResponse<Vec<ProviderResponse>>>, AppError> {
let providers = state.provider_service.list().await?;
Ok(Json(ApiResponse::ok(providers)))
}
async fn create_provider(
State(state): State<SystemRouterState>,
body: Result<Json<CreateProviderRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<ApiResponse<ProviderResponse>>), AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let provider = state.provider_service.create(req).await?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(provider))))
}
async fn update_provider(
State(state): State<SystemRouterState>,
Path(id): Path<String>,
body: Result<Json<UpdateProviderRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<ProviderResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let provider = state.provider_service.update(&id, req).await?;
Ok(Json(ApiResponse::ok(provider)))
}
async fn delete_provider(
State(state): State<SystemRouterState>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.provider_service.delete(&id).await?;
Ok(Json(ApiResponse::success()))
}
async fn fetch_models(
State(state): State<SystemRouterState>,
Path(id): Path<String>,
body: Result<Json<FetchModelsRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<FetchModelsResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let result = state.model_fetch_service.fetch_models(&id, &req).await?;
Ok(Json(ApiResponse::ok(result)))
}
async fn fetch_models_anonymous(
State(state): State<SystemRouterState>,
body: Result<Json<FetchModelsAnonymousRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<FetchModelsResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let result = state.model_fetch_service.fetch_models_anonymous(&req).await?;
Ok(Json(ApiResponse::ok(result)))
}
async fn detect_protocol(
State(state): State<SystemRouterState>,
body: Result<Json<DetectProtocolRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<ProtocolDetectionResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let result = state.protocol_detection_service.detect_protocol(&req).await?;
Ok(Json(ApiResponse::ok(result)))
}
// ===========================================================================
// System info & version check handlers
// ===========================================================================
async fn get_system_info() -> Json<ApiResponse<SystemInfoResponse>> {
let info = crate::sysinfo::get_system_info();
Json(ApiResponse::ok(info))
}
async fn check_update(
State(state): State<SystemRouterState>,
body: Result<Json<UpdateCheckRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<UpdateCheckResult>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let result = state.version_check_service.check_update(&req).await?;
Ok(Json(ApiResponse::ok(result)))
}
// ===========================================================================
// Factory reset handler
// ===========================================================================
/// Arm a factory reset: write the marker that the next boot consumes. The
/// actual database/derived-data wipe happens early on the next startup (see
/// `nomifun_common::factory_reset`); the client should restart the app right
/// after this returns. Nothing is deleted synchronously here — that would race
/// with the live connection pool and the background write loops.
async fn factory_reset(State(state): State<SystemRouterState>) -> Result<Json<ApiResponse<()>>, AppError> {
let marker = nomifun_common::factory_reset::ResetMarker::new(nomifun_common::factory_reset::ResetScope::Full);
nomifun_common::factory_reset::write_marker(&state.data_dir, &marker)?;
tracing::warn!(target: "factory_reset", "factory reset armed — will wipe database and derived data on next restart");
Ok(Json(ApiResponse::success()))
}
// ===========================================================================
// System initialization handlers
// ===========================================================================
/// GET /api/system/initialized — returns whether the system has been set up.
async fn get_system_initialized(
State(state): State<SystemRouterState>,
) -> Result<Json<ApiResponse<SystemInitializedResponse>>, AppError> {
let cfg = state
.system_config_repo
.get_config()
.await?
.ok_or_else(|| AppError::Internal("system_config not initialized".into()))?;
let resp = SystemInitializedResponse {
initialized: cfg.initialized,
organization_name: cfg.organization_name,
domain_type: cfg.domain_type,
};
Ok(Json(ApiResponse::ok(resp)))
}
/// POST /api/system/init — submit the initial system configuration.
async fn init_system(
State(state): State<SystemRouterState>,
body: Result<Json<InitSystemRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<SystemInitializedResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let cfg = state
.system_config_repo
.init_system(&req.organization_name, &req.domain_type)
.await?;
// Audit log: system initialization
let audit_params = CreateAuditLogParams {
action: "system_init".to_string(),
category: AuditCategory::SystemConfig,
user_id: None, // System initialization has no authenticated user
username: None,
ip_address: None,
user_agent: None,
resource_type: Some("system_config".to_string()),
resource_id: Some(cfg.id.to_string()),
details: serde_json::json!({
"organization_name": req.organization_name,
"domain_type": req.domain_type,
}),
status: AuditStatus::Success,
};
if let Err(e) = state.audit_log_repo.create(audit_params).await {
tracing::warn!("Failed to create audit log for system init: {}", e);
}
let resp = SystemInitializedResponse {
initialized: cfg.initialized,
organization_name: cfg.organization_name,
domain_type: cfg.domain_type,
};
Ok(Json(ApiResponse::ok(resp)))
}
// ===========================================================================
// Branding configuration handlers
// ===========================================================================
/// GET /api/system/branding — get current branding configuration.
async fn get_branding_config(
State(state): State<SystemRouterState>,
) -> Result<Json<ApiResponse<BrandingConfigResponse>>, AppError> {
let cfg = state
.branding_config_repo
.get_config()
.await?
.ok_or_else(|| AppError::NotFound("branding_config not found".into()))?;
Ok(Json(ApiResponse::ok(branding_config_row_to_response(cfg))))
}
/// PATCH /api/system/branding — partial update branding configuration.
async fn update_branding_config(
State(state): State<SystemRouterState>,
body: Result<Json<UpdateBrandingRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<BrandingConfigResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
if req.is_empty() {
return Err(AppError::BadRequest(
"No branding fields to update".into(),
));
}
let params = nomifun_db::models::UpdateBrandingParams {
logo_light: req.logo_light.clone(),
logo_dark: req.logo_dark.clone(),
logo_favicon: req.logo_favicon.clone(),
primary_color: req.primary_color.clone(),
secondary_color: req.secondary_color.clone(),
accent_color: req.accent_color.clone(),
background_light: req.background_light.clone(),
background_dark: req.background_dark.clone(),
surface_light: req.surface_light.clone(),
surface_dark: req.surface_dark.clone(),
text_primary_light: req.text_primary_light.clone(),
text_primary_dark: req.text_primary_dark.clone(),
text_secondary_light: req.text_secondary_light.clone(),
text_secondary_dark: req.text_secondary_dark.clone(),
border_light: req.border_light.clone(),
border_dark: req.border_dark.clone(),
custom_css: req.custom_css.clone(),
};
let cfg = state.branding_config_repo.update_config(params).await?;
// Audit log: branding configuration update
let audit_params = CreateAuditLogParams {
action: "branding_config_update".to_string(),
category: AuditCategory::Branding,
user_id: None, // TODO: extract from auth context when available
username: None,
ip_address: None,
user_agent: None,
resource_type: Some("branding_config".to_string()),
resource_id: Some(cfg.id.to_string()),
details: serde_json::to_value(&req).unwrap_or(serde_json::json!({})),
status: AuditStatus::Success,
};
if let Err(e) = state.audit_log_repo.create(audit_params).await {
tracing::warn!("Failed to create audit log for branding update: {}", e);
}
Ok(Json(ApiResponse::ok(branding_config_row_to_response(cfg))))
}
/// GET /api/system/branding/presets — list all available theme presets.
async fn list_branding_presets(
State(state): State<SystemRouterState>,
) -> Result<Json<ApiResponse<Vec<ThemePresetResponse>>>, AppError> {
let presets: Vec<ThemePresetResponse> = state
.branding_config_repo
.get_presets()
.into_iter()
.map(theme_preset_to_response)
.collect();
Ok(Json(ApiResponse::ok(presets)))
}
/// POST /api/system/branding/presets/{preset_id} — apply a preset theme.
async fn apply_branding_preset(
State(state): State<SystemRouterState>,
Path(preset_id): Path<String>,
) -> Result<Json<ApiResponse<BrandingConfigResponse>>, AppError> {
let cfg = state
.branding_config_repo
.apply_preset(&preset_id)
.await?;
// Audit log: preset application
let audit_params = CreateAuditLogParams {
action: "branding_preset_apply".to_string(),
category: AuditCategory::Branding,
user_id: None,
username: None,
ip_address: None,
user_agent: None,
resource_type: Some("branding_config".to_string()),
resource_id: Some(cfg.id.to_string()),
details: serde_json::json!({
"preset_id": preset_id,
}),
status: AuditStatus::Success,
};
if let Err(e) = state.audit_log_repo.create(audit_params).await {
tracing::warn!("Failed to create audit log for preset application: {}", e);
}
Ok(Json(ApiResponse::ok(branding_config_row_to_response(cfg))))
}
/// POST /api/system/branding/logo — upload logo file (PNG/SVG).
async fn upload_branding_logo(
State(state): State<SystemRouterState>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<String>>, AppError> {
let mut file_data: Option<Vec<u8>> = None;
let mut file_name: Option<String> = None;
let mut logo_type: Option<String> = None; // "light", "dark", or "favicon"
// Extract multipart fields
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(format!("multipart error: {e}")))?
{
let name = field.name().unwrap_or("").to_owned();
match name.as_str() {
"file" => {
let original_filename = field.file_name().map(|s| s.to_owned());
file_data = Some(
field
.bytes()
.await
.map_err(|e| AppError::BadRequest(format!("failed to read file: {e}")))?
.to_vec(),
);
file_name = original_filename;
}
"type" => {
logo_type = Some(
field
.text()
.await
.map_err(|e| AppError::BadRequest(format!("failed to read type: {e}")))?
.trim()
.to_owned(),
);
}
_ => {}
}
}
let file_data = file_data.ok_or_else(|| AppError::BadRequest("missing 'file' field".to_owned()))?;
let file_name = file_name.ok_or_else(|| AppError::BadRequest("missing file name".to_owned()))?;
let logo_type = logo_type.unwrap_or_else(|| "light".to_owned());
// Validate file extension
let ext = std::path::Path::new(&file_name)
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase())
.ok_or_else(|| AppError::BadRequest("file must have an extension".to_owned()))?;
if !matches!(ext.as_str(), "png" | "svg" | "jpg" | "jpeg") {
return Err(AppError::BadRequest(
"only PNG, SVG, JPG files are supported".to_owned(),
));
}
// Create logos directory
let logos_dir = state.data_dir.join("logos");
tokio::fs::create_dir_all(&logos_dir)
.await
.map_err(|e| AppError::Internal(format!("failed to create logos directory: {e}")))?;
// Generate unique filename
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let safe_filename = format!("{}_{}.{}", logo_type, timestamp, ext);
let file_path = logos_dir.join(&safe_filename);
// Write file
tokio::fs::write(&file_path, &file_data)
.await
.map_err(|e| AppError::Internal(format!("failed to write logo file: {e}")))?;
// Return relative path for frontend
let relative_path = format!("/logos/{}", safe_filename);
// Update branding config with the new logo path
let update_params = match logo_type.as_str() {
"dark" => nomifun_db::models::UpdateBrandingParams {
logo_dark: Some(relative_path.clone()),
..Default::default()
},
"favicon" => nomifun_db::models::UpdateBrandingParams {
logo_favicon: Some(relative_path.clone()),
..Default::default()
},
_ => nomifun_db::models::UpdateBrandingParams {
logo_light: Some(relative_path.clone()),
..Default::default()
},
};
state.branding_config_repo.update_config(update_params).await?;
// Audit log: logo upload
let audit_params = CreateAuditLogParams {
action: "branding_logo_upload".to_string(),
category: AuditCategory::Branding,
user_id: None,
username: None,
ip_address: None,
user_agent: None,
resource_type: Some("branding_logo".to_string()),
resource_id: Some(relative_path.clone()),
details: serde_json::json!({
"logo_type": logo_type,
"filename": safe_filename,
"path": relative_path,
}),
status: AuditStatus::Success,
};
if let Err(e) = state.audit_log_repo.create(audit_params).await {
tracing::warn!("Failed to create audit log for logo upload: {}", e);
}
Ok(Json(ApiResponse::ok(relative_path)))
}
// ===========================================================================
// Audit log handlers
// ===========================================================================
use nomifun_db::models::{AuditCategory, AuditStatus, CreateAuditLogParams};
/// Convert `AuditLogRow` to `AuditLogResponse`.
fn audit_log_row_to_response(row: nomifun_db::models::AuditLogRow) -> AuditLogResponse {
AuditLogResponse {
id: row.id,
action: row.action,
category: row.category,
user_id: row.user_id,
username: row.username,
ip_address: row.ip_address,
user_agent: row.user_agent,
resource_type: row.resource_type,
resource_id: row.resource_id,
details: row.details.parse().unwrap_or(serde_json::Value::Null),
status: row.status,
created_at: row.created_at,
}
}
/// Convert `PaginatedAuditLogs` to `PaginatedAuditLogsResponse`.
fn paginated_to_response(result: nomifun_db::models::PaginatedAuditLogs) -> PaginatedAuditLogsResponse {
PaginatedAuditLogsResponse {
items: result.items.into_iter().map(audit_log_row_to_response).collect(),
total: result.total,
page: result.page,
page_size: result.page_size,
total_pages: result.total_pages,
}
}
/// GET /api/system/audit-logs — list audit logs with pagination and filtering.
async fn list_audit_logs(
State(state): State<SystemRouterState>,
Query(query): Query<QueryAuditLogRequest>,
) -> Result<Json<ApiResponse<PaginatedAuditLogsResponse>>, AppError> {
let params = nomifun_db::models::QueryAuditLogParams {
page: query.page,
page_size: query.page_size,
start_date: query.start_date,
end_date: query.end_date,
action: query.action,
user_id: query.user_id,
category: query.category,
status: query.status,
};
let result = state.audit_log_repo.query(params).await?;
Ok(Json(ApiResponse::ok(paginated_to_response(result))))
}
/// POST /api/system/audit-logs — create a new audit log entry.
async fn create_audit_log(
State(state): State<SystemRouterState>,
body: Result<Json<CreateAuditLogRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<AuditLogResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let params = CreateAuditLogParams {
action: req.action,
category: AuditCategory::from_str(&req.category),
user_id: req.user_id,
username: req.username,
ip_address: req.ip_address,
user_agent: req.user_agent,
resource_type: req.resource_type,
resource_id: req.resource_id,
details: req.details,
status: AuditStatus::from_str(&req.status),
};
let row = state.audit_log_repo.create(params).await?;
Ok(Json(ApiResponse::ok(audit_log_row_to_response(row))))
}
/// GET /api/system/audit-logs/{id} — get a single audit log entry.
async fn get_audit_log(
State(state): State<SystemRouterState>,
Path(id): Path<i64>,
) -> Result<Json<ApiResponse<AuditLogResponse>>, AppError> {
let row = state
.audit_log_repo
.get_by_id(id)
.await?
.ok_or_else(|| AppError::NotFound(format!("audit log {} not found", id)))?;
Ok(Json(ApiResponse::ok(audit_log_row_to_response(row))))
}
/// GET /api/system/audit-logs/export — export audit logs as CSV.
async fn export_audit_logs_csv(
State(state): State<SystemRouterState>,
Query(query): Query<QueryAuditLogRequest>,
) -> Result<Response, AppError> {
// Query logs with same filters but no pagination limit
let params = nomifun_db::models::QueryAuditLogParams {
page: Some(1),
page_size: Some(10000), // Export up to 10k records
start_date: query.start_date,
end_date: query.end_date,
action: query.action,
user_id: query.user_id,
category: query.category,
status: query.status,
};
let result = state.audit_log_repo.query(params).await?;
// Build CSV content
let mut csv_content = String::new();
csv_content.push_str("ID,Action,Category,User ID,Username,IP Address,User Agent,Resource Type,Resource ID,Status,Created At,Details\n");
for log in result.items {
let details_str = log.details.replace('"', "\"\""); // Escape quotes
csv_content.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{},{}\n",
log.id,
escape_csv_field(&log.action),
log.category,
log.user_id.unwrap_or_default(),
escape_csv_field(&log.username.unwrap_or_default()),
escape_csv_field(&log.ip_address.unwrap_or_default()),
escape_csv_field(&log.user_agent.unwrap_or_default()),
escape_csv_field(&log.resource_type.unwrap_or_default()),
escape_csv_field(&log.resource_id.unwrap_or_default()),
log.status,
log.created_at,
escape_csv_field(&details_str),
));
}
// Return as downloadable CSV
Ok((
[
(header::CONTENT_TYPE, "text/csv; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"audit_logs.csv\"",
),
],
csv_content,
)
.into_response())
}
/// Escape CSV field (wrap in quotes if contains comma, newline, or quote).
fn escape_csv_field(field: &str) -> String {
if field.contains(',') || field.contains('\n') || field.contains('"') {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_owned()
}
}
// ===========================================================================
// Domain configuration handlers
// ===========================================================================
use nomifun_db::models::{DomainConfigRow, DomainPreset};
/// Convert `DomainConfigRow` to `DomainConfigResponse`.
fn domain_config_row_to_response(row: DomainConfigRow) -> DomainConfigResponse {
let parse_json = |s: &str| -> serde_json::Value {
serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
};
let parse_string_vec = |s: &str| -> Vec<String> {
serde_json::from_str(s).unwrap_or_default()
};
DomainConfigResponse {
id: row.id as i64,
domain_type: row.domain_type,
settings: parse_json(&row.settings),
government_settings: parse_json(&row.government_settings),
enterprise_settings: parse_json(&row.enterprise_settings),
education_settings: parse_json(&row.education_settings),
enabled_features: parse_string_vec(&row.enabled_features),
departments: parse_string_vec(&row.departments),
custom_params: parse_json(&row.custom_params),
created_at: row.created_at,
updated_at: row.updated_at,
}
}
/// Convert `DomainPreset` to `DomainPresetResponse`.
fn domain_preset_to_response(preset: DomainPreset) -> DomainPresetResponse {
DomainPresetResponse {
id: preset.id,
name: preset.name,
domain_type: preset.domain_type,
description: preset.description,
settings: preset.settings,
config: preset.config,
sort_order: preset.sort_order,
created_at: preset.created_at,
}
}
/// GET /api/system/domain-config — get current domain configuration.
async fn get_domain_config(
State(state): State<SystemRouterState>,
) -> Result<Json<ApiResponse<DomainConfigResponse>>, AppError> {
let cfg = state
.domain_config_repo
.get_config()
.await?
.ok_or_else(|| AppError::NotFound("domain_config not found".into()))?;
Ok(Json(ApiResponse::ok(domain_config_row_to_response(cfg))))
}
/// PATCH /api/system/domain-config — partial update domain configuration.
async fn update_domain_config(
State(state): State<SystemRouterState>,
body: Result<Json<UpdateDomainConfigRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<DomainConfigResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
// Serialize req for audit log before moving its fields
let req_details = serde_json::to_value(&req).unwrap_or(serde_json::json!({}));
let params = nomifun_db::models::UpdateDomainConfigParams {
settings: req.settings.map(|v| v.to_string()),
government_settings: req.government_settings.map(|v| v.to_string()),
enterprise_settings: req.enterprise_settings.map(|v| v.to_string()),
education_settings: req.education_settings.map(|v| v.to_string()),
enabled_features: req.enabled_features.as_ref().map(|v| {
serde_json::to_string(v).unwrap_or_default()
}),
departments: req.departments.as_ref().map(|v| {
serde_json::to_string(v).unwrap_or_default()
}),
custom_params: req.custom_params.map(|v| v.to_string()),
};
let cfg = state.domain_config_repo.update_config(params).await?;
// Audit log: domain configuration update
let audit_params = CreateAuditLogParams {
action: "domain_config_update".to_string(),
category: AuditCategory::SystemConfig,
user_id: None,
username: None,
ip_address: None,
user_agent: None,
resource_type: Some("domain_config".to_string()),
resource_id: Some(cfg.id.to_string()),
details: req_details,
status: AuditStatus::Success,
};
if let Err(e) = state.audit_log_repo.create(audit_params).await {
tracing::warn!("Failed to create audit log for domain config update: {}", e);
}
Ok(Json(ApiResponse::ok(domain_config_row_to_response(cfg))))
}
/// GET /api/system/domain-config/presets — list all available domain presets.
async fn list_domain_presets(
State(state): State<SystemRouterState>,
) -> Result<Json<ApiResponse<Vec<DomainPresetResponse>>>, AppError> {
let presets: Vec<DomainPresetResponse> = state
.domain_config_repo
.get_presets()
.await?
.into_iter()
.map(domain_preset_to_response)
.collect();
Ok(Json(ApiResponse::ok(presets)))
}
/// POST /api/system/domain-config/presets/{preset_id} — apply a domain preset.
async fn apply_domain_preset(
State(state): State<SystemRouterState>,
Path(preset_id): Path<String>,
) -> Result<Json<ApiResponse<DomainConfigResponse>>, AppError> {
let cfg = state
.domain_config_repo
.apply_preset(&preset_id)
.await?;
// Audit log: domain preset application
let audit_params = CreateAuditLogParams {
action: "domain_preset_apply".to_string(),
category: AuditCategory::SystemConfig,
user_id: None,
username: None,
ip_address: None,
user_agent: None,
resource_type: Some("domain_config".to_string()),
resource_id: Some(cfg.id.to_string()),
details: serde_json::json!({
"preset_id": preset_id,
}),
status: AuditStatus::Success,
};
if let Err(e) = state.audit_log_repo.create(audit_params).await {
tracing::warn!("Failed to create audit log for domain preset application: {}", e);
}
Ok(Json(ApiResponse::ok(domain_config_row_to_response(cfg))))
}
@@ -0,0 +1,183 @@
use std::sync::Arc;
use nomifun_api_types::{SystemSettingsResponse, UpdateSettingsRequest};
use nomifun_common::AppError;
use nomifun_db::ISettingsRepository;
/// Supported BCP 47 language codes.
const SUPPORTED_LANGUAGES: &[&str] = &["en-US", "zh-CN"];
/// Business logic for system settings (language, notifications, etc.).
#[derive(Clone)]
pub struct SettingsService {
repo: Arc<dyn ISettingsRepository>,
}
impl SettingsService {
pub fn new(repo: Arc<dyn ISettingsRepository>) -> Self {
Self { repo }
}
/// Get current system settings, falling back to defaults if not yet persisted.
pub async fn get_settings(&self) -> Result<SystemSettingsResponse, AppError> {
let row = self
.repo
.get_settings()
.await
.map_err(|e| AppError::Internal(format!("Failed to get settings: {e}")))?;
Ok(
row.map_or_else(SystemSettingsResponse::default, |s| SystemSettingsResponse {
language: s.language,
notification_enabled: s.notification_enabled,
cron_notification_enabled: s.cron_notification_enabled,
command_queue_enabled: s.command_queue_enabled,
save_upload_to_workspace: s.save_upload_to_workspace,
}),
)
}
/// Partially update system settings. Only fields present in the request are changed.
pub async fn update_settings(&self, req: UpdateSettingsRequest) -> Result<SystemSettingsResponse, AppError> {
if let Some(ref lang) = req.language {
validate_language(lang)?;
}
// Merge with current settings (or defaults)
let current = self.get_settings().await?;
let language = req.language.unwrap_or(current.language);
let notification_enabled = req.notification_enabled.unwrap_or(current.notification_enabled);
let cron_notification_enabled = req
.cron_notification_enabled
.unwrap_or(current.cron_notification_enabled);
let command_queue_enabled = req.command_queue_enabled.unwrap_or(current.command_queue_enabled);
let save_upload_to_workspace = req.save_upload_to_workspace.unwrap_or(current.save_upload_to_workspace);
let row = self
.repo
.upsert_settings(
&language,
notification_enabled,
cron_notification_enabled,
command_queue_enabled,
save_upload_to_workspace,
)
.await
.map_err(|e| AppError::Internal(format!("Failed to update settings: {e}")))?;
Ok(SystemSettingsResponse {
language: row.language,
notification_enabled: row.notification_enabled,
cron_notification_enabled: row.cron_notification_enabled,
command_queue_enabled: row.command_queue_enabled,
save_upload_to_workspace: row.save_upload_to_workspace,
})
}
}
fn validate_language(lang: &str) -> Result<(), AppError> {
if SUPPORTED_LANGUAGES.contains(&lang) {
Ok(())
} else {
Err(AppError::BadRequest(format!("Unsupported language code: '{lang}'")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_db::{SqliteSettingsRepository, init_database_memory};
async fn setup() -> SettingsService {
let db = init_database_memory().await.unwrap();
let repo = Arc::new(SqliteSettingsRepository::new(db.pool().clone()));
// Leak the db handle so the pool stays alive for the test
std::mem::forget(db);
SettingsService::new(repo)
}
#[test]
fn validate_language_accepts_supported() {
assert!(validate_language("en-US").is_ok());
assert!(validate_language("zh-CN").is_ok());
}
#[test]
fn validate_language_rejects_unsupported() {
for lang in [
"invalid", "", "xx-YY", "zh-TW", "ja-JP", "ko-KR", "ru-RU", "tr-TR", "uk-UA", "fr-FR",
] {
assert!(validate_language(lang).is_err(), "{lang} should be rejected");
}
}
#[tokio::test]
async fn get_settings_returns_defaults_when_empty() {
let svc = setup().await;
let settings = svc.get_settings().await.unwrap();
assert_eq!(settings, SystemSettingsResponse::default());
}
#[tokio::test]
async fn update_single_field() {
let svc = setup().await;
let req = UpdateSettingsRequest {
language: Some("zh-CN".into()),
..Default::default()
};
let result = svc.update_settings(req).await.unwrap();
assert_eq!(result.language, "zh-CN");
// Other fields stay at defaults
assert!(result.notification_enabled);
assert!(!result.cron_notification_enabled);
}
#[tokio::test]
async fn update_multiple_fields() {
let svc = setup().await;
let req = UpdateSettingsRequest {
notification_enabled: Some(false),
command_queue_enabled: Some(true),
..Default::default()
};
let result = svc.update_settings(req).await.unwrap();
assert!(!result.notification_enabled);
assert!(result.command_queue_enabled);
assert_eq!(result.language, "en-US");
}
#[tokio::test]
async fn update_empty_request_returns_current() {
let svc = setup().await;
let result = svc.update_settings(UpdateSettingsRequest::default()).await.unwrap();
assert_eq!(result, SystemSettingsResponse::default());
}
#[tokio::test]
async fn update_invalid_language_rejected() {
let svc = setup().await;
let req = UpdateSettingsRequest {
language: Some("invalid-lang".into()),
..Default::default()
};
let err = svc.update_settings(req).await.unwrap_err();
assert_eq!(err.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn update_then_get_reflects_changes() {
let svc = setup().await;
svc.update_settings(UpdateSettingsRequest {
language: Some("zh-CN".into()),
save_upload_to_workspace: Some(true),
..Default::default()
})
.await
.unwrap();
let settings = svc.get_settings().await.unwrap();
assert_eq!(settings.language, "zh-CN");
assert!(settings.save_upload_to_workspace);
}
}
@@ -0,0 +1,136 @@
use nomifun_api_types::SystemInfoResponse;
/// Map Rust `std::env::consts::OS` to the Node.js-compatible platform name
/// used by the API contract.
fn map_platform() -> &'static str {
match std::env::consts::OS {
"macos" => "darwin",
"windows" => "win32",
other => other, // "linux" stays "linux"
}
}
/// Map Rust `std::env::consts::ARCH` to the API contract arch name.
fn map_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "x64",
"aarch64" => "arm64",
other => other,
}
}
/// Resolve the cache directory for Nomi.
///
/// Priority: `NOMIFUN_CACHE_DIR` env → `dirs::cache_dir()/nomifun`.
fn resolve_cache_dir() -> String {
if let Ok(v) = std::env::var("NOMIFUN_CACHE_DIR")
&& !v.is_empty()
{
return v;
}
dirs::cache_dir()
.map(|p| p.join("nomifun").to_string_lossy().into_owned())
.unwrap_or_default()
}
/// Resolve the work (data) directory for Nomi.
///
/// Priority: `NOMIFUN_WORK_DIR` env → `dirs::data_dir()/nomifun`.
fn resolve_work_dir() -> String {
if let Ok(v) = std::env::var("NOMIFUN_WORK_DIR")
&& !v.is_empty()
{
return v;
}
dirs::data_dir()
.map(|p| p.join("nomifun").to_string_lossy().into_owned())
.unwrap_or_default()
}
/// Resolve the log directory for Nomi.
///
/// Priority: `NOMIFUN_LOG_DIR` env →
/// macOS: `~/Library/Logs/nomifun`
/// Linux: `dirs::state_dir()/nomifun/logs` (XDG_STATE_HOME)
/// Windows: `dirs::data_dir()/nomifun/logs`
fn resolve_log_dir() -> String {
if let Ok(v) = std::env::var("NOMIFUN_LOG_DIR")
&& !v.is_empty()
{
return v;
}
// macOS: ~/Library/Logs is the conventional log location
if cfg!(target_os = "macos")
&& let Some(home) = dirs::home_dir()
{
return home.join("Library/Logs/nomifun").to_string_lossy().into_owned();
}
// Linux: XDG state dir
if let Some(state) = dirs::state_dir() {
return state.join("nomifun/logs").to_string_lossy().into_owned();
}
// Fallback: data_dir/nomifun/logs
dirs::data_dir()
.map(|p| p.join("nomifun/logs").to_string_lossy().into_owned())
.unwrap_or_default()
}
/// Build the system info response from the current runtime environment.
pub fn get_system_info() -> SystemInfoResponse {
SystemInfoResponse {
cache_dir: resolve_cache_dir(),
work_dir: resolve_work_dir(),
log_dir: resolve_log_dir(),
platform: map_platform().to_owned(),
arch: map_arch().to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_platform_known() {
let p = map_platform();
// On CI this will be one of the known values
assert!(["darwin", "win32", "linux"].contains(&p), "unexpected platform: {p}");
}
#[test]
fn test_map_arch_known() {
let a = map_arch();
assert!(["x64", "arm64"].contains(&a), "unexpected arch: {a}");
}
#[test]
fn test_get_system_info_fields_non_empty() {
let info = get_system_info();
assert!(!info.cache_dir.is_empty(), "cache_dir should not be empty");
assert!(!info.work_dir.is_empty(), "work_dir should not be empty");
assert!(!info.log_dir.is_empty(), "log_dir should not be empty");
assert!(!info.platform.is_empty());
assert!(!info.arch.is_empty());
}
#[test]
fn test_env_override_cache_dir() {
// This test verifies the resolve logic reads env vars.
// We cannot reliably set env in parallel tests, so just verify
// the default path contains "nomifun".
let dir = resolve_cache_dir();
assert!(dir.contains("nomifun"), "cache_dir should contain 'nomifun': {dir}");
}
#[test]
fn test_env_override_work_dir() {
let dir = resolve_work_dir();
assert!(dir.contains("nomifun"), "work_dir should contain 'nomifun': {dir}");
}
#[test]
fn test_env_override_log_dir() {
let dir = resolve_log_dir();
assert!(dir.contains("nomifun"), "log_dir should contain 'nomifun': {dir}");
}
}
@@ -0,0 +1,509 @@
use nomifun_api_types::{GitHubReleaseAsset, UpdateCheckRequest, UpdateCheckResult, UpdateReleaseInfo};
use nomifun_common::AppError;
use serde::Deserialize;
const DEFAULT_REPO: &str = "nomifun/nomifun-app";
const GITHUB_API_BASE: &str = "https://api.github.com";
/// Service that checks GitHub Releases for available updates.
#[derive(Clone)]
pub struct VersionCheckService {
http_client: reqwest::Client,
current_version: String,
/// Base URL for GitHub API. Defaults to `https://api.github.com`.
/// Configurable for testing with mock servers.
api_base: String,
}
impl VersionCheckService {
pub fn new(http_client: reqwest::Client, current_version: String) -> Self {
Self {
http_client,
current_version,
api_base: GITHUB_API_BASE.to_owned(),
}
}
/// Create a service with a custom API base URL (for testing).
#[doc(hidden)]
pub fn with_api_base(http_client: reqwest::Client, current_version: String, api_base: String) -> Self {
Self {
http_client,
current_version,
api_base,
}
}
/// Check for updates against GitHub Releases.
pub async fn check_update(&self, req: &UpdateCheckRequest) -> Result<UpdateCheckResult, AppError> {
let repo = resolve_repo(req.repo.as_deref());
let releases = self.fetch_releases(&repo).await?;
let current = parse_version(&self.current_version)
.ok_or_else(|| AppError::Internal(format!("invalid current version: {}", self.current_version)))?;
let platform = crate::sysinfo::get_system_info();
let best = find_best_release(
&releases,
&current,
req.include_prerelease,
&platform.platform,
&platform.arch,
);
match best {
Some(info) => Ok(UpdateCheckResult {
current_version: self.current_version.clone(),
update_available: true,
latest: Some(info),
}),
None => Ok(UpdateCheckResult {
current_version: self.current_version.clone(),
update_available: false,
latest: None,
}),
}
}
/// Fetch releases from GitHub API with pagination.
///
/// Requests up to 100 releases per page (GitHub max). For most repositories
/// a single page is sufficient, but we follow `Link: <..>; rel="next"` headers
/// to collect additional pages (up to 5 pages / 500 releases).
async fn fetch_releases(&self, repo: &str) -> Result<Vec<GitHubRelease>, AppError> {
const PER_PAGE: u32 = 100;
const MAX_PAGES: u32 = 5;
let mut all_releases = Vec::new();
let mut page = 1u32;
loop {
let url = format!(
"{}/repos/{repo}/releases?per_page={PER_PAGE}&page={page}",
self.api_base
);
let resp = self
.http_client
.get(&url)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "nomicore")
.send()
.await
.map_err(|e| AppError::BadGateway(format!("GitHub API request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(AppError::BadGateway(format!("GitHub API returned {status}: {body}")));
}
let has_next = resp
.headers()
.get("link")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("rel=\"next\""));
let batch: Vec<GitHubRelease> = resp
.json()
.await
.map_err(|e| AppError::BadGateway(format!("Failed to parse GitHub releases: {e}")))?;
let batch_len = batch.len();
all_releases.extend(batch);
page += 1;
if !has_next || batch_len < PER_PAGE as usize || page > MAX_PAGES {
break;
}
}
Ok(all_releases)
}
}
/// Resolve the GitHub repo from request or env or default.
fn resolve_repo(from_request: Option<&str>) -> String {
if let Some(r) = from_request
&& !r.is_empty()
{
return r.to_owned();
}
if let Ok(v) = std::env::var("NOMIFUN_GITHUB_REPO")
&& !v.is_empty()
{
return v;
}
DEFAULT_REPO.to_owned()
}
/// Parse a version string, stripping a leading `v` if present.
fn parse_version(s: &str) -> Option<semver::Version> {
let stripped = s.strip_prefix('v').unwrap_or(s);
semver::Version::parse(stripped).ok()
}
/// Find the best available release that is newer than `current`.
fn find_best_release(
releases: &[GitHubRelease],
current: &semver::Version,
include_prerelease: bool,
platform: &str,
arch: &str,
) -> Option<UpdateReleaseInfo> {
let mut best: Option<(semver::Version, &GitHubRelease)> = None;
for release in releases {
// Skip drafts always
if release.draft {
continue;
}
// Skip prereleases unless requested
if release.prerelease && !include_prerelease {
continue;
}
let version = match parse_version(&release.tag_name) {
Some(v) => v,
None => continue,
};
// Must be newer than current
if version <= *current {
continue;
}
// Keep the highest version
let dominated = best.as_ref().is_none_or(|(v, _)| version > *v);
if dominated {
best = Some((version, release));
}
}
best.map(|(version, release)| {
let assets: Vec<GitHubReleaseAsset> = release
.assets
.iter()
.map(|a| GitHubReleaseAsset {
name: a.name.clone(),
url: a.browser_download_url.clone(),
size: a.size,
content_type: a.content_type.clone(),
})
.collect();
let recommended_asset = find_recommended_asset(&assets, platform, arch);
UpdateReleaseInfo {
tag_name: release.tag_name.clone(),
version: version.to_string(),
name: release.name.clone(),
body: release.body.clone(),
html_url: release.html_url.clone(),
published_at: release.published_at.clone(),
prerelease: release.prerelease,
draft: release.draft,
assets,
recommended_asset,
}
})
}
/// Match the best asset for the given platform and architecture.
///
/// Uses filename heuristics: the asset name should contain a platform
/// keyword and an architecture keyword.
fn find_recommended_asset(assets: &[GitHubReleaseAsset], platform: &str, arch: &str) -> Option<GitHubReleaseAsset> {
let platform_keywords = platform_keywords(platform);
let arch_keywords = arch_keywords(arch);
assets
.iter()
.find(|a| {
let name = a.name.to_lowercase();
let has_platform = platform_keywords.iter().any(|k| name.contains(k));
let has_arch = arch_keywords.iter().any(|k| name.contains(k));
has_platform && has_arch
})
.cloned()
}
/// Return filename keywords that identify the given platform.
fn platform_keywords(platform: &str) -> Vec<&'static str> {
match platform {
"darwin" => vec!["darwin", "macos", "mac", "osx"],
"win32" => vec!["win", "windows"],
"linux" => vec!["linux"],
_ => vec![],
}
}
/// Return filename keywords that identify the given architecture.
fn arch_keywords(arch: &str) -> Vec<&'static str> {
match arch {
"x64" => vec!["x64", "x86_64", "amd64"],
"arm64" => vec!["arm64", "aarch64"],
_ => vec![],
}
}
// ---------------------------------------------------------------------------
// GitHub API response types (internal, not exposed)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct GitHubRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
html_url: String,
published_at: Option<String>,
prerelease: bool,
draft: bool,
assets: Vec<GitHubAsset>,
}
#[derive(Debug, Deserialize)]
struct GitHubAsset {
name: String,
browser_download_url: String,
size: u64,
content_type: Option<String>,
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_repo_from_request() {
assert_eq!(resolve_repo(Some("org/repo")), "org/repo");
}
#[test]
fn test_resolve_repo_empty_request() {
let result = resolve_repo(Some(""));
// Falls back to env or default
assert!(!result.is_empty());
}
#[test]
fn test_resolve_repo_none() {
let result = resolve_repo(None);
assert!(!result.is_empty());
}
#[test]
fn test_parse_version_plain() {
let v = parse_version("1.2.3").unwrap();
assert_eq!(v, semver::Version::new(1, 2, 3));
}
#[test]
fn test_parse_version_with_v_prefix() {
let v = parse_version("v2.0.0").unwrap();
assert_eq!(v, semver::Version::new(2, 0, 0));
}
#[test]
fn test_parse_version_prerelease() {
let v = parse_version("v3.0.0-beta.1").unwrap();
assert_eq!(v.major, 3);
assert!(!v.pre.is_empty());
}
#[test]
fn test_parse_version_invalid() {
assert!(parse_version("not-a-version").is_none());
}
#[test]
fn test_platform_keywords_darwin() {
let kw = platform_keywords("darwin");
assert!(kw.contains(&"darwin"));
assert!(kw.contains(&"macos"));
}
#[test]
fn test_platform_keywords_win32() {
let kw = platform_keywords("win32");
assert!(kw.contains(&"win"));
assert!(kw.contains(&"windows"));
}
#[test]
fn test_arch_keywords_x64() {
let kw = arch_keywords("x64");
assert!(kw.contains(&"x64"));
assert!(kw.contains(&"x86_64"));
assert!(kw.contains(&"amd64"));
}
#[test]
fn test_arch_keywords_arm64() {
let kw = arch_keywords("arm64");
assert!(kw.contains(&"arm64"));
assert!(kw.contains(&"aarch64"));
}
fn make_release(tag: &str, draft: bool, prerelease: bool, assets: Vec<GitHubAsset>) -> GitHubRelease {
GitHubRelease {
tag_name: tag.to_owned(),
name: Some(format!("Release {tag}")),
body: None,
html_url: format!("https://github.com/org/repo/releases/tag/{tag}"),
published_at: Some("2026-01-01T00:00:00Z".to_owned()),
prerelease,
draft,
assets,
}
}
fn make_asset(name: &str) -> GitHubAsset {
GitHubAsset {
name: name.to_owned(),
browser_download_url: format!("https://github.com/download/{name}"),
size: 100_000,
content_type: Some("application/octet-stream".to_owned()),
}
}
#[test]
fn test_find_best_release_newer_version() {
let current = semver::Version::new(1, 0, 0);
let releases = vec![
make_release("v1.1.0", false, false, vec![]),
make_release("v2.0.0", false, false, vec![]),
make_release("v0.9.0", false, false, vec![]),
];
let best = find_best_release(&releases, &current, false, "darwin", "arm64");
assert!(best.is_some());
assert_eq!(best.unwrap().version, "2.0.0");
}
#[test]
fn test_find_best_release_no_update() {
let current = semver::Version::new(3, 0, 0);
let releases = vec![
make_release("v1.0.0", false, false, vec![]),
make_release("v2.0.0", false, false, vec![]),
];
let best = find_best_release(&releases, &current, false, "darwin", "arm64");
assert!(best.is_none());
}
#[test]
fn test_find_best_release_skips_draft() {
let current = semver::Version::new(1, 0, 0);
let releases = vec![
make_release("v5.0.0", true, false, vec![]), // draft — skip
make_release("v2.0.0", false, false, vec![]),
];
let best = find_best_release(&releases, &current, false, "darwin", "arm64");
assert_eq!(best.unwrap().version, "2.0.0");
}
#[test]
fn test_find_best_release_skips_prerelease_unless_included() {
let current = semver::Version::new(1, 0, 0);
let releases = vec![
make_release("v3.0.0-beta.1", false, true, vec![]),
make_release("v2.0.0", false, false, vec![]),
];
// Without prerelease
let best = find_best_release(&releases, &current, false, "darwin", "arm64");
assert_eq!(best.unwrap().version, "2.0.0");
// With prerelease
let best = find_best_release(&releases, &current, true, "darwin", "arm64");
assert_eq!(best.unwrap().version, "3.0.0-beta.1");
}
#[test]
fn test_find_best_release_invalid_tag_skipped() {
let current = semver::Version::new(1, 0, 0);
let releases = vec![
make_release("not-semver", false, false, vec![]),
make_release("v2.0.0", false, false, vec![]),
];
let best = find_best_release(&releases, &current, false, "darwin", "arm64");
assert_eq!(best.unwrap().version, "2.0.0");
}
#[test]
fn test_find_recommended_asset_darwin_arm64() {
let assets = vec![
GitHubReleaseAsset {
name: "app-2.0.0-win-x64.exe".into(),
url: "https://example.com/win.exe".into(),
size: 100,
content_type: None,
},
GitHubReleaseAsset {
name: "app-2.0.0-darwin-arm64.dmg".into(),
url: "https://example.com/mac.dmg".into(),
size: 200,
content_type: None,
},
GitHubReleaseAsset {
name: "app-2.0.0-linux-x64.deb".into(),
url: "https://example.com/linux.deb".into(),
size: 150,
content_type: None,
},
];
let rec = find_recommended_asset(&assets, "darwin", "arm64");
assert!(rec.is_some());
assert!(rec.unwrap().name.contains("darwin-arm64"));
}
#[test]
fn test_find_recommended_asset_linux_x64() {
let assets = vec![GitHubReleaseAsset {
name: "app-linux-amd64.tar.gz".into(),
url: "https://example.com/linux.tar.gz".into(),
size: 150,
content_type: None,
}];
let rec = find_recommended_asset(&assets, "linux", "x64");
assert!(rec.is_some());
}
#[test]
fn test_find_recommended_asset_no_match() {
let assets = vec![GitHubReleaseAsset {
name: "app-win-x64.exe".into(),
url: "https://example.com/win.exe".into(),
size: 100,
content_type: None,
}];
let rec = find_recommended_asset(&assets, "darwin", "arm64");
assert!(rec.is_none());
}
#[test]
fn test_find_best_release_with_asset_matching() {
let current = semver::Version::new(1, 0, 0);
let releases = vec![make_release(
"v2.0.0",
false,
false,
vec![
make_asset("app-2.0.0-win-x64.exe"),
make_asset("app-2.0.0-darwin-arm64.dmg"),
],
)];
let best = find_best_release(&releases, &current, false, "darwin", "arm64").unwrap();
assert!(best.recommended_asset.is_some());
assert!(best.recommended_asset.unwrap().name.contains("darwin-arm64"));
}
#[test]
fn test_find_best_release_equal_version_not_update() {
let current = semver::Version::new(2, 0, 0);
let releases = vec![make_release("v2.0.0", false, false, vec![])];
let best = find_best_release(&releases, &current, false, "darwin", "arm64");
assert!(best.is_none(), "equal version should not be an update");
}
}
@@ -0,0 +1,102 @@
//! Integration tests for ConnectionTestService.
//!
//! Tests validate input checking, service construction, and error paths.
//! Real AWS calls are tested only with fake credentials to verify
//! proper error handling (no real accounts needed).
use nomifun_api_types::{BedrockAuthMethod, BedrockConfig};
use nomifun_system::ConnectionTestService;
fn make_service() -> ConnectionTestService {
ConnectionTestService::new(reqwest::Client::new())
}
// ── Bedrock validation ──────────────────────────────────────────────
#[tokio::test]
async fn bedrock_rejects_empty_region() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "".into(),
access_key_id: Some("AKIA".into()),
secret_access_key: Some("secret".into()),
profile: None,
};
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(err.to_string().contains("region"));
}
#[tokio::test]
async fn bedrock_rejects_missing_access_key_id() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "us-east-1".into(),
access_key_id: None,
secret_access_key: Some("secret".into()),
profile: None,
};
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(err.to_string().contains("accessKeyId"));
}
#[tokio::test]
async fn bedrock_rejects_missing_secret_access_key() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "us-east-1".into(),
access_key_id: Some("AKIA".into()),
secret_access_key: None,
profile: None,
};
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(err.to_string().contains("secretAccessKey"));
}
#[tokio::test]
async fn bedrock_rejects_empty_profile() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::Profile,
region: "us-east-1".into(),
access_key_id: None,
secret_access_key: None,
profile: Some("".into()),
};
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(err.to_string().contains("profile"));
}
#[tokio::test]
async fn bedrock_rejects_none_profile() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::Profile,
region: "us-east-1".into(),
access_key_id: None,
secret_access_key: None,
profile: None,
};
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(err.to_string().contains("profile"));
}
#[tokio::test]
async fn bedrock_fake_credentials_error() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "us-east-1".into(),
access_key_id: Some("AKIAFAKEKEY1234567890".into()),
secret_access_key: Some("fakesecretkey1234567890abcdefgh".into()),
profile: None,
};
// Should fail with credential error, not panic
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(
err.to_string().contains("Bedrock credentials invalid"),
"Expected credential error, got: {err}"
);
}
@@ -0,0 +1,492 @@
//! Black-box integration tests for model fetch endpoint.
//!
//! Uses `wiremock` to mock remote API responses and tests the full
//! HTTP flow: request -> handler -> remote API call -> response.
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::json;
use tower::ServiceExt;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use nomifun_common::encrypt_string;
use nomifun_db::{
CreateProviderParams, IProviderRepository, SqliteBrandingConfigRepository,
SqliteClientPreferenceRepository, SqliteProviderRepository, SqliteSettingsRepository,
SqliteSystemConfigRepository, init_database_memory,
};
use nomifun_system::{
ClientPrefService, ModelFetchService, ProtocolDetectionService, ProviderService, SettingsService,
SystemRouterState, VersionCheckService, system_routes,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEST_KEY: [u8; 32] = [0x42; 32];
fn build_state(db: &nomifun_db::Database) -> SystemRouterState {
let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let http_client = reqwest::Client::new();
let system_config_repo = Arc::new(SqliteSystemConfigRepository::new(db.pool().clone()));
let branding_config_repo = Arc::new(SqliteBrandingConfigRepository::new(db.pool().clone()));
SystemRouterState {
settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))),
client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))),
provider_service: ProviderService::new(provider_repo.clone(), TEST_KEY),
model_fetch_service: ModelFetchService::new(provider_repo, TEST_KEY, http_client.clone()),
protocol_detection_service: ProtocolDetectionService::new(http_client.clone()),
version_check_service: VersionCheckService::new(http_client, "0.1.0".to_owned()),
data_dir: std::env::temp_dir(),
system_config_repo,
branding_config_repo,
}
}
async fn setup() -> (axum::Router, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let state = build_state(&db);
(system_routes(state), db)
}
async fn create_provider(db: &nomifun_db::Database, platform: &str, base_url: &str, api_key: &str) -> String {
let repo = SqliteProviderRepository::new(db.pool().clone());
let encrypted = encrypt_string(api_key, &TEST_KEY).unwrap();
let row = repo
.create(CreateProviderParams {
id: None,
platform,
name: "Test Provider",
base_url,
api_key_encrypted: &encrypted,
models: "[]",
enabled: true,
capabilities: "[]",
context_limit: None,
model_protocols: None,
model_enabled: None,
model_health: None,
bedrock_config: None,
is_full_url: false,
})
.await
.unwrap();
row.id
}
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
fn post_request(uri: &str, body: serde_json::Value) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
// ---------------------------------------------------------------------------
// Tests: basic flow
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_nonexistent_provider() {
let (router, _db) = setup().await;
let req = post_request("/api/providers/nonexistent/models", json!({"try_fix": false}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn fetch_models_vertex_ai_hardcoded() {
let (router, db) = setup().await;
let id = create_provider(&db, "vertex-ai", "https://unused", "fake-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": false}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0], "gemini-2.5-pro");
assert_eq!(models[1], "gemini-2.5-flash");
assert!(json["data"].get("fixed_base_url").is_none());
}
#[tokio::test]
async fn fetch_models_minimax_hardcoded() {
let (router, db) = setup().await;
let id = create_provider(&db, "minimax", "https://unused", "fake-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["models"].as_array().unwrap().len(), 3);
}
// ---------------------------------------------------------------------------
// Tests: OpenAI-compatible with mock
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_openai_compatible_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer test-api-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "gpt-4o", "object": "model"},
{"id": "gpt-4o-mini", "object": "model"}
]
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "openai", &mock_server.uri(), "test-api-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": false}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0], "gpt-4o");
assert_eq!(models[1], "gpt-4o-mini");
}
#[tokio::test]
async fn fetch_models_openai_remote_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(500))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "openai", &mock_server.uri(), "test-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": false}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
// ---------------------------------------------------------------------------
// Tests: Anthropic with mock
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_anthropic_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "sk-ant-test"))
.and(header("anthropic-version", "2023-06-01"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "claude-sonnet-4-20250514", "type": "model"},
{"id": "claude-opus-4-20250514", "type": "model"}
],
"has_more": false
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "anthropic", &mock_server.uri(), "sk-ant-test").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0], "claude-sonnet-4-20250514");
}
#[tokio::test]
async fn fetch_models_anthropic_fallback_on_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "anthropic", &mock_server.uri(), "bad-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
// Should return fallback models
assert!(!models.is_empty());
}
// ---------------------------------------------------------------------------
// Tests: Gemini with mock
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_gemini_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1beta/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"models": [
{"name": "models/gemini-2.5-pro", "displayName": "Gemini 2.5 Pro"},
{"name": "models/gemini-2.5-flash", "displayName": "Gemini 2.5 Flash"}
]
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "gemini", &mock_server.uri(), "gemini-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 2);
// models/ prefix should be stripped
assert_eq!(models[0], "gemini-2.5-pro");
assert_eq!(models[1], "gemini-2.5-flash");
}
#[tokio::test]
async fn fetch_models_gemini_fallback_on_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1beta/models"))
.respond_with(ResponseTemplate::new(403))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "gemini", &mock_server.uri(), "bad-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert!(!models.is_empty());
}
// ---------------------------------------------------------------------------
// Tests: new-api (OpenAI with /v1 enforcement)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_new_api_adds_v1() {
let mock_server = MockServer::start().await;
// new-api should ensure /v1 is in the path
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "model-a"}]
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
// base_url without /v1
let id = create_provider(&db, "new-api", &mock_server.uri(), "test-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 1);
assert_eq!(models[0], "model-a");
}
// ---------------------------------------------------------------------------
// Tests: URL auto-fix
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_url_auto_fix_success() {
let mock_server = MockServer::start().await;
// Original /models should fail
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
// /v1/models should succeed
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "fixed-model"}]
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "openai", &mock_server.uri(), "test-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": true}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 1);
assert_eq!(models[0], "fixed-model");
// fixedBaseUrl should be present
assert!(json["data"]["fixed_base_url"].as_str().unwrap().contains("/v1"));
}
#[tokio::test]
async fn fetch_models_url_auto_fix_not_triggered_when_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "original-model"}]
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "openai", &mock_server.uri(), "test-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": true}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models[0], "original-model");
// fixedBaseUrl should NOT be present since original URL worked
assert!(json["data"].get("fixed_base_url").is_none());
}
#[tokio::test]
async fn fetch_models_url_auto_fix_not_for_anthropic() {
let mock_server = MockServer::start().await;
// Anthropic API fails
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "anthropic", &mock_server.uri(), "bad-key").await;
// Even with tryFix=true, Anthropic should use fallback, not URL fix
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": true}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
// Should be fallback models, no fixedBaseUrl
assert!(json["data"].get("fixed_base_url").is_none());
}
// ---------------------------------------------------------------------------
// Tests: anonymous fetch-models (T1b)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_anonymous_returns_models_for_valid_input() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer sk-anon"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "gpt-4o"}, {"id": "gpt-4o-mini"}]
})))
.mount(&mock_server)
.await;
let (router, _db) = setup().await;
let req = post_request(
"/api/providers/fetch-models",
json!({
"platform": "openai",
"base_url": mock_server.uri(),
"api_key": "sk-anon"
}),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0], "gpt-4o");
}
#[tokio::test]
async fn fetch_models_anonymous_rejects_empty_api_key() {
let (router, _db) = setup().await;
let req = post_request(
"/api/providers/fetch-models",
json!({
"platform": "openai",
"base_url": "https://api.openai.com",
"api_key": " "
}),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn fetch_models_anonymous_minimax_hardcoded() {
// Hardcoded-list platforms work without hitting any remote endpoint.
let (router, _db) = setup().await;
let req = post_request(
"/api/providers/fetch-models",
json!({
"platform": "minimax",
"base_url": "https://unused",
"api_key": "fake"
}),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["models"].as_array().unwrap().len(), 3);
}
#[tokio::test]
async fn fetch_models_route_literal_segment_beats_id_shadowing() {
// Regression guard for axum route ordering: POST /api/providers/fetch-models
// must NOT be matched as /api/providers/{id}/models with id="fetch-models".
// If shadowing occurred we'd either hit the by-id handler (→ 404 provider
// not found) or get a routing error. Hitting the anonymous handler returns
// 400 for missing required fields, which is the right signature.
let (router, _db) = setup().await;
let req = post_request("/api/providers/fetch-models", json!({}));
let resp = router.oneshot(req).await.unwrap();
// Missing "platform" / "base_url" / "api_key" — anonymous handler
// rejects with 400 via JSON deserialization failure, not 404 from the
// by-id handler.
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
@@ -0,0 +1,603 @@
//! Black-box integration tests for protocol detection endpoint.
//!
//! Uses `wiremock` to mock remote API responses and tests the full
//! HTTP flow: request -> handler -> remote API probe -> response.
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::json;
use tower::ServiceExt;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use nomifun_db::{
SqliteBrandingConfigRepository, SqliteClientPreferenceRepository, SqliteProviderRepository,
SqliteSettingsRepository, SqliteSystemConfigRepository, init_database_memory,
};
use nomifun_system::{
ClientPrefService, ModelFetchService, ProtocolDetectionService, ProviderService, SettingsService,
SystemRouterState, VersionCheckService, system_routes,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEST_KEY: [u8; 32] = [0x42; 32];
fn build_state(db: &nomifun_db::Database) -> SystemRouterState {
let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let http_client = reqwest::Client::new();
let system_config_repo = Arc::new(SqliteSystemConfigRepository::new(db.pool().clone()));
let branding_config_repo = Arc::new(SqliteBrandingConfigRepository::new(db.pool().clone()));
SystemRouterState {
settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))),
client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))),
provider_service: ProviderService::new(provider_repo.clone(), TEST_KEY),
model_fetch_service: ModelFetchService::new(provider_repo, TEST_KEY, http_client.clone()),
protocol_detection_service: ProtocolDetectionService::new(http_client.clone()),
version_check_service: VersionCheckService::new(http_client, "0.1.0".to_owned()),
data_dir: std::env::temp_dir(),
system_config_repo,
branding_config_repo,
}
}
async fn setup() -> axum::Router {
let db = init_database_memory().await.unwrap();
let state = build_state(&db);
system_routes(state)
}
async fn detect(router: &axum::Router, body: serde_json::Value) -> (StatusCode, serde_json::Value) {
let req = Request::builder()
.method("POST")
.uri("/api/providers/detect-protocol")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
(status, json)
}
// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_protocol_missing_base_url() {
let router = setup().await;
let (status, json) = detect(&router, json!({"api_key": "sk-xxx"})).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!json["success"].as_bool().unwrap());
}
#[tokio::test]
async fn detect_protocol_missing_api_key() {
let router = setup().await;
let (status, json) = detect(&router, json!({"base_url": "https://example.com"})).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!json["success"].as_bool().unwrap());
}
#[tokio::test]
async fn detect_protocol_empty_base_url() {
let router = setup().await;
let (status, _) = detect(&router, json!({"base_url": " ", "api_key": "sk-test"})).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn detect_protocol_empty_api_key() {
let router = setup().await;
let (status, _) = detect(&router, json!({"base_url": "https://example.com", "api_key": " "})).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
// ---------------------------------------------------------------------------
// OpenAI detection with mock server
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_openai_protocol_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer sk-test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "gpt-4"},
{"id": "gpt-3.5-turbo"}
]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-test-key"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(json["success"].as_bool().unwrap());
let data = &json["data"];
assert_eq!(data["protocol"], "openai");
assert!(data["confidence"].as_u64().unwrap() > 0);
let models = data["models"].as_array().unwrap();
assert!(models.contains(&json!("gpt-4")));
assert!(models.contains(&json!("gpt-3.5-turbo")));
assert_eq!(data["suggestion"]["type"], "none");
}
// ---------------------------------------------------------------------------
// Anthropic detection with mock server
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_anthropic_protocol_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "sk-ant-test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "claude-sonnet-4-20250514"},
{"id": "claude-opus-4-20250514"}
]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-ant-test-key",
"preferred_protocol": "anthropic"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "anthropic");
assert!(data["confidence"].as_u64().unwrap() >= 90);
let models = data["models"].as_array().unwrap();
assert!(models.contains(&json!("claude-sonnet-4-20250514")));
}
// ---------------------------------------------------------------------------
// Gemini detection with mock server
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_gemini_protocol_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1beta/models"))
.and(query_param("key", "AIzaSyBtest"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"models": [
{"name": "models/gemini-2.5-pro"},
{"name": "models/gemini-2.5-flash"}
]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "AIzaSyBtest",
"preferred_protocol": "gemini"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "gemini");
assert!(data["confidence"].as_u64().unwrap() >= 80);
let models = data["models"].as_array().unwrap();
// Prefix stripped
assert!(models.contains(&json!("gemini-2.5-pro")));
assert!(models.contains(&json!("gemini-2.5-flash")));
}
// ---------------------------------------------------------------------------
// All protocols fail → unknown
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_protocol_all_fail_returns_unknown() {
let mock_server = MockServer::start().await;
// All endpoints return 404
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-unknown-key"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "unknown");
assert_eq!(data["confidence"], 0);
assert_eq!(data["suggestion"]["type"], "check_key");
}
// ---------------------------------------------------------------------------
// Auth failure detection (401)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_protocol_auth_failure_returns_check_key() {
let mock_server = MockServer::start().await;
// OpenAI endpoint returns 401
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {"message": "Invalid API key"}
})))
.mount(&mock_server)
.await;
// /v1/models also returns 401
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "invalid-key"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
// Should detect a protocol (OpenAI likely) with check_key suggestion
assert!(data["confidence"].as_u64().unwrap() > 0);
assert_eq!(data["suggestion"]["type"], "check_key");
}
// ---------------------------------------------------------------------------
// URL fix variant detection
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_openai_via_v1_variant() {
let mock_server = MockServer::start().await;
// /models returns 404
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
// /v1/models returns success
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "gpt-4"}]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-test"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "openai");
// fixed_base_url should be set when using /v1 variant
assert!(data["fixed_base_url"].is_string());
assert!(data["fixed_base_url"].as_str().unwrap().ends_with("/v1"));
}
// ---------------------------------------------------------------------------
// Multi-key testing
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_with_multi_key_test() {
let mock_server = MockServer::start().await;
// /models returns success for any key
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "gpt-4"}]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "key1,key2,key3",
"test_all_keys": true
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "openai");
let mkr = &data["multi_key_result"];
assert_eq!(mkr["total"], 3);
assert_eq!(mkr["details"].as_array().unwrap().len(), 3);
// All keys should be valid (mock returns 200 for any key)
assert_eq!(mkr["valid"], 3);
assert_eq!(mkr["invalid"], 0);
}
// ---------------------------------------------------------------------------
// Multi-key partial validity
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_multi_key_partial_validity() {
let mock_server = MockServer::start().await;
// /models returns success only for "good-key"
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer good-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "gpt-4"}]
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer bad-key"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "good-key,bad-key",
"test_all_keys": true
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
let mkr = &data["multi_key_result"];
assert_eq!(mkr["total"], 2);
assert_eq!(mkr["valid"], 1);
assert_eq!(mkr["invalid"], 1);
// Verify details are sorted by index
let details = mkr["details"].as_array().unwrap();
assert_eq!(details[0]["index"], 0);
assert!(details[0]["valid"].as_bool().unwrap());
assert_eq!(details[1]["index"], 1);
assert!(!details[1]["valid"].as_bool().unwrap());
}
// ---------------------------------------------------------------------------
// Preferred protocol takes priority
// ---------------------------------------------------------------------------
#[tokio::test]
async fn preferred_protocol_tested_first() {
let mock_server = MockServer::start().await;
// Only Anthropic endpoint works
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "claude-3"}]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "test-key",
"preferred_protocol": "anthropic"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "anthropic");
}
// ---------------------------------------------------------------------------
// Single key → no multiKeyResult
// ---------------------------------------------------------------------------
#[tokio::test]
async fn single_key_no_multi_key_result() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "gpt-4"}]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-single-key",
"test_all_keys": true
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
// Single key → multi_key_result should be null
assert!(data["multi_key_result"].is_null());
}
// ---------------------------------------------------------------------------
// Timeout configuration
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_protocol_with_custom_timeout() {
let mock_server = MockServer::start().await;
// Return success after a small delay (well within timeout)
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"data": [{"id": "gpt-4"}]}))
.set_delay(std::time::Duration::from_millis(50)),
)
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-test",
"timeout": 5000
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["data"]["protocol"], "openai");
}
// ---------------------------------------------------------------------------
// Multi-protocol detection (detectedProtocols)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_multiple_protocols_reports_all_successes() {
let mock_server = MockServer::start().await;
// OpenAI probe: GET /models with Authorization header
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer multi-test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "gpt-4"},
{"id": "gpt-3.5-turbo"}
]
})))
.mount(&mock_server)
.await;
// Anthropic probe: GET /v1/models with x-api-key header
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "multi-test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "claude-sonnet-4-20250514"},
{"id": "claude-opus-4-20250514"}
]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "multi-test-key"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(json["success"].as_bool().unwrap());
let data = &json["data"];
// Primary result is still the first successful probe in priority order (OpenAI)
assert_eq!(data["protocol"], "openai");
assert_eq!(data["success"], true);
// detectedProtocols contains both OpenAI and Anthropic
let detected = data["detectedProtocols"].as_array().unwrap();
assert!(
detected.len() >= 2,
"Expected at least 2 detected protocols, got {}",
detected.len()
);
let protocols: Vec<&str> = detected.iter().map(|d| d["protocol"].as_str().unwrap()).collect();
assert!(protocols.contains(&"openai"), "Expected openai in detectedProtocols");
assert!(
protocols.contains(&"anthropic"),
"Expected anthropic in detectedProtocols"
);
// Each entry should have confidence > 0
for entry in detected {
assert!(entry["confidence"].as_u64().unwrap() > 0);
}
}
@@ -0,0 +1,473 @@
//! Black-box integration tests for provider CRUD routes.
//!
//! Tests exercise the HTTP layer (request -> handler -> response) via
//! `tower::ServiceExt::oneshot`, without authentication middleware.
//! Auth protection is verified at the app-level E2E tests (task 3.9).
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::json;
use tower::ServiceExt;
use nomifun_db::{
SqliteBrandingConfigRepository, SqliteClientPreferenceRepository, SqliteProviderRepository,
SqliteSettingsRepository, SqliteSystemConfigRepository, init_database_memory,
};
use nomifun_system::{
ClientPrefService, ModelFetchService, ProtocolDetectionService, ProviderService, SettingsService,
SystemRouterState, VersionCheckService, system_routes,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEST_ENCRYPTION_KEY: [u8; 32] = [0x42; 32];
fn build_state(db: &nomifun_db::Database) -> SystemRouterState {
let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let http_client = reqwest::Client::new();
let system_config_repo = Arc::new(SqliteSystemConfigRepository::new(db.pool().clone()));
let branding_config_repo = Arc::new(SqliteBrandingConfigRepository::new(db.pool().clone()));
SystemRouterState {
settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))),
client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))),
provider_service: ProviderService::new(provider_repo.clone(), TEST_ENCRYPTION_KEY),
model_fetch_service: ModelFetchService::new(provider_repo, TEST_ENCRYPTION_KEY, http_client.clone()),
protocol_detection_service: ProtocolDetectionService::new(http_client.clone()),
version_check_service: VersionCheckService::new(http_client, "0.1.0".to_owned()),
data_dir: std::env::temp_dir(),
system_config_repo,
branding_config_repo,
}
}
async fn setup() -> (axum::Router, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let state = build_state(&db);
(system_routes(state), db)
}
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
fn get_request(uri: &str) -> Request<Body> {
Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()
}
fn json_request(method: &str, uri: &str, body: serde_json::Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
fn delete_request(uri: &str) -> Request<Body> {
Request::builder()
.method("DELETE")
.uri(uri)
.body(Body::empty())
.unwrap()
}
fn sample_create_body() -> serde_json::Value {
json!({
"platform": "anthropic",
"name": "Anthropic",
"base_url": "https://api.anthropic.com",
"api_key": "sk-ant-api03-test1234"
})
}
/// Create a provider and return (response_json, provider_id, fresh_router).
async fn create_one(db: &nomifun_db::Database) -> (serde_json::Value, String) {
let app = system_routes(build_state(db));
let resp = app
.oneshot(json_request("POST", "/api/providers", sample_create_body()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = body_json(resp).await;
let id = json["data"]["id"].as_str().unwrap().to_string();
(json, id)
}
// ===========================================================================
// GET /api/providers — list
// ===========================================================================
#[tokio::test]
async fn list_providers_empty() {
let (app, _db) = setup().await;
let resp = app.oneshot(get_request("/api/providers")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["data"], json!([]));
}
#[tokio::test]
async fn list_providers_returns_plaintext_api_key() {
let (_app, db) = setup().await;
create_one(&db).await;
let app2 = system_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/providers")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let providers = json["data"].as_array().unwrap();
assert_eq!(providers.len(), 1);
let api_key = providers[0]["api_key"].as_str().unwrap();
// Pre-launch: api_key is returned plaintext on the wire (encrypted at rest).
assert_eq!(api_key, "sk-ant-api03-test1234");
assert!(!api_key.contains("***"));
}
// ===========================================================================
// POST /api/providers — create
// ===========================================================================
#[tokio::test]
async fn create_provider_success() {
let (app, _db) = setup().await;
let resp = app
.oneshot(json_request("POST", "/api/providers", sample_create_body()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
let data = &json["data"];
assert!(data["id"].as_str().unwrap().starts_with("prov_"));
assert_eq!(data["platform"], "anthropic");
assert_eq!(data["name"], "Anthropic");
assert_eq!(data["base_url"], "https://api.anthropic.com");
assert_eq!(data["api_key"], "sk-ant-api03-test1234");
assert!(data["enabled"].as_bool().unwrap());
assert!(data["models"].as_array().unwrap().is_empty());
assert!(data["created_at"].as_i64().unwrap() > 0);
assert!(data["updated_at"].as_i64().unwrap() > 0);
}
#[tokio::test]
async fn create_provider_with_supplied_id() {
let (app, _db) = setup().await;
let body = json!({
"id": "caller-id-123",
"platform": "openai",
"name": "OpenAI",
"base_url": "https://api.openai.com",
"api_key": "sk-test",
"model_enabled": {"gpt-4": true, "gpt-3.5": false}
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = body_json(resp).await;
let data = &json["data"];
assert_eq!(data["id"], "caller-id-123");
assert_eq!(data["api_key"], "sk-test");
assert_eq!(data["model_enabled"]["gpt-4"], true);
assert_eq!(data["model_enabled"]["gpt-3.5"], false);
}
#[tokio::test]
async fn create_provider_with_duplicate_id_returns_conflict() {
let (_app, db) = setup().await;
let body = json!({
"id": "dup-id",
"platform": "openai",
"name": "OpenAI",
"base_url": "https://api.openai.com",
"api_key": "sk-test"
});
let app1 = system_routes(build_state(&db));
let resp = app1
.oneshot(json_request("POST", "/api/providers", body.clone()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let app2 = system_routes(build_state(&db));
let resp = app2
.oneshot(json_request("POST", "/api/providers", body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn create_provider_with_invalid_id_rejected() {
let (app, _db) = setup().await;
let body = json!({
"id": "bad/slash",
"platform": "openai",
"name": "OpenAI",
"base_url": "https://api.openai.com",
"api_key": "sk-test"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_provider_with_optional_fields() {
let (app, _db) = setup().await;
let body = json!({
"platform": "bedrock",
"name": "AWS Bedrock",
"base_url": "https://bedrock.us-east-1.amazonaws.com",
"api_key": "test-key-abcd",
"models": ["anthropic.claude-3-sonnet"],
"enabled": false,
"capabilities": [{"type": "text"}, {"type": "vision", "is_user_selected": true}],
"context_limit": 200000,
"bedrock_config": {
"auth_method": "accessKey",
"region": "us-east-1",
"access_key_id": "AKIA...",
"secret_access_key": "secret"
}
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = body_json(resp).await;
let data = &json["data"];
assert!(!data["enabled"].as_bool().unwrap());
assert_eq!(data["models"].as_array().unwrap().len(), 1);
assert_eq!(data["capabilities"].as_array().unwrap().len(), 2);
assert_eq!(data["context_limit"], 200000);
assert_eq!(data["bedrock_config"]["auth_method"], "accessKey");
assert_eq!(data["bedrock_config"]["region"], "us-east-1");
}
#[tokio::test]
async fn create_provider_missing_platform() {
let (app, _db) = setup().await;
let body = json!({
"name": "Test",
"base_url": "https://api.example.com",
"api_key": "sk-test"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_provider_missing_name() {
let (app, _db) = setup().await;
let body = json!({
"platform": "openai",
"base_url": "https://api.example.com",
"api_key": "sk-test"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_provider_missing_base_url() {
let (app, _db) = setup().await;
let body = json!({
"platform": "openai",
"name": "Test",
"api_key": "sk-test"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_provider_missing_api_key() {
let (app, _db) = setup().await;
let body = json!({
"platform": "openai",
"name": "Test",
"base_url": "https://api.example.com"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_provider_invalid_url() {
let (app, _db) = setup().await;
let body = json!({
"platform": "openai",
"name": "Test",
"base_url": "not-a-url",
"api_key": "sk-test"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ===========================================================================
// PUT /api/providers/{id} — update
// ===========================================================================
#[tokio::test]
async fn update_provider_name() {
let (_app, db) = setup().await;
let (_, id) = create_one(&db).await;
let app2 = system_routes(build_state(&db));
let resp = app2
.oneshot(json_request(
"PUT",
&format!("/api/providers/{id}"),
json!({"name": "New Name"}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["name"], "New Name");
assert_eq!(json["data"]["platform"], "anthropic");
}
#[tokio::test]
async fn update_provider_api_key_returns_plaintext() {
let (_app, db) = setup().await;
let (_, id) = create_one(&db).await;
let app2 = system_routes(build_state(&db));
let resp = app2
.oneshot(json_request(
"PUT",
&format!("/api/providers/{id}"),
json!({"api_key": "new-key-abcdefgh"}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let api_key = json["data"]["api_key"].as_str().unwrap();
assert_eq!(api_key, "new-key-abcdefgh");
}
#[tokio::test]
async fn update_provider_nonexistent() {
let (app, _db) = setup().await;
let resp = app
.oneshot(json_request("PUT", "/api/providers/nonexistent", json!({"name": "X"})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// ===========================================================================
// DELETE /api/providers/{id}
// ===========================================================================
#[tokio::test]
async fn delete_provider_success() {
let (_app, db) = setup().await;
let (_, id) = create_one(&db).await;
let app2 = system_routes(build_state(&db));
let resp = app2
.oneshot(delete_request(&format!("/api/providers/{id}")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
}
#[tokio::test]
async fn delete_provider_then_list_excludes_deleted() {
let (_app, db) = setup().await;
let (_, id) = create_one(&db).await;
let app2 = system_routes(build_state(&db));
let resp = app2
.oneshot(delete_request(&format!("/api/providers/{id}")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app3 = system_routes(build_state(&db));
let resp = app3.oneshot(get_request("/api/providers")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"], json!([]));
}
#[tokio::test]
async fn delete_provider_nonexistent() {
let (app, _db) = setup().await;
let resp = app.oneshot(delete_request("/api/providers/nonexistent")).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// ===========================================================================
// Full CRUD flow
// ===========================================================================
#[tokio::test]
async fn full_crud_flow() {
let (_app, db) = setup().await;
// 1. Create
let (create_json, id) = create_one(&db).await;
assert_eq!(create_json["data"]["platform"], "anthropic");
// 2. List — should contain one
let app2 = system_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/providers")).await.unwrap();
let list_json = body_json(resp).await;
assert_eq!(list_json["data"].as_array().unwrap().len(), 1);
// 3. Update
let app3 = system_routes(build_state(&db));
let resp = app3
.oneshot(json_request(
"PUT",
&format!("/api/providers/{id}"),
json!({"name": "Updated", "enabled": false}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let update_json = body_json(resp).await;
assert_eq!(update_json["data"]["name"], "Updated");
assert!(!update_json["data"]["enabled"].as_bool().unwrap());
// 4. Verify update via list
let app4 = system_routes(build_state(&db));
let resp = app4.oneshot(get_request("/api/providers")).await.unwrap();
let list_json = body_json(resp).await;
assert_eq!(list_json["data"][0]["name"], "Updated");
// 5. Delete
let app5 = system_routes(build_state(&db));
let resp = app5
.oneshot(delete_request(&format!("/api/providers/{id}")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 6. Verify deleted
let app6 = system_routes(build_state(&db));
let resp = app6.oneshot(get_request("/api/providers")).await.unwrap();
let list_json = body_json(resp).await;
assert_eq!(list_json["data"], json!([]));
}
@@ -0,0 +1,357 @@
//! Black-box integration tests for system settings routes.
//!
//! Tests exercise the HTTP layer (request → handler → response) via
//! `tower::ServiceExt::oneshot`, without authentication middleware.
//! Auth protection is verified at the app-level E2E tests (task 3.9).
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use nomifun_db::{
SqliteBrandingConfigRepository, SqliteClientPreferenceRepository, SqliteProviderRepository,
SqliteSettingsRepository, SqliteSystemConfigRepository, init_database_memory,
};
use nomifun_system::{
ClientPrefService, ModelFetchService, ProtocolDetectionService, ProviderService, SettingsService,
SystemRouterState, VersionCheckService, settings_routes,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEST_ENCRYPTION_KEY: [u8; 32] = [0x42; 32];
fn build_state(db: &nomifun_db::Database) -> SystemRouterState {
let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let http_client = reqwest::Client::new();
let system_config_repo = Arc::new(SqliteSystemConfigRepository::new(db.pool().clone()));
let branding_config_repo = Arc::new(SqliteBrandingConfigRepository::new(db.pool().clone()));
SystemRouterState {
settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))),
client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))),
provider_service: ProviderService::new(provider_repo.clone(), TEST_ENCRYPTION_KEY),
model_fetch_service: ModelFetchService::new(provider_repo, TEST_ENCRYPTION_KEY, http_client.clone()),
protocol_detection_service: ProtocolDetectionService::new(http_client.clone()),
version_check_service: VersionCheckService::new(http_client, "0.1.0".to_owned()),
data_dir: std::env::temp_dir(),
system_config_repo,
branding_config_repo,
}
}
async fn setup() -> (axum::Router, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let state = build_state(&db);
(settings_routes(state), db)
}
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
fn get_request(uri: &str) -> Request<Body> {
Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()
}
fn json_request(method: &str, uri: &str, body: serde_json::Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
// ===========================================================================
// System Settings (GET/PATCH /api/settings)
// ===========================================================================
#[tokio::test]
async fn get_settings_default_values() {
let (app, _db) = setup().await;
let resp = app.oneshot(get_request("/api/settings")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["data"]["language"], "en-US");
assert_eq!(json["data"]["notification_enabled"], true);
assert_eq!(json["data"]["cron_notification_enabled"], false);
assert_eq!(json["data"]["command_queue_enabled"], false);
assert_eq!(json["data"]["save_upload_to_workspace"], false);
}
#[tokio::test]
async fn patch_settings_single_field() {
let (app, _db) = setup().await;
let req = json_request("PATCH", "/api/settings", serde_json::json!({"language": "zh-CN"}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["data"]["language"], "zh-CN");
// Others remain default
assert_eq!(json["data"]["notification_enabled"], true);
assert_eq!(json["data"]["cron_notification_enabled"], false);
}
#[tokio::test]
async fn patch_settings_multiple_fields() {
let (app, _db) = setup().await;
let req = json_request(
"PATCH",
"/api/settings",
serde_json::json!({
"notification_enabled": false,
"command_queue_enabled": true
}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["notification_enabled"], false);
assert_eq!(json["data"]["command_queue_enabled"], true);
assert_eq!(json["data"]["language"], "en-US");
}
#[tokio::test]
async fn patch_settings_empty_body() {
let (app, _db) = setup().await;
let req = json_request("PATCH", "/api/settings", serde_json::json!({}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["language"], "en-US");
}
#[tokio::test]
async fn patch_settings_unsupported_language_rejected() {
let (app, _db) = setup().await;
let req = json_request(
"PATCH",
"/api/settings",
serde_json::json!({"language": "invalid-lang"}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn patch_settings_type_error_rejected() {
let (app, _db) = setup().await;
let req = json_request(
"PATCH",
"/api/settings",
serde_json::json!({"notification_enabled": "yes"}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn patch_settings_unknown_field_ignored() {
let (app, _db) = setup().await;
let req = json_request("PATCH", "/api/settings", serde_json::json!({"unknown_field": 123}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["language"], "en-US");
}
#[tokio::test]
async fn patch_then_get_reflects_changes() {
let (app, db) = setup().await;
// First PATCH to update
let req = json_request(
"PATCH",
"/api/settings",
serde_json::json!({"language": "zh-CN", "save_upload_to_workspace": true}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Build a fresh router with the same DB to GET
let app2 = settings_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/settings")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["language"], "zh-CN");
assert_eq!(json["data"]["save_upload_to_workspace"], true);
}
// ===========================================================================
// Client Preferences (GET/PUT /api/settings/client)
// ===========================================================================
#[tokio::test]
async fn get_client_prefs_empty() {
let (app, _db) = setup().await;
let resp = app.oneshot(get_request("/api/settings/client")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["data"], serde_json::json!({}));
}
#[tokio::test]
async fn put_and_get_boolean_value() {
let (app, db) = setup().await;
let req = json_request(
"PUT",
"/api/settings/client",
serde_json::json!({"system.closeToTray": true}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app2 = settings_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["system.closeToTray"], true);
}
#[tokio::test]
async fn put_and_get_number_value() {
let (app, db) = setup().await;
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"companion.size": 360}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app2 = settings_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["companion.size"], 360);
}
#[tokio::test]
async fn put_and_get_string_value() {
let (app, db) = setup().await;
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"theme": "dark"}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app2 = settings_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["theme"], "dark");
}
#[tokio::test]
async fn put_null_deletes_key() {
let (app, db) = setup().await;
// First write a value
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"theme": "dark"}));
app.oneshot(req).await.unwrap();
// Then delete it with null
let app2 = settings_routes(build_state(&db));
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"theme": null}));
app2.oneshot(req).await.unwrap();
// Verify it's gone
let app3 = settings_routes(build_state(&db));
let resp = app3.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"], serde_json::json!({}));
}
#[tokio::test]
async fn put_batch_write() {
let (app, db) = setup().await;
let req = json_request(
"PUT",
"/api/settings/client",
serde_json::json!({"a": 1, "b": "x", "c": true}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app2 = settings_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["a"], 1);
assert_eq!(json["data"]["b"], "x");
assert_eq!(json["data"]["c"], true);
}
#[tokio::test]
async fn get_client_prefs_with_keys_filter() {
let (app, db) = setup().await;
// Write several values
let req = json_request(
"PUT",
"/api/settings/client",
serde_json::json!({"a": 1, "b": 2, "c": 3}),
);
app.oneshot(req).await.unwrap();
// Fetch with key filter
let app2 = settings_routes(build_state(&db));
let resp = app2
.oneshot(get_request("/api/settings/client?keys=a,c"))
.await
.unwrap();
let json = body_json(resp).await;
let data = json["data"].as_object().unwrap();
assert_eq!(data.len(), 2);
assert_eq!(data["a"], 1);
assert_eq!(data["c"], 3);
}
#[tokio::test]
async fn put_overwrite_existing_value() {
let (app, db) = setup().await;
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"k": "v1"}));
app.oneshot(req).await.unwrap();
let app2 = settings_routes(build_state(&db));
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"k": "v2"}));
app2.oneshot(req).await.unwrap();
let app3 = settings_routes(build_state(&db));
let resp = app3.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["k"], "v2");
}
#[tokio::test]
async fn put_empty_key_rejected() {
let (app, _db) = setup().await;
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"": true}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn put_long_key_rejected() {
let (app, _db) = setup().await;
let long_key = "x".repeat(256);
let req = json_request("PUT", "/api/settings/client", serde_json::json!({long_key: true}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
@@ -0,0 +1,469 @@
//! Black-box integration tests for system info and version check routes.
//!
//! System info tests verify the GET /api/system/info endpoint returns
//! correct platform/arch values and non-empty directory paths.
//!
//! Version check tests use `wiremock` to mock the GitHub Releases API
//! and verify the POST /api/system/check-update endpoint.
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::json;
use tower::ServiceExt;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use nomifun_db::{
SqliteBrandingConfigRepository, SqliteClientPreferenceRepository, SqliteProviderRepository,
SqliteSettingsRepository, SqliteSystemConfigRepository, init_database_memory,
};
use nomifun_system::{
ClientPrefService, ModelFetchService, ProtocolDetectionService, ProviderService, SettingsService,
SystemRouterState, VersionCheckService, system_routes,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEST_KEY: [u8; 32] = [0x42; 32];
fn build_state(db: &nomifun_db::Database, version_check_service: VersionCheckService) -> SystemRouterState {
let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let http_client = reqwest::Client::new();
let system_config_repo = Arc::new(SqliteSystemConfigRepository::new(db.pool().clone()));
let branding_config_repo = Arc::new(SqliteBrandingConfigRepository::new(db.pool().clone()));
SystemRouterState {
settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))),
client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))),
provider_service: ProviderService::new(provider_repo.clone(), TEST_KEY),
model_fetch_service: ModelFetchService::new(provider_repo, TEST_KEY, http_client.clone()),
protocol_detection_service: ProtocolDetectionService::new(http_client),
version_check_service,
data_dir: std::env::temp_dir(),
system_config_repo,
branding_config_repo,
}
}
async fn setup() -> axum::Router {
let db = init_database_memory().await.unwrap();
let http_client = reqwest::Client::new();
let vcs = VersionCheckService::new(http_client, "1.0.0".to_owned());
let state = build_state(&db, vcs);
system_routes(state)
}
async fn setup_with_mock(current_version: &str, mock_server: &MockServer) -> axum::Router {
let db = init_database_memory().await.unwrap();
let http_client = reqwest::Client::new();
let vcs = VersionCheckService::with_api_base(http_client, current_version.to_owned(), mock_server.uri());
let state = build_state(&db, vcs);
system_routes(state)
}
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
fn get_request(uri: &str) -> Request<Body> {
Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()
}
fn json_request(method_str: &str, uri: &str, body: serde_json::Value) -> Request<Body> {
Request::builder()
.method(method_str)
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
fn make_github_release(tag: &str, draft: bool, prerelease: bool, assets: Vec<serde_json::Value>) -> serde_json::Value {
json!({
"tag_name": tag,
"name": format!("Release {tag}"),
"body": "Release notes",
"html_url": format!("https://github.com/nomifun/nomifun-app/releases/tag/{tag}"),
"published_at": "2026-04-01T00:00:00Z",
"prerelease": prerelease,
"draft": draft,
"assets": assets,
})
}
fn make_github_asset(name: &str, size: u64) -> serde_json::Value {
json!({
"name": name,
"browser_download_url": format!("https://github.com/download/{name}"),
"size": size,
"content_type": "application/octet-stream",
})
}
// ===========================================================================
// GET /api/system/info
// ===========================================================================
#[tokio::test]
async fn test_system_info_returns_all_fields() {
let app = setup().await;
let resp = app.oneshot(get_request("/api/system/info")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
let data = &json["data"];
assert!(data["cache_dir"].as_str().is_some_and(|s| !s.is_empty()));
assert!(data["work_dir"].as_str().is_some_and(|s| !s.is_empty()));
assert!(data["log_dir"].as_str().is_some_and(|s| !s.is_empty()));
assert!(data["platform"].as_str().is_some_and(|s| !s.is_empty()));
assert!(data["arch"].as_str().is_some_and(|s| !s.is_empty()));
}
#[tokio::test]
async fn test_system_info_platform_is_known() {
let app = setup().await;
let resp = app.oneshot(get_request("/api/system/info")).await.unwrap();
let json = body_json(resp).await;
let platform = json["data"]["platform"].as_str().unwrap();
assert!(
["darwin", "win32", "linux"].contains(&platform),
"unexpected platform: {platform}"
);
}
#[tokio::test]
async fn test_system_info_arch_is_known() {
let app = setup().await;
let resp = app.oneshot(get_request("/api/system/info")).await.unwrap();
let json = body_json(resp).await;
let arch = json["data"]["arch"].as_str().unwrap();
assert!(["x64", "arm64"].contains(&arch), "unexpected arch: {arch}");
}
#[tokio::test]
async fn test_system_info_snake_case_keys() {
let app = setup().await;
let resp = app.oneshot(get_request("/api/system/info")).await.unwrap();
let json = body_json(resp).await;
let data = &json["data"];
assert!(data.get("cache_dir").is_some());
assert!(data.get("work_dir").is_some());
assert!(data.get("log_dir").is_some());
assert!(data.get("cacheDir").is_none());
assert!(data.get("workDir").is_none());
assert!(data.get("logDir").is_none());
}
// ===========================================================================
// POST /api/system/check-update — with wiremock
// ===========================================================================
#[tokio::test]
async fn test_check_update_has_new_version() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release(
"v2.0.0",
false,
false,
vec![
make_github_asset("app-2.0.0-darwin-arm64.dmg", 80_000_000),
make_github_asset("app-2.0.0-linux-x64.deb", 60_000_000),
]
),
make_github_release("v1.5.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["data"]["current_version"], "1.0.0");
assert_eq!(json["data"]["update_available"], true);
let latest = &json["data"]["latest"];
assert_eq!(latest["tag_name"], "v2.0.0");
assert_eq!(latest["version"], "2.0.0");
assert!(!latest["assets"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_check_update_no_update_available() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release("v1.0.0", false, false, vec![]),
make_github_release("v0.9.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["update_available"], false);
assert!(json["data"].get("latest").is_none());
}
#[tokio::test]
async fn test_check_update_skips_draft() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release("v5.0.0", true, false, vec![]), // draft — skip
make_github_release("v2.0.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["update_available"], true);
assert_eq!(json["data"]["latest"]["version"], "2.0.0");
}
#[tokio::test]
async fn test_check_update_skips_prerelease_by_default() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release("v3.0.0-beta.1", false, true, vec![]),
make_github_release("v2.0.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request(
"POST",
"/api/system/check-update",
json!({"include_prerelease": false}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["latest"]["version"], "2.0.0");
}
#[tokio::test]
async fn test_check_update_includes_prerelease_when_requested() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release("v3.0.0-beta.1", false, true, vec![]),
make_github_release("v2.0.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request(
"POST",
"/api/system/check-update",
json!({"include_prerelease": true}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["latest"]["version"], "3.0.0-beta.1");
}
#[tokio::test]
async fn test_check_update_recommended_asset_matches_platform() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([make_github_release(
"v2.0.0",
false,
false,
vec![
make_github_asset("app-2.0.0-win-x64.exe", 50_000_000),
make_github_asset("app-2.0.0-darwin-arm64.dmg", 80_000_000),
make_github_asset("app-2.0.0-linux-amd64.deb", 60_000_000),
]
),])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let recommended = &json["data"]["latest"]["recommended_asset"];
// On the CI runner's actual platform, the recommended asset should match
if recommended.is_object() {
let name = recommended["name"].as_str().unwrap();
// Verify it's one of the known assets
assert!(
name.contains("darwin") || name.contains("linux") || name.contains("win"),
"recommended asset should contain platform keyword: {name}"
);
}
}
#[tokio::test]
async fn test_check_update_github_api_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
let json = body_json(resp).await;
assert_eq!(json["success"], false);
}
#[tokio::test]
async fn test_check_update_empty_releases() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["update_available"], false);
}
#[tokio::test]
async fn test_check_update_custom_repo() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/custom-org/custom-repo/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([make_github_release(
"v3.0.0",
false,
false,
vec![]
),])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request(
"POST",
"/api/system/check-update",
json!({"repo": "custom-org/custom-repo"}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["update_available"], true);
assert_eq!(json["data"]["latest"]["version"], "3.0.0");
}
#[tokio::test]
async fn test_check_update_invalid_tag_ignored() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release("not-semver", false, false, vec![]),
make_github_release("v2.0.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["latest"]["version"], "2.0.0");
}
#[tokio::test]
async fn test_check_update_response_format() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([make_github_release(
"v2.0.0",
false,
false,
vec![make_github_asset("app.dmg", 100_000),]
),])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
let json = body_json(resp).await;
let latest = &json["data"]["latest"];
// Verify snake_case serialization
assert!(latest.get("tag_name").is_some());
assert!(latest.get("html_url").is_some());
assert!(latest.get("published_at").is_some());
// Verify camelCase is NOT used
assert!(latest.get("tagName").is_none());
assert!(latest.get("htmlUrl").is_none());
}