Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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,
|
||||
¤t,
|
||||
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, ¤t, 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, ¤t, 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, ¤t, 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, ¤t, false, "darwin", "arm64");
|
||||
assert_eq!(best.unwrap().version, "2.0.0");
|
||||
|
||||
// With prerelease
|
||||
let best = find_best_release(&releases, ¤t, 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, ¤t, 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, ¤t, 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, ¤t, 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());
|
||||
}
|
||||
Reference in New Issue
Block a user