mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 19:12:04 +08:00
4f78451405
Replace the generic Provider save/upsert surface with strict typed create/update/rename/endpoint operations. Keep aggregate hydration read-only, preserve nullable endpoint timestamps end to end, and make service create own the initial endpoint set atomically. Restore SQL and binary backups only through UntrustedScratch, migrate and copy fixed data columns into a fresh CanonicalStage, validate the canonical result, then publish through the Backup API. The imported schema is never eligible to become the live schema. Old save_provider callsite classification ========================================== Inventory authority: abandoned 5a385fc8 tree. The old definition at src-tauri/src/database/dao/providers.rs:180 is deleted and is not a callsite. Production callsites: - src-tauri/src/commands/provider.rs:253 [create] Claude Desktop import creates one absent aggregate; it now strict-inserts the row and initial endpoints in one transaction. - src-tauri/src/database/dao/providers.rs:638 [create] official seed first proves absence, then strict-creates; a racing insert is a conflict. - src-tauri/src/database/dao/providers.rs:704 [create] on-demand seed first proves absence, then strict-creates; it cannot overwrite an existing row. - src-tauri/src/services/omo.rs:291 [create] OMO import constructs a new aggregate and strict-creates it; OMO is not eligible for rename. - src-tauri/src/services/provider/endpoints.rs:85 [update] endpoint last-used is not a Provider-row save; it now calls the exact touch endpoint operation. - src-tauri/src/services/provider/live.rs:1567 [create/update] default live import is reconciliation: read first, then strict create or strict update. - src-tauri/src/services/provider/live.rs:1743 [update] an existing OpenCode live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1770 [create] a new OpenCode live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/live.rs:1825 [update] an existing OpenClaw live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1858 [create] a new OpenClaw live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/live.rs:1900 [update] an existing Hermes live provider follows the strict row-update branch. - src-tauri/src/services/provider/live.rs:1926 [create] a new Hermes live provider follows the strict aggregate-create branch. - src-tauri/src/services/provider/mod.rs:2568 [create] ProviderService::add owns strict aggregate creation and all initial endpoints. - src-tauri/src/services/provider/mod.rs:2680 [rename] an additive DB-only key change now uses the dedicated transactional rename after eligibility checks. - src-tauri/src/services/provider/mod.rs:2711 [update] OMO edit updates exactly the existing main row after its live-file coordination. - src-tauri/src/services/provider/mod.rs:2740 [update] additive-provider edit updates exactly the existing main row after resolving live ownership. - src-tauri/src/services/provider/mod.rs:2750 [update] switch-mode edit updates exactly the existing main row and never inserts. - src-tauri/src/services/provider/mod.rs:2948 [update] remove-from-live changes only the existing provider's live-managed marker. - src-tauri/src/services/provider/mod.rs:3120 [update] switch backfill updates only the existing current provider row. - src-tauri/src/services/provider/mod.rs:3174 [update] successful additive switch changes only the existing live-managed marker. - src-tauri/src/services/provider/mod.rs:3315 [update] common-config migration updates only each already-read existing row. - src-tauri/src/services/provider/mod.rs:3895 [update] Gemini credential scrub updates only each already-read existing row. - src-tauri/src/services/provider/mod.rs:4082 [update] sort ordering is routed to the dedicated sort-index state operation, not row replacement. - src-tauri/src/services/provider/mod.rs:4636 [create/update] universal-to- Claude reconciliation reads the target and selects strict create or update. - src-tauri/src/services/provider/mod.rs:4651 [create/update] universal-to- Codex reconciliation reads the target and selects strict create or update. - src-tauri/src/services/provider/mod.rs:4665 [create/update] universal-to- Gemini reconciliation reads the target and selects strict create or update. Required indirect ownership paths: - src-tauri/src/deeplink/provider.rs [create] the old indirect flow called ProviderService::add and then appended endpoints one by one. It now supplies every non-primary endpoint to one strict aggregate create, so hydration is complete atomically and a duplicate is zero-side-effect. - [restore] no old generic-save callsite is reclassified as restore. Exact aggregate replacement exists only as the sealed restore_provider_aggregate_on_tx compensation primitive. Test-only callsites: Every item below is classified [test]. Each is fixture setup, not a production write authority, and is migrated to a real ProviderService entry where the behavior is under test or to the cfg(test)-only typed fixture reconciler where the test merely needs pre-existing rows. - src-tauri/src/codex_history_migration.rs:1442 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:1452 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2174 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2176 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2199 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2219 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2247 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2267 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2288 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2320 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2393 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2449 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2498 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2555 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2604 [test] migration fixture setup. - src-tauri/src/codex_history_migration.rs:2625 [test] migration fixture setup. - src-tauri/src/database/dao/providers.rs:754 [test] DAO fixture setup. - src-tauri/src/proxy/provider_router.rs:351 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:352 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:377 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:378 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:410 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:411 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:447 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:448 [test] router fixture setup. - src-tauri/src/proxy/provider_router.rs:488 [test] router fixture setup. - src-tauri/src/services/provider/mod.rs:485 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:586 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:813 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:825 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1472 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1607 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1737 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1945 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1952 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:1978 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2006 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2056 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2130 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2167 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2207 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2235 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2270 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2320 [test] service fixture setup. - src-tauri/src/services/provider/mod.rs:2362 [test] service fixture setup. - src-tauri/src/services/proxy.rs:3762 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:3948 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4034 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4095 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4114 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4263 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4341 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4421 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4533 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4651 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:4787 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5264 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5320 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5385 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5387 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5460 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5462 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5611 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5613 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5615 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5698 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5700 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:5998 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6000 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6173 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6175 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6417 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6419 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6553 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6555 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6635 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6637 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:6919 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7173 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7175 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7240 [test] proxy fixture setup. - src-tauri/src/services/proxy.rs:7242 [test] proxy fixture setup. - src-tauri/tests/profile_roundtrip.rs:112 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:116 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:126 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:133 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:292 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:501 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:505 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:670 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:677 [test] profile fixture create. - src-tauri/tests/profile_roundtrip.rs:762 [test] Linux Desktop fixture create. - src-tauri/tests/profile_roundtrip.rs:769 [test] Linux Desktop fixture create. - src-tauri/tests/provider_commands.rs:69 [test] command fixture create. - src-tauri/tests/provider_service.rs:2927 [test] service fixture create.
694 lines
22 KiB
Rust
694 lines
22 KiB
Rust
use serde_json::json;
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use cc_switch_lib::{
|
||
get_codex_auth_path, get_codex_config_path, import_default_config_test_hook, read_json_file,
|
||
switch_provider_test_hook, write_codex_live_atomic, AppError, AppType, McpApps, McpServer,
|
||
MultiAppConfig, Provider, ProviderService,
|
||
};
|
||
|
||
#[path = "support.rs"]
|
||
mod support;
|
||
use std::collections::HashMap;
|
||
use support::{
|
||
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
|
||
ensure_test_home, new_provider_input, reset_test_fs, test_mutex,
|
||
};
|
||
|
||
fn settings_path(home: &Path) -> PathBuf {
|
||
home.join(".cc-switch").join("settings.json")
|
||
}
|
||
|
||
fn grokbuild_config(name: &str, endpoint: &str, api_key: &str) -> String {
|
||
format!(
|
||
r#"[models]
|
||
default = "grok-4.5"
|
||
|
||
[model."grok-4.5"]
|
||
model = "grok-4.5"
|
||
base_url = "{endpoint}"
|
||
name = "{name}"
|
||
api_key = "{api_key}"
|
||
api_backend = "responses"
|
||
context_window = 500000
|
||
"#
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn grokbuild_import_and_switch_write_live_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
let live_path = home.join(".grok").join("config.toml");
|
||
std::fs::create_dir_all(live_path.parent().expect("grok config dir"))
|
||
.expect("create grok config dir");
|
||
let imported_config = grokbuild_config("Imported", "https://old.example/v1", "old-key");
|
||
std::fs::write(&live_path, &imported_config).expect("seed Grok Build config");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
import_default_config_test_hook(&state, AppType::GrokBuild)
|
||
.expect("import Grok Build default provider");
|
||
|
||
let imported = state
|
||
.db
|
||
.get_provider_by_id("default", AppType::GrokBuild.as_str())
|
||
.expect("query imported provider")
|
||
.expect("imported provider exists");
|
||
assert_eq!(
|
||
imported
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|value| value.as_str()),
|
||
Some(imported_config.as_str())
|
||
);
|
||
|
||
let next_config = grokbuild_config("Relay", "https://new.example/v1", "new-key");
|
||
ProviderService::add(
|
||
&state,
|
||
AppType::GrokBuild,
|
||
new_provider_input(Provider::with_id(
|
||
"relay".to_string(),
|
||
"Relay".to_string(),
|
||
json!({ "config": next_config }),
|
||
None,
|
||
)),
|
||
false,
|
||
)
|
||
.expect("create second Grok Build provider");
|
||
|
||
switch_provider_test_hook(&state, AppType::GrokBuild, "relay")
|
||
.expect("switch Grok Build provider");
|
||
|
||
assert_eq!(
|
||
std::fs::read_to_string(&live_path).expect("read switched Grok Build config"),
|
||
next_config
|
||
);
|
||
assert_eq!(
|
||
state
|
||
.db
|
||
.get_current_provider(AppType::GrokBuild.as_str())
|
||
.expect("read Grok Build current provider")
|
||
.as_deref(),
|
||
Some("relay")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_startup_import_fresh_install_imports_once_and_syncs_current_setting() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let home = ensure_test_home();
|
||
|
||
let auth = json!({"OPENAI_API_KEY": "fresh-key"});
|
||
let config = r#"model = "gpt-5"
|
||
"#;
|
||
write_codex_live_atomic(&auth, Some(config)).expect("seed codex live config");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
|
||
assert!(
|
||
ProviderService::should_import_default_config_on_startup(&state, &AppType::Codex)
|
||
.expect("check startup import eligibility"),
|
||
"empty Codex provider set should import on startup"
|
||
);
|
||
|
||
import_default_config_test_hook(&state, AppType::Codex).expect("import codex default");
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers after import");
|
||
assert_eq!(
|
||
providers.len(),
|
||
1,
|
||
"fresh install import should create exactly one Codex provider before seeding"
|
||
);
|
||
assert!(
|
||
providers.contains_key("default"),
|
||
"fresh install import should create default provider"
|
||
);
|
||
|
||
let current_id = state
|
||
.db
|
||
.get_current_provider(AppType::Codex.as_str())
|
||
.expect("get codex current provider");
|
||
assert_eq!(current_id.as_deref(), Some("default"));
|
||
|
||
let settings: serde_json::Value = serde_json::from_str(
|
||
&std::fs::read_to_string(settings_path(home)).expect("read settings.json"),
|
||
)
|
||
.expect("parse settings.json");
|
||
assert_eq!(
|
||
settings
|
||
.get("currentProviderCodex")
|
||
.and_then(|value| value.as_str()),
|
||
Some("default"),
|
||
"live import should also sync device-local currentProviderCodex"
|
||
);
|
||
|
||
state
|
||
.db
|
||
.init_default_official_providers()
|
||
.expect("seed official providers");
|
||
let providers_after_seed = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers after seed");
|
||
assert_eq!(
|
||
providers_after_seed.len(),
|
||
2,
|
||
"official seeding should add codex-official alongside imported default"
|
||
);
|
||
assert!(providers_after_seed.contains_key("codex-official"));
|
||
|
||
assert!(
|
||
!ProviderService::should_import_default_config_on_startup(&state, &AppType::Codex)
|
||
.expect("re-check startup import eligibility"),
|
||
"subsequent startup should skip once Codex already has providers"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_startup_import_accepts_config_without_auth_file() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let config_path = get_codex_config_path();
|
||
if let Some(parent) = config_path.parent() {
|
||
std::fs::create_dir_all(parent).expect("create codex config dir");
|
||
}
|
||
std::fs::write(
|
||
&config_path,
|
||
r#"model_provider = "aihubmix"
|
||
|
||
[model_providers.aihubmix]
|
||
name = "AiHubMix"
|
||
base_url = "https://aihubmix.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
experimental_bearer_token = "live-key"
|
||
"#,
|
||
)
|
||
.expect("seed config.toml without auth.json");
|
||
assert!(
|
||
!get_codex_auth_path().exists(),
|
||
"test should not seed auth.json"
|
||
);
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
import_default_config_test_hook(&state, AppType::Codex)
|
||
.expect("import codex config-only default");
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers after import");
|
||
let provider = providers.get("default").expect("default provider exists");
|
||
assert_eq!(
|
||
provider.settings_config.pointer("/auth"),
|
||
Some(&json!({})),
|
||
"missing auth.json should import as an empty auth object"
|
||
);
|
||
assert!(
|
||
provider
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|value| value.as_str())
|
||
.unwrap_or_default()
|
||
.contains("experimental_bearer_token"),
|
||
"config.toml content should still be imported"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_startup_import_marks_oauth_only_default_official() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let auth = json!({
|
||
"auth_mode": "chatgpt",
|
||
"tokens": {
|
||
"id_token": "oauth-id",
|
||
"access_token": "oauth-access"
|
||
}
|
||
});
|
||
let config = r#"[mcp_servers.echo]
|
||
command = "echo"
|
||
"#;
|
||
write_codex_live_atomic(&auth, Some(config)).expect("seed oauth-only codex live config");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
import_default_config_test_hook(&state, AppType::Codex).expect("import codex default");
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers after import");
|
||
let provider = providers.get("default").expect("default provider exists");
|
||
|
||
assert_eq!(
|
||
provider.category.as_deref(),
|
||
Some("official"),
|
||
"OAuth-only live Codex installs should keep official behavior"
|
||
);
|
||
assert_eq!(
|
||
provider.settings_config.pointer("/auth/tokens/id_token"),
|
||
Some(&json!("oauth-id")),
|
||
"import should preserve OAuth login material"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_startup_import_skips_when_only_official_seed_exists() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let auth = json!({"OPENAI_API_KEY": "fresh-key"});
|
||
let config = r#"model = "gpt-5"
|
||
"#;
|
||
write_codex_live_atomic(&auth, Some(config)).expect("seed codex live config");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
state
|
||
.db
|
||
.init_default_official_providers()
|
||
.expect("seed official providers");
|
||
|
||
let providers_before = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers before restart check");
|
||
assert_eq!(
|
||
providers_before.len(),
|
||
1,
|
||
"fixture should start with only codex-official present"
|
||
);
|
||
assert!(providers_before.contains_key("codex-official"));
|
||
|
||
assert!(
|
||
!ProviderService::should_import_default_config_on_startup(&state, &AppType::Codex)
|
||
.expect("check startup import eligibility"),
|
||
"startup should skip import when codex-official already exists"
|
||
);
|
||
|
||
let providers_after = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers after restart check");
|
||
assert_eq!(
|
||
providers_after.len(),
|
||
providers_before.len(),
|
||
"skipping startup import should not grow the Codex provider set"
|
||
);
|
||
assert!(
|
||
!providers_after.contains_key("default"),
|
||
"restart path should not create a new default provider"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switch_provider_updates_codex_live_and_state() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
enable_codex_official_auth_preservation();
|
||
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 config = MultiAppConfig::default();
|
||
{
|
||
let manager = 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,
|
||
),
|
||
);
|
||
}
|
||
|
||
// v3.7.0+: 使用统一的 MCP 结构
|
||
config.mcp.servers = Some(HashMap::new());
|
||
config.mcp.servers.as_mut().unwrap().insert(
|
||
"echo-server".into(),
|
||
McpServer {
|
||
id: "echo-server".to_string(),
|
||
name: "Echo Server".to_string(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: true, // 启用 Codex
|
||
gemini: false,
|
||
grokbuild: false,
|
||
opencode: false,
|
||
hermes: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
);
|
||
|
||
let app_state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
switch_provider_test_hook(&app_state, AppType::Codex, "new-provider")
|
||
.expect("switch provider should succeed");
|
||
|
||
let auth_value: serde_json::Value =
|
||
read_json_file(&get_codex_auth_path()).expect("read auth.json");
|
||
assert_eq!(
|
||
auth_value
|
||
.get("OPENAI_API_KEY")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or(""),
|
||
"legacy-key",
|
||
"Codex provider switching should preserve the existing live auth.json"
|
||
);
|
||
|
||
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
||
assert!(
|
||
config_text.contains("mcp_servers.echo-server"),
|
||
"config.toml should contain synced MCP servers"
|
||
);
|
||
assert!(
|
||
config_text.contains("experimental_bearer_token"),
|
||
"config.toml should carry the selected provider API key as bearer token"
|
||
);
|
||
|
||
let current_id = app_state
|
||
.db
|
||
.get_current_provider(AppType::Codex.as_str())
|
||
.expect("get current provider");
|
||
assert_eq!(
|
||
current_id.as_deref(),
|
||
Some("new-provider"),
|
||
"current provider updated"
|
||
);
|
||
|
||
let providers = app_state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get all providers");
|
||
|
||
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();
|
||
// 供应商配置应该包含在 live 文件中
|
||
// 注意:live 文件还会包含 MCP 同步后的内容
|
||
assert!(
|
||
config_text.contains("mcp_servers.latest"),
|
||
"live file should contain provider's original config"
|
||
);
|
||
assert!(
|
||
new_config_text.contains("mcp_servers.latest"),
|
||
"provider snapshot should contain provider's original config"
|
||
);
|
||
|
||
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("");
|
||
// 回填机制:切换前会将 live 配置回填到当前供应商
|
||
// 这保护了用户在 live 文件中的手动修改
|
||
assert_eq!(
|
||
legacy_auth_value, "legacy-key",
|
||
"previous provider should be backfilled with live auth"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switch_provider_missing_provider_returns_error() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
config
|
||
.get_manager_mut(&AppType::Claude)
|
||
.expect("claude manager")
|
||
.current = "does-not-exist".to_string();
|
||
|
||
let app_state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
let err = switch_provider_test_hook(&app_state, AppType::Claude, "missing-provider")
|
||
.expect_err("switching to a missing provider should fail");
|
||
|
||
let err_str = err.to_string();
|
||
assert!(
|
||
err_str.contains("供应商不存在")
|
||
|| err_str.contains("Provider not found")
|
||
|| err_str.contains("missing-provider"),
|
||
"error message should mention missing provider, got: {err_str}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switch_provider_updates_claude_live_and_state() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let settings_path = cc_switch_lib::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 app_state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
switch_provider_test_hook(&app_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 current_id = app_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 providers = app_state
|
||
.db
|
||
.get_all_providers(AppType::Claude.as_str())
|
||
.expect("get all providers");
|
||
|
||
let legacy_provider = providers
|
||
.get("old-provider")
|
||
.expect("legacy provider still exists");
|
||
// 回填机制:切换前会将 live 配置回填到当前供应商
|
||
// 这保护了用户在 live 文件中的手动修改
|
||
assert_eq!(
|
||
legacy_provider.settings_config, legacy_live,
|
||
"previous provider should be backfilled with live config"
|
||
);
|
||
|
||
let new_provider = providers.get("new-provider").expect("new provider exists");
|
||
assert_eq!(
|
||
new_provider
|
||
.settings_config
|
||
.get("env")
|
||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||
.and_then(|key| key.as_str()),
|
||
Some("fresh-key"),
|
||
"new provider snapshot should retain fresh auth"
|
||
);
|
||
|
||
// v3.7.0+ 使用 SQLite 数据库而非 config.json
|
||
// 验证数据已持久化到数据库
|
||
let home_dir = std::env::var("HOME").expect("HOME should be set by ensure_test_home");
|
||
let db_path = std::path::Path::new(&home_dir)
|
||
.join(".cc-switch")
|
||
.join("cc-switch.db");
|
||
assert!(
|
||
db_path.exists(),
|
||
"switching provider should persist to cc-switch.db"
|
||
);
|
||
|
||
// 验证当前供应商已更新
|
||
let current_id = app_state
|
||
.db
|
||
.get_current_provider(AppType::Claude.as_str())
|
||
.expect("get current provider");
|
||
assert_eq!(
|
||
current_id.as_deref(),
|
||
Some("new-provider"),
|
||
"database should record the new current provider"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
|
||
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 app_state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
let err = switch_provider_test_hook(&app_state, AppType::Codex, "invalid")
|
||
.expect_err("switching should fail when auth missing");
|
||
match err {
|
||
AppError::Config(msg) => assert!(
|
||
msg.contains("auth"),
|
||
"expected auth missing error message, got {msg}"
|
||
),
|
||
other => panic!("expected config error, got {other:?}"),
|
||
}
|
||
|
||
let current_id = app_state
|
||
.db
|
||
.get_current_provider(AppType::Codex.as_str())
|
||
.expect("get current provider");
|
||
// 切换失败后,由于数据库操作是先设置再验证,current 可能已被设为 "invalid"
|
||
// 但由于 live 配置写入失败,状态应该回滚
|
||
// 注意:这个行为取决于 switch_provider 的具体实现
|
||
assert!(
|
||
current_id.is_none() || current_id.as_deref() == Some("invalid"),
|
||
"current provider should remain empty or be the attempted id on failure, got: {current_id:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn import_refuses_live_config_under_proxy_takeover() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
ensure_test_home();
|
||
|
||
// 接管态 Codex Live:auth 是 PROXY_MANAGED 占位符,不是用户真实配置
|
||
let auth = json!({"OPENAI_API_KEY": "PROXY_MANAGED"});
|
||
let config = r#"model = "gpt-5"
|
||
"#;
|
||
write_codex_live_atomic(&auth, Some(config)).expect("seed taken-over codex live");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
|
||
import_default_config_test_hook(&state, AppType::Codex)
|
||
.expect_err("importing a taken-over live config must fail");
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("get codex providers");
|
||
assert!(
|
||
providers.is_empty(),
|
||
"taken-over live import must not create providers"
|
||
);
|
||
}
|