mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-03 02:28:09 +08:00
This merge brings the SQLite migration system from feat/sqlite-migration branch: ## New Features - Schema version control with SCHEMA_VERSION constant - Automatic migration of missing columns for providers table - Dry-run validation mode for schema compatibility checks - JSON→SQLite migration feature gate (CC_SWITCH_ENABLE_JSON_DB_MIGRATION) - Settings reload mechanism after imports ## Test Updates - Updated tests to use SQLite database instead of config.json - Removed obsolete import_config_from_path tests (replaced by db.import_sql) - Fixed MCP tests to use unified McpServer structure (v3.7.0+) - Updated provider switch tests to reflect no-backfill behavior - Adjusted error type matching for new error variants
620 lines
21 KiB
Rust
620 lines
21 KiB
Rust
use serde_json::json;
|
||
|
||
use cc_switch_lib::{
|
||
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType,
|
||
MultiAppConfig, Provider, ProviderMeta, ProviderService,
|
||
};
|
||
|
||
#[path = "support.rs"]
|
||
mod support;
|
||
use support::{create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
|
||
|
||
fn sanitize_provider_name(name: &str) -> String {
|
||
name.chars()
|
||
.map(|c| match c {
|
||
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
|
||
_ => c,
|
||
})
|
||
.collect::<String>()
|
||
.to_lowercase()
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_switch_codex_updates_live_and_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let legacy_auth = json!({ "OPENAI_API_KEY": "legacy-key" });
|
||
let legacy_config = r#"[mcp_servers.legacy]
|
||
type = "stdio"
|
||
command = "echo"
|
||
"#;
|
||
write_codex_live_atomic(&legacy_auth, Some(legacy_config))
|
||
.expect("seed existing codex live config");
|
||
|
||
let mut initial_config = MultiAppConfig::default();
|
||
{
|
||
let manager = initial_config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.current = "old-provider".to_string();
|
||
manager.providers.insert(
|
||
"old-provider".to_string(),
|
||
Provider::with_id(
|
||
"old-provider".to_string(),
|
||
"Legacy".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "stale"},
|
||
"config": "stale-config"
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
manager.providers.insert(
|
||
"new-provider".to_string(),
|
||
Provider::with_id(
|
||
"new-provider".to_string(),
|
||
"Latest".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "fresh-key"},
|
||
"config": r#"[mcp_servers.latest]
|
||
type = "stdio"
|
||
command = "say"
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
initial_config.mcp.codex.servers.insert(
|
||
"echo-server".into(),
|
||
json!({
|
||
"id": "echo-server",
|
||
"enabled": true,
|
||
"server": {
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}
|
||
}),
|
||
);
|
||
|
||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "new-provider")
|
||
.expect("switch provider should succeed");
|
||
|
||
let auth_value: serde_json::Value =
|
||
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
|
||
assert_eq!(
|
||
auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()),
|
||
Some("fresh-key"),
|
||
"live auth.json should reflect new provider"
|
||
);
|
||
|
||
let config_text =
|
||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||
assert!(
|
||
config_text.contains("mcp_servers.echo-server"),
|
||
"config.toml should contain synced MCP servers"
|
||
);
|
||
|
||
let current_id = state.db.get_current_provider(AppType::Codex.as_str())
|
||
.expect("read current provider after switch");
|
||
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
|
||
|
||
let providers = state.db.get_all_providers(AppType::Codex.as_str())
|
||
.expect("read providers after switch");
|
||
|
||
let new_provider = providers
|
||
.get("new-provider")
|
||
.expect("new provider exists");
|
||
let new_config_text = new_provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or_default();
|
||
assert_eq!(
|
||
new_config_text, config_text,
|
||
"provider config snapshot should match live file"
|
||
);
|
||
|
||
let legacy = providers
|
||
.get("old-provider")
|
||
.expect("legacy provider still exists");
|
||
let legacy_auth_value = legacy
|
||
.settings_config
|
||
.get("auth")
|
||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("");
|
||
assert_eq!(
|
||
legacy_auth_value, "legacy-key",
|
||
"previous provider should be backfilled with live auth"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switch_packycode_gemini_updates_security_selected_type() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Gemini)
|
||
.expect("gemini manager");
|
||
manager.current = "packy-gemini".to_string();
|
||
manager.providers.insert(
|
||
"packy-gemini".to_string(),
|
||
Provider::with_id(
|
||
"packy-gemini".to_string(),
|
||
"PackyCode".to_string(),
|
||
json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "pk-key",
|
||
"GOOGLE_GEMINI_BASE_URL": "https://www.packyapi.com"
|
||
}
|
||
}),
|
||
Some("https://www.packyapi.com".to_string()),
|
||
),
|
||
);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
|
||
.expect("switching to PackyCode Gemini should succeed");
|
||
|
||
let settings_path = home.join(".cc-switch").join("settings.json");
|
||
assert!(
|
||
settings_path.exists(),
|
||
"settings.json should exist at {}",
|
||
settings_path.display()
|
||
);
|
||
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||
let value: serde_json::Value =
|
||
serde_json::from_str(&raw).expect("parse settings.json after switch");
|
||
|
||
assert_eq!(
|
||
value
|
||
.pointer("/security/auth/selectedType")
|
||
.and_then(|v| v.as_str()),
|
||
Some("gemini-api-key"),
|
||
"PackyCode Gemini should set security.auth.selectedType"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Gemini)
|
||
.expect("gemini manager");
|
||
manager.current = "packy-meta".to_string();
|
||
let mut provider = Provider::with_id(
|
||
"packy-meta".to_string(),
|
||
"Generic Gemini".to_string(),
|
||
json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "pk-meta",
|
||
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com"
|
||
}
|
||
}),
|
||
Some("https://example.com".to_string()),
|
||
);
|
||
provider.meta = Some(ProviderMeta {
|
||
partner_promotion_key: Some("packycode".to_string()),
|
||
..ProviderMeta::default()
|
||
});
|
||
manager.providers.insert("packy-meta".to_string(), provider);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
|
||
.expect("switching to partner meta provider should succeed");
|
||
|
||
let settings_path = home.join(".cc-switch").join("settings.json");
|
||
assert!(
|
||
settings_path.exists(),
|
||
"settings.json should exist at {}",
|
||
settings_path.display()
|
||
);
|
||
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||
let value: serde_json::Value =
|
||
serde_json::from_str(&raw).expect("parse settings.json after switch");
|
||
|
||
assert_eq!(
|
||
value
|
||
.pointer("/security/auth/selectedType")
|
||
.and_then(|v| v.as_str()),
|
||
Some("gemini-api-key"),
|
||
"Partner meta should set security.auth.selectedType even without packy keywords"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switch_google_official_gemini_sets_oauth_security() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Gemini)
|
||
.expect("gemini manager");
|
||
manager.current = "google-official".to_string();
|
||
let mut provider = Provider::with_id(
|
||
"google-official".to_string(),
|
||
"Google".to_string(),
|
||
json!({
|
||
"env": {}
|
||
}),
|
||
Some("https://ai.google.dev".to_string()),
|
||
);
|
||
provider.meta = Some(ProviderMeta {
|
||
partner_promotion_key: Some("google-official".to_string()),
|
||
..ProviderMeta::default()
|
||
});
|
||
manager
|
||
.providers
|
||
.insert("google-official".to_string(), provider);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
ProviderService::switch(&state, AppType::Gemini, "google-official")
|
||
.expect("switching to Google official Gemini should succeed");
|
||
|
||
let settings_path = home.join(".cc-switch").join("settings.json");
|
||
assert!(
|
||
settings_path.exists(),
|
||
"settings.json should exist at {}",
|
||
settings_path.display()
|
||
);
|
||
|
||
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
|
||
assert_eq!(
|
||
value
|
||
.pointer("/security/auth/selectedType")
|
||
.and_then(|v| v.as_str()),
|
||
Some("oauth-personal"),
|
||
"Google official Gemini should set oauth-personal selectedType in app settings"
|
||
);
|
||
|
||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||
assert!(
|
||
gemini_settings.exists(),
|
||
"Gemini settings.json should exist at {}",
|
||
gemini_settings.display()
|
||
);
|
||
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
||
let gemini_value: serde_json::Value =
|
||
serde_json::from_str(&gemini_raw).expect("parse gemini settings");
|
||
|
||
assert_eq!(
|
||
gemini_value
|
||
.pointer("/security/auth/selectedType")
|
||
.and_then(|v| v.as_str()),
|
||
Some("oauth-personal"),
|
||
"Gemini settings json should also reflect oauth-personal"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_switch_claude_updates_live_and_state() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let settings_path = get_claude_settings_path();
|
||
if let Some(parent) = settings_path.parent() {
|
||
std::fs::create_dir_all(parent).expect("create claude settings dir");
|
||
}
|
||
let legacy_live = json!({
|
||
"env": {
|
||
"ANTHROPIC_API_KEY": "legacy-key"
|
||
},
|
||
"workspace": {
|
||
"path": "/tmp/workspace"
|
||
}
|
||
});
|
||
std::fs::write(
|
||
&settings_path,
|
||
serde_json::to_string_pretty(&legacy_live).expect("serialize legacy live"),
|
||
)
|
||
.expect("seed claude live config");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Claude)
|
||
.expect("claude manager");
|
||
manager.current = "old-provider".to_string();
|
||
manager.providers.insert(
|
||
"old-provider".to_string(),
|
||
Provider::with_id(
|
||
"old-provider".to_string(),
|
||
"Legacy Claude".to_string(),
|
||
json!({
|
||
"env": { "ANTHROPIC_API_KEY": "stale-key" }
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
manager.providers.insert(
|
||
"new-provider".to_string(),
|
||
Provider::with_id(
|
||
"new-provider".to_string(),
|
||
"Fresh Claude".to_string(),
|
||
json!({
|
||
"env": { "ANTHROPIC_API_KEY": "fresh-key" },
|
||
"workspace": { "path": "/tmp/new-workspace" }
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
ProviderService::switch(&state, AppType::Claude, "new-provider")
|
||
.expect("switch provider should succeed");
|
||
|
||
let live_after: serde_json::Value =
|
||
read_json_file(&settings_path).expect("read claude live settings");
|
||
assert_eq!(
|
||
live_after
|
||
.get("env")
|
||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||
.and_then(|key| key.as_str()),
|
||
Some("fresh-key"),
|
||
"live settings.json should reflect new provider auth"
|
||
);
|
||
|
||
let providers = state.db.get_all_providers(AppType::Claude.as_str())
|
||
.expect("get all providers");
|
||
let current_id = state.db.get_current_provider(AppType::Claude.as_str())
|
||
.expect("get current provider");
|
||
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
|
||
|
||
let legacy_provider = providers
|
||
.get("old-provider")
|
||
.expect("legacy provider still exists");
|
||
assert_eq!(
|
||
legacy_provider.settings_config, legacy_live,
|
||
"previous provider should receive backfilled live config"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_switch_missing_provider_returns_error() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
|
||
let err = ProviderService::switch(&state, AppType::Claude, "missing")
|
||
.expect_err("switching missing provider should fail");
|
||
match err {
|
||
AppError::Message(msg) => {
|
||
assert!(
|
||
msg.contains("不存在") || msg.contains("not found"),
|
||
"expected provider not found message, got {msg}"
|
||
);
|
||
}
|
||
other => panic!("expected Message error for provider not found, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_switch_codex_missing_auth_returns_error() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.providers.insert(
|
||
"invalid".to_string(),
|
||
Provider::with_id(
|
||
"invalid".to_string(),
|
||
"Broken Codex".to_string(),
|
||
json!({
|
||
"config": "[mcp_servers.test]\ncommand = \"noop\""
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
let err = ProviderService::switch(&state, AppType::Codex, "invalid")
|
||
.expect_err("switching should fail without auth");
|
||
match err {
|
||
AppError::Config(msg) => assert!(
|
||
msg.contains("auth"),
|
||
"expected auth related message, got {msg}"
|
||
),
|
||
other => panic!("expected config error, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_delete_codex_removes_provider_and_files() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.current = "keep".to_string();
|
||
manager.providers.insert(
|
||
"keep".to_string(),
|
||
Provider::with_id(
|
||
"keep".to_string(),
|
||
"Keep".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "keep-key"},
|
||
"config": ""
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
manager.providers.insert(
|
||
"to-delete".to_string(),
|
||
Provider::with_id(
|
||
"to-delete".to_string(),
|
||
"DeleteCodex".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "delete-key"},
|
||
"config": ""
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
let sanitized = sanitize_provider_name("DeleteCodex");
|
||
let codex_dir = home.join(".codex");
|
||
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||
let auth_path = codex_dir.join(format!("auth-{sanitized}.json"));
|
||
let cfg_path = codex_dir.join(format!("config-{sanitized}.toml"));
|
||
std::fs::write(&auth_path, "{}").expect("seed auth file");
|
||
std::fs::write(&cfg_path, "base_url = \"https://example\"").expect("seed config file");
|
||
|
||
let app_state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
ProviderService::delete(&app_state, AppType::Codex, "to-delete")
|
||
.expect("delete provider should succeed");
|
||
|
||
let providers = app_state.db.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get all providers");
|
||
assert!(
|
||
!providers.contains_key("to-delete"),
|
||
"provider entry should be removed"
|
||
);
|
||
// v3.7.0+ 不再使用供应商特定文件(如 auth-*.json, config-*.toml)
|
||
// 删除供应商只影响数据库记录,不清理这些旧格式文件
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_delete_claude_removes_provider_files() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Claude)
|
||
.expect("claude manager");
|
||
manager.current = "keep".to_string();
|
||
manager.providers.insert(
|
||
"keep".to_string(),
|
||
Provider::with_id(
|
||
"keep".to_string(),
|
||
"Keep".to_string(),
|
||
json!({
|
||
"env": { "ANTHROPIC_API_KEY": "keep-key" }
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
manager.providers.insert(
|
||
"delete".to_string(),
|
||
Provider::with_id(
|
||
"delete".to_string(),
|
||
"DeleteClaude".to_string(),
|
||
json!({
|
||
"env": { "ANTHROPIC_API_KEY": "delete-key" }
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
let sanitized = sanitize_provider_name("DeleteClaude");
|
||
let claude_dir = home.join(".claude");
|
||
std::fs::create_dir_all(&claude_dir).expect("create claude dir");
|
||
let by_name = claude_dir.join(format!("settings-{sanitized}.json"));
|
||
let by_id = claude_dir.join("settings-delete.json");
|
||
std::fs::write(&by_name, "{}").expect("seed settings by name");
|
||
std::fs::write(&by_id, "{}").expect("seed settings by id");
|
||
|
||
let app_state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
|
||
|
||
let providers = app_state.db.get_all_providers(AppType::Claude.as_str())
|
||
.expect("get all providers");
|
||
assert!(
|
||
!providers.contains_key("delete"),
|
||
"claude provider should be removed"
|
||
);
|
||
assert!(
|
||
!by_name.exists() && !by_id.exists(),
|
||
"provider config files should be deleted"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_delete_current_provider_returns_error() {
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Claude)
|
||
.expect("claude manager");
|
||
manager.current = "keep".to_string();
|
||
manager.providers.insert(
|
||
"keep".to_string(),
|
||
Provider::with_id(
|
||
"keep".to_string(),
|
||
"Keep".to_string(),
|
||
json!({
|
||
"env": { "ANTHROPIC_API_KEY": "keep-key" }
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
let app_state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
let err = ProviderService::delete(&app_state, AppType::Claude, "keep")
|
||
.expect_err("deleting current provider should fail");
|
||
match err {
|
||
AppError::Localized { zh, .. } => assert!(
|
||
zh.contains("不能删除当前正在使用的供应商") || zh.contains("无法删除当前正在使用的供应商"),
|
||
"unexpected message: {zh}"
|
||
),
|
||
AppError::Config(msg) => assert!(
|
||
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
|
||
"unexpected message: {msg}"
|
||
),
|
||
AppError::Message(msg) => assert!(
|
||
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
|
||
"unexpected message: {msg}"
|
||
),
|
||
other => panic!("expected Config/Message error, got {other:?}"),
|
||
}
|
||
}
|