2948 lines
99 KiB
Rust
2948 lines
99 KiB
Rust
use serde_json::json;
|
||
|
||
use cc_switch_lib::{
|
||
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,
|
||
McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
|
||
};
|
||
|
||
#[path = "support.rs"]
|
||
mod support;
|
||
use support::{
|
||
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
|
||
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 migrate_legacy_common_config_usage_marks_historical_provider_enabled() {
|
||
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 = "legacy-provider".to_string();
|
||
manager.providers.insert(
|
||
"legacy-provider".to_string(),
|
||
Provider::with_id(
|
||
"legacy-provider".to_string(),
|
||
"Legacy".to_string(),
|
||
json!({
|
||
"includeCoAuthoredBy": false,
|
||
"env": {
|
||
"ANTHROPIC_API_KEY": "legacy-key"
|
||
}
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
state
|
||
.db
|
||
.set_config_snippet(
|
||
AppType::Claude.as_str(),
|
||
Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()),
|
||
)
|
||
.expect("set common config snippet");
|
||
|
||
ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude)
|
||
.expect("migrate legacy common config");
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Claude.as_str())
|
||
.expect("get providers after migration");
|
||
let provider = providers
|
||
.get("legacy-provider")
|
||
.expect("legacy provider exists");
|
||
|
||
assert_eq!(
|
||
provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.common_config_enabled),
|
||
Some(true),
|
||
"historical provider should be explicitly marked as using common config"
|
||
);
|
||
assert!(
|
||
provider
|
||
.settings_config
|
||
.get("includeCoAuthoredBy")
|
||
.is_none(),
|
||
"common config fields should be stripped from provider storage after migration"
|
||
);
|
||
assert_eq!(
|
||
provider
|
||
.settings_config
|
||
.get("env")
|
||
.and_then(|v| v.get("ANTHROPIC_API_KEY"))
|
||
.and_then(|v| v.as_str()),
|
||
Some("legacy-key"),
|
||
"provider-specific auth should remain untouched"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_switch_codex_updates_live_and_config() {
|
||
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 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,
|
||
),
|
||
);
|
||
}
|
||
|
||
// 使用新的统一 MCP 结构(v3.7.0+)
|
||
let servers = initial_config
|
||
.mcp
|
||
.servers
|
||
.get_or_insert_with(Default::default);
|
||
servers.insert(
|
||
"echo-server".into(),
|
||
McpServer {
|
||
id: "echo-server".into(),
|
||
name: "Echo Server".into(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: true,
|
||
gemini: false,
|
||
opencode: false,
|
||
hermes: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
);
|
||
|
||
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("legacy-key"),
|
||
"Codex provider switching should preserve the existing live auth.json"
|
||
);
|
||
|
||
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"
|
||
);
|
||
assert!(
|
||
config_text.contains("experimental_bearer_token"),
|
||
"config.toml should carry the selected provider API key"
|
||
);
|
||
|
||
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();
|
||
// provider 存储的是原始配置,不包含 MCP 同步后的内容
|
||
assert!(
|
||
new_config_text.contains("mcp_servers.latest"),
|
||
"provider config should contain original MCP servers"
|
||
);
|
||
// live 文件额外包含同步的 MCP 服务器
|
||
assert!(
|
||
config_text.contains("mcp_servers.echo-server"),
|
||
"live config should include synced MCP servers"
|
||
);
|
||
|
||
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 provider_service_switch_codex_preserves_user_model_provider_id_after_migration() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" });
|
||
let legacy_config = r#"model_provider = "rightcode"
|
||
model = "gpt-5.4"
|
||
|
||
[model_providers.rightcode]
|
||
name = "RightCode"
|
||
base_url = "https://rightcode.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#;
|
||
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(),
|
||
"RightCode".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "stale"},
|
||
"config": legacy_config
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
manager.providers.insert(
|
||
"new-provider".to_string(),
|
||
Provider::with_id(
|
||
"new-provider".to_string(),
|
||
"AiHubMix".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "fresh-key"},
|
||
"config": r#"model_provider = "aihubmix"
|
||
model = "gpt-5.4"
|
||
|
||
[model_providers.aihubmix]
|
||
name = "AiHubMix"
|
||
base_url = "https://aihubmix.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
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 config_text =
|
||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||
let parsed: toml::Value = toml::from_str(&config_text).expect("parse config.toml");
|
||
|
||
assert_eq!(
|
||
parsed.get("model_provider").and_then(|v| v.as_str()),
|
||
Some("aihubmix"),
|
||
"provider switching should preserve user-editable model_provider after the one-time migration"
|
||
);
|
||
|
||
let model_providers = parsed
|
||
.get("model_providers")
|
||
.and_then(|v| v.as_table())
|
||
.expect("model_providers table exists");
|
||
assert!(
|
||
model_providers.get("custom").is_none(),
|
||
"provider switching should not force user-edited provider ids back to custom"
|
||
);
|
||
assert_eq!(
|
||
model_providers
|
||
.get("aihubmix")
|
||
.and_then(|v| v.get("base_url"))
|
||
.and_then(|v| v.as_str()),
|
||
Some("https://aihubmix.example/v1"),
|
||
"selected provider id should point at the newly selected supplier endpoint"
|
||
);
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("read providers after switch");
|
||
let new_config_text = providers
|
||
.get("new-provider")
|
||
.expect("new provider exists")
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or_default();
|
||
assert!(
|
||
new_config_text.contains("[model_providers.aihubmix]"),
|
||
"stored provider template should remain provider-specific"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
enable_codex_official_auth_preservation();
|
||
let _home = ensure_test_home();
|
||
|
||
let live_auth = json!({
|
||
"auth_mode": "chatgpt",
|
||
"OPENAI_API_KEY": null,
|
||
"tokens": {
|
||
"access_token": "oauth-token",
|
||
"account_id": "acct-1"
|
||
}
|
||
});
|
||
let legacy_config = r#"model_provider = "rightcode"
|
||
model = "gpt-5.4"
|
||
|
||
[model_providers.rightcode]
|
||
name = "RightCode"
|
||
base_url = "https://rightcode.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#;
|
||
write_codex_live_atomic(&live_auth, Some(legacy_config))
|
||
.expect("seed existing Codex OAuth live config");
|
||
|
||
let bridge_provider = Provider::with_id(
|
||
"bridge-provider".to_string(),
|
||
"Bridge Provider".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "bridge-key"},
|
||
"config": r#"model_provider = "aihubmix"
|
||
model = "gpt-5.4"
|
||
|
||
[model_providers.aihubmix]
|
||
name = "AiHubMix"
|
||
base_url = "https://aihubmix.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#
|
||
}),
|
||
None,
|
||
);
|
||
|
||
let mut initial_config = MultiAppConfig::default();
|
||
{
|
||
let manager = initial_config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.current = "legacy-provider".to_string();
|
||
manager.providers.insert(
|
||
"legacy-provider".to_string(),
|
||
Provider::with_id(
|
||
"legacy-provider".to_string(),
|
||
"RightCode".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "rightcode-key"},
|
||
"config": legacy_config
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
manager
|
||
.providers
|
||
.insert("bridge-provider".to_string(), bridge_provider);
|
||
manager.providers.insert(
|
||
"plain-provider".to_string(),
|
||
Provider::with_id(
|
||
"plain-provider".to_string(),
|
||
"Plain Provider".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "plain-key"},
|
||
"config": r#"model_provider = "plain"
|
||
model = "gpt-5.4"
|
||
|
||
[model_providers.plain]
|
||
name = "Plain"
|
||
base_url = "https://plain.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "bridge-provider")
|
||
.expect("switch to bridge 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("auth_mode").and_then(|v| v.as_str()),
|
||
Some("chatgpt")
|
||
);
|
||
assert!(
|
||
auth_value
|
||
.get("OPENAI_API_KEY")
|
||
.is_some_and(|v| v.is_null()),
|
||
"provider switching should keep OPENAI_API_KEY null in live auth.json"
|
||
);
|
||
assert_eq!(
|
||
auth_value
|
||
.pointer("/tokens/access_token")
|
||
.and_then(|v| v.as_str()),
|
||
Some("oauth-token"),
|
||
"existing ChatGPT OAuth token should be preserved"
|
||
);
|
||
|
||
let live_config =
|
||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||
let parsed_live: toml::Value = toml::from_str(&live_config).expect("parse live config");
|
||
assert_eq!(
|
||
parsed_live
|
||
.get("model_providers")
|
||
.and_then(|v| v.get("aihubmix"))
|
||
.and_then(|v| v.get("experimental_bearer_token"))
|
||
.and_then(|v| v.as_str()),
|
||
Some("bridge-key"),
|
||
"third-party key should be injected into the selected live provider table"
|
||
);
|
||
assert_eq!(
|
||
parsed_live
|
||
.get("model_providers")
|
||
.and_then(|v| v.get("aihubmix"))
|
||
.and_then(|v| v.get("requires_openai_auth"))
|
||
.and_then(|v| v.as_bool()),
|
||
Some(true)
|
||
);
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "plain-provider")
|
||
.expect("switch away should backfill bridge provider");
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("read providers");
|
||
let stored_bridge = providers
|
||
.get("bridge-provider")
|
||
.expect("bridge provider exists after backfill");
|
||
assert_eq!(
|
||
stored_bridge
|
||
.settings_config
|
||
.pointer("/auth/OPENAI_API_KEY")
|
||
.and_then(|v| v.as_str()),
|
||
Some("bridge-key"),
|
||
"backfill should restore the API key into stored provider auth"
|
||
);
|
||
assert!(
|
||
stored_bridge
|
||
.settings_config
|
||
.pointer("/auth/tokens")
|
||
.is_none(),
|
||
"backfill should not persist ChatGPT OAuth tokens into provider storage"
|
||
);
|
||
assert!(
|
||
!stored_bridge
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or_default()
|
||
.contains("experimental_bearer_token"),
|
||
"stored provider config should stay clean; bridge token is generated only for live config"
|
||
);
|
||
}
|
||
|
||
#[tokio::test(flavor = "current_thread")]
|
||
#[allow(
|
||
clippy::await_holding_lock,
|
||
reason = "this integration-style test must serialize global test HOME and settings mutations across async takeover calls"
|
||
)]
|
||
async fn codex_official_to_deepseek_then_takeover_enters_and_restores_proxy_managed_live_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
enable_codex_official_auth_preservation();
|
||
let _home = ensure_test_home();
|
||
|
||
let oauth_auth = json!({
|
||
"auth_mode": "chatgpt",
|
||
"tokens": {
|
||
"access_token": "oauth-access",
|
||
"id_token": "oauth-id"
|
||
}
|
||
});
|
||
let official_config = r#"model_provider = "openai"
|
||
model = "gpt-5"
|
||
|
||
[model_providers.openai]
|
||
name = "OpenAI"
|
||
wire_api = "responses"
|
||
"#;
|
||
write_codex_live_atomic(&oauth_auth, Some(official_config))
|
||
.expect("seed official Codex OAuth live config");
|
||
|
||
let deepseek_provider_config = r#"model_provider = "deepseek"
|
||
model = "deepseek-chat"
|
||
|
||
[model_providers.deepseek]
|
||
name = "DeepSeek"
|
||
base_url = "https://api.deepseek.com/v1"
|
||
wire_api = "responses"
|
||
"#;
|
||
let mut initial_config = MultiAppConfig::default();
|
||
{
|
||
let manager = initial_config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.current = "official-provider".to_string();
|
||
|
||
let mut official_provider = Provider::with_id(
|
||
"official-provider".to_string(),
|
||
"OpenAI Official".to_string(),
|
||
json!({
|
||
"auth": oauth_auth,
|
||
"config": official_config
|
||
}),
|
||
None,
|
||
);
|
||
official_provider.category = Some("official".to_string());
|
||
manager
|
||
.providers
|
||
.insert("official-provider".to_string(), official_provider);
|
||
|
||
let mut deepseek_provider = Provider::with_id(
|
||
"deepseek-provider".to_string(),
|
||
"DeepSeek".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "deepseek-key"},
|
||
"config": deepseek_provider_config
|
||
}),
|
||
None,
|
||
);
|
||
deepseek_provider.category = Some("custom".to_string());
|
||
manager
|
||
.providers
|
||
.insert("deepseek-provider".to_string(), deepseek_provider);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||
|
||
let mut proxy_config = state.db.get_proxy_config().await.expect("get proxy config");
|
||
proxy_config.listen_port = 0;
|
||
state
|
||
.db
|
||
.update_proxy_config(proxy_config)
|
||
.await
|
||
.expect("use ephemeral proxy port");
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "deepseek-provider")
|
||
.expect("switch from official subscription to DeepSeek");
|
||
|
||
let auth_after_switch: serde_json::Value =
|
||
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after switch");
|
||
assert_eq!(
|
||
auth_after_switch, oauth_auth,
|
||
"normal provider switch with Codex preservation enabled must keep OAuth auth.json"
|
||
);
|
||
|
||
let config_after_switch =
|
||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config");
|
||
assert!(
|
||
config_after_switch.contains("https://api.deepseek.com/v1"),
|
||
"normal switch should write the DeepSeek endpoint before takeover"
|
||
);
|
||
assert!(
|
||
config_after_switch.contains("deepseek-key"),
|
||
"normal switch should inject the DeepSeek key into config.toml"
|
||
);
|
||
|
||
state
|
||
.proxy_service
|
||
.set_takeover_for_app("codex", true)
|
||
.await
|
||
.expect("enable Codex takeover");
|
||
let proxy_status = state
|
||
.proxy_service
|
||
.get_status()
|
||
.await
|
||
.expect("read proxy status after takeover");
|
||
let codex_proxy_base_url = format!("http://127.0.0.1:{}/v1", proxy_status.port);
|
||
|
||
let auth_after_takeover: serde_json::Value =
|
||
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after takeover");
|
||
assert_eq!(
|
||
auth_after_takeover, oauth_auth,
|
||
"enabling takeover must not rewrite Codex OAuth auth.json"
|
||
);
|
||
|
||
let config_after_takeover =
|
||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config");
|
||
assert!(
|
||
config_after_takeover.contains(&codex_proxy_base_url),
|
||
"enabling takeover should point Codex config.toml at the local proxy"
|
||
);
|
||
assert!(
|
||
config_after_takeover.contains("PROXY_MANAGED"),
|
||
"enabling takeover should move the proxy placeholder into config.toml"
|
||
);
|
||
assert!(
|
||
!config_after_takeover.contains("https://api.deepseek.com/v1"),
|
||
"takeover live config should not keep the upstream DeepSeek endpoint"
|
||
);
|
||
|
||
let backup = state
|
||
.db
|
||
.get_live_backup("codex")
|
||
.await
|
||
.expect("read Codex backup")
|
||
.expect("backup exists after takeover");
|
||
let backup_value: serde_json::Value =
|
||
serde_json::from_str(&backup.original_config).expect("parse backup");
|
||
let backup_config = backup_value
|
||
.get("config")
|
||
.and_then(|value| value.as_str())
|
||
.unwrap_or_default();
|
||
assert!(
|
||
backup_config.contains("https://api.deepseek.com/v1")
|
||
&& backup_config.contains("deepseek-key"),
|
||
"takeover backup should remain the restorable DeepSeek config"
|
||
);
|
||
|
||
state
|
||
.proxy_service
|
||
.set_takeover_for_app("codex", false)
|
||
.await
|
||
.expect("disable Codex takeover");
|
||
|
||
let restored_auth: serde_json::Value =
|
||
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read restored auth");
|
||
assert_eq!(
|
||
restored_auth, oauth_auth,
|
||
"disabling takeover should restore without replacing OAuth auth.json"
|
||
);
|
||
|
||
let restored_config = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
|
||
.expect("read restored config");
|
||
assert!(
|
||
restored_config.contains("https://api.deepseek.com/v1")
|
||
&& restored_config.contains("deepseek-key"),
|
||
"disabling takeover should restore the selected DeepSeek live config"
|
||
);
|
||
assert!(
|
||
!restored_config.contains("PROXY_MANAGED"),
|
||
"restored live config must not keep the proxy placeholder"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_switch_codex_default_overwrites_official_auth_when_preservation_off() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
// Intentionally do NOT enable preservation: this locks the default opt-out
|
||
// behavior where switching to a third-party provider rewrites auth.json,
|
||
// discarding the user's ChatGPT OAuth login. It is the dual of
|
||
// `provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token`.
|
||
let _home = ensure_test_home();
|
||
|
||
let live_auth = json!({
|
||
"auth_mode": "chatgpt",
|
||
"OPENAI_API_KEY": null,
|
||
"tokens": {
|
||
"access_token": "official-oauth-token",
|
||
"account_id": "acct-1"
|
||
}
|
||
});
|
||
let legacy_config = r#"model_provider = "rightcode"
|
||
model = "gpt-5.4"
|
||
|
||
[model_providers.rightcode]
|
||
name = "RightCode"
|
||
base_url = "https://rightcode.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#;
|
||
write_codex_live_atomic(&live_auth, Some(legacy_config))
|
||
.expect("seed existing Codex OAuth live config");
|
||
|
||
let mut initial_config = MultiAppConfig::default();
|
||
{
|
||
let manager = initial_config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.current = "legacy-provider".to_string();
|
||
manager.providers.insert(
|
||
"legacy-provider".to_string(),
|
||
Provider::with_id(
|
||
"legacy-provider".to_string(),
|
||
"RightCode".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "rightcode-key"},
|
||
"config": legacy_config
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
manager.providers.insert(
|
||
"third-party".to_string(),
|
||
Provider::with_id(
|
||
"third-party".to_string(),
|
||
"AiHubMix".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "third-party-key"},
|
||
"config": r#"model_provider = "aihubmix"
|
||
model = "gpt-5.4"
|
||
|
||
[model_providers.aihubmix]
|
||
name = "AiHubMix"
|
||
base_url = "https://aihubmix.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "third-party")
|
||
.expect("switch to third-party 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("third-party-key"),
|
||
"default (preservation off) should overwrite auth.json with the third-party API key"
|
||
);
|
||
assert!(
|
||
auth_value.pointer("/tokens/access_token").is_none(),
|
||
"default switch must clear the official ChatGPT OAuth token from live auth.json"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_switch_codex_supports_official_login_provider_without_auth_write() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let live_auth = json!({
|
||
"auth_mode": "chatgpt",
|
||
"OPENAI_API_KEY": null,
|
||
"tokens": {
|
||
"access_token": "official-oauth-token",
|
||
"account_id": "acct-official"
|
||
}
|
||
});
|
||
write_codex_live_atomic(&live_auth, Some("")).expect("seed official OAuth live config");
|
||
|
||
let mut initial_config = MultiAppConfig::default();
|
||
{
|
||
let manager = initial_config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.current = "legacy-provider".to_string();
|
||
manager.providers.insert(
|
||
"legacy-provider".to_string(),
|
||
Provider::with_id(
|
||
"legacy-provider".to_string(),
|
||
"Legacy".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "legacy-key"},
|
||
"config": r#"model_provider = "legacy"
|
||
|
||
[model_providers.legacy]
|
||
name = "Legacy"
|
||
base_url = "https://legacy.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
let mut official_provider = Provider::with_id(
|
||
"official-provider".to_string(),
|
||
"OpenAI Official".to_string(),
|
||
json!({
|
||
"auth": {},
|
||
"config": ""
|
||
}),
|
||
None,
|
||
);
|
||
official_provider.category = Some("official".to_string());
|
||
manager
|
||
.providers
|
||
.insert("official-provider".to_string(), official_provider);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "official-provider")
|
||
.expect("switch to official provider should succeed without API key");
|
||
|
||
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("auth_mode").and_then(|v| v.as_str()),
|
||
Some("chatgpt")
|
||
);
|
||
assert!(
|
||
auth_value
|
||
.get("OPENAI_API_KEY")
|
||
.is_some_and(|v| v.is_null()),
|
||
"official provider switching should keep OPENAI_API_KEY null"
|
||
);
|
||
assert_eq!(
|
||
auth_value
|
||
.pointer("/tokens/access_token")
|
||
.and_then(|v| v.as_str()),
|
||
Some("official-oauth-token"),
|
||
"official provider should preserve the existing ChatGPT OAuth token"
|
||
);
|
||
|
||
let live_config =
|
||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||
assert!(
|
||
!live_config.contains("experimental_bearer_token"),
|
||
"official login provider has no API key to inject"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reapply_codex_official_live_resyncs_mcp_servers() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let live_auth = json!({
|
||
"auth_mode": "chatgpt",
|
||
"OPENAI_API_KEY": null,
|
||
"tokens": { "access_token": "official-oauth-token", "account_id": "acct" }
|
||
});
|
||
write_codex_live_atomic(&live_auth, Some("")).expect("seed official live auth");
|
||
|
||
let mut initial_config = MultiAppConfig::default();
|
||
{
|
||
let manager = initial_config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
let mut official = Provider::with_id(
|
||
"official-provider".to_string(),
|
||
"Official".to_string(),
|
||
json!({
|
||
"auth": {
|
||
"auth_mode": "chatgpt",
|
||
"OPENAI_API_KEY": null,
|
||
"tokens": { "access_token": "official-oauth-token", "account_id": "acct" }
|
||
},
|
||
"config": ""
|
||
}),
|
||
None,
|
||
);
|
||
official.category = Some("official".to_string());
|
||
manager
|
||
.providers
|
||
.insert("official-provider".to_string(), official);
|
||
}
|
||
let servers = initial_config
|
||
.mcp
|
||
.servers
|
||
.get_or_insert_with(Default::default);
|
||
servers.insert(
|
||
"echo-server".into(),
|
||
McpServer {
|
||
id: "echo-server".into(),
|
||
name: "Echo Server".into(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: true,
|
||
gemini: false,
|
||
opencode: false,
|
||
hermes: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
);
|
||
|
||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "official-provider")
|
||
.expect("switch to official provider");
|
||
let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
|
||
.expect("read config.toml after switch");
|
||
assert!(
|
||
live.contains("mcp_servers.echo-server"),
|
||
"switch should sync enabled MCP servers into live"
|
||
);
|
||
|
||
// 统一会话开关变更触发的 reapply 会整体重写 live config.toml(有意设计),
|
||
// 写完必须重新投影 DB 里启用的 MCP,否则用户的 MCP 会静默失效。
|
||
let reapplied =
|
||
cc_switch_lib::reapply_current_codex_official_live(&state).expect("reapply official live");
|
||
assert!(
|
||
reapplied,
|
||
"current provider is official, reapply should run"
|
||
);
|
||
|
||
let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
|
||
.expect("read config.toml after reapply");
|
||
assert!(
|
||
live.contains("mcp_servers.echo-server"),
|
||
"reapply must re-project enabled MCP servers after the full live rewrite, got: {live}"
|
||
);
|
||
}
|
||
|
||
/// reapply 走到 MCP 投影时 live 已按新开关状态落盘、开关事实上已生效:
|
||
/// ① 投影失败若上抛,save_settings 会回滚开关设置,制造"设置=旧值、
|
||
/// live=新桶"的会话分裂——必须降级为警告而不是失败;
|
||
/// ② 投影必须只针对 Codex:sync_all_enabled 按 AppType::all() 顺序短路,
|
||
/// Claude 排在 Codex 前面,损坏的 ~/.claude.json 会在轮到 Codex 之前
|
||
/// 报错——若吞错了事,刚被整体重写清掉的 [mcp_servers] 就无人补回,
|
||
/// Codex MCP 静默消失。这里用坏 JSON 的 ~/.claude.json 复现该场景。
|
||
#[test]
|
||
fn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let live_auth = json!({
|
||
"auth_mode": "chatgpt",
|
||
"OPENAI_API_KEY": null,
|
||
"tokens": { "access_token": "official-oauth-token", "account_id": "acct" }
|
||
});
|
||
write_codex_live_atomic(&live_auth, Some("")).expect("seed official live auth");
|
||
|
||
let mut initial_config = MultiAppConfig::default();
|
||
{
|
||
let manager = initial_config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
let mut official = Provider::with_id(
|
||
"official-provider".to_string(),
|
||
"Official".to_string(),
|
||
json!({
|
||
"auth": {
|
||
"auth_mode": "chatgpt",
|
||
"OPENAI_API_KEY": null,
|
||
"tokens": { "access_token": "official-oauth-token", "account_id": "acct" }
|
||
},
|
||
"config": ""
|
||
}),
|
||
None,
|
||
);
|
||
official.category = Some("official".to_string());
|
||
manager
|
||
.providers
|
||
.insert("official-provider".to_string(), official);
|
||
}
|
||
let servers = initial_config
|
||
.mcp
|
||
.servers
|
||
.get_or_insert_with(Default::default);
|
||
servers.insert(
|
||
"echo-server".into(),
|
||
McpServer {
|
||
id: "echo-server".into(),
|
||
name: "Echo Server".into(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: true,
|
||
gemini: false,
|
||
opencode: false,
|
||
hermes: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
);
|
||
|
||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||
|
||
// 先切换建立"当前=官方"的前置状态,再破坏 claude 文件,
|
||
// 让损坏只作用于被测的 reapply 路径。
|
||
ProviderService::switch(&state, AppType::Codex, "official-provider")
|
||
.expect("switch to official provider");
|
||
|
||
// 破坏 ~/.claude.json:坏 JSON 能通过 should_sync_claude_mcp 门控
|
||
// (文件存在即过),但 read_mcp_servers_map 解析必然报错。
|
||
// 注意 codex-only 的服务器也会触发 claude 的 remove 分支读该文件。
|
||
let claude_json = cc_switch_lib::get_claude_mcp_path();
|
||
std::fs::write(&claude_json, "{ not valid json").expect("seed broken claude json");
|
||
|
||
let reapplied = cc_switch_lib::reapply_current_codex_official_live(&state)
|
||
.expect("MCP projection failure must degrade to a warning, not fail the toggle");
|
||
assert!(
|
||
reapplied,
|
||
"current provider is official, reapply should run"
|
||
);
|
||
|
||
// 定向投影不碰 claude:Codex 的 MCP 投影必须完成,不能被
|
||
// 无关应用的损坏文件阻断后静默丢失。
|
||
let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
|
||
.expect("read config.toml after reapply");
|
||
assert!(
|
||
live.contains("mcp_servers.echo-server"),
|
||
"codex MCP projection must not be blocked by a broken ~/.claude.json, got: {live}"
|
||
);
|
||
|
||
// 顺带锁定:定向投影不应把手改坏的 ~/.claude.json 触碰或"修复"。
|
||
let claude_after = std::fs::read_to_string(&claude_json).expect("read claude json");
|
||
assert_eq!(
|
||
claude_after, "{ not valid json",
|
||
"codex-only projection must not touch claude's live file"
|
||
);
|
||
}
|
||
|
||
/// 切换供应商与 reapply 是同一类场景:live 整体重写后只需重投影本应用
|
||
/// 的 MCP。历史实现走全量 sync_all_enabled + `?`,损坏的 ~/.claude.json
|
||
/// 会让"切 Codex"直接报切换失败——而此时 DB is_current 与 live 都已
|
||
/// 落盘,切换事实上成功,报错只制造分裂假象。
|
||
#[test]
|
||
fn switch_codex_projects_mcp_despite_broken_claude_json() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk-old" }), Some(""))
|
||
.expect("seed codex live");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.providers.insert(
|
||
"p".to_string(),
|
||
Provider::with_id(
|
||
"p".to_string(),
|
||
"P".to_string(),
|
||
json!({
|
||
"auth": { "OPENAI_API_KEY": "sk-p" },
|
||
"config": "model = \"gpt-5.5\"\n"
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
let servers = config.mcp.servers.get_or_insert_with(Default::default);
|
||
servers.insert(
|
||
"echo-server".into(),
|
||
McpServer {
|
||
id: "echo-server".into(),
|
||
name: "Echo Server".into(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: true,
|
||
gemini: false,
|
||
opencode: false,
|
||
hermes: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
);
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
// 坏 JSON 能通过 should_sync_claude_mcp 门控(文件存在即过),
|
||
// 但 read_mcp_servers_map 解析必然报错;codex-only 服务器也会
|
||
// 触发 claude 的 remove 分支去读这个文件。
|
||
let claude_json = cc_switch_lib::get_claude_mcp_path();
|
||
std::fs::write(&claude_json, "{ not valid json").expect("seed broken claude json");
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "p")
|
||
.expect("broken ~/.claude.json must not fail an unrelated codex switch");
|
||
|
||
let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
|
||
.expect("read config.toml after switch");
|
||
assert!(
|
||
live.contains("mcp_servers.echo-server"),
|
||
"switch must re-project codex MCP after the full live rewrite, got: {live}"
|
||
);
|
||
|
||
let claude_after = std::fs::read_to_string(&claude_json).expect("read claude json");
|
||
assert_eq!(
|
||
claude_after, "{ not valid json",
|
||
"codex-only projection must not touch claude's live file"
|
||
);
|
||
}
|
||
|
||
/// sync_all_enabled 的全量语义(配置导入 / 云同步恢复):单个应用的
|
||
/// live 损坏不阻断其余应用的投影,但失败必须聚合上报——调用方需要
|
||
/// 知道结果不完整。历史实现按 AppType::all() 顺序 `?` 短路,Claude
|
||
/// 排在 Codex 前面,一份坏 ~/.claude.json 会让所有后续应用的 MCP
|
||
/// 状态永远陈旧。
|
||
#[test]
|
||
fn sync_all_enabled_reports_broken_app_but_projects_the_rest() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk" }), Some("")).expect("seed codex live");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
let servers = config.mcp.servers.get_or_insert_with(Default::default);
|
||
servers.insert(
|
||
"echo-server".into(),
|
||
McpServer {
|
||
id: "echo-server".into(),
|
||
name: "Echo Server".into(),
|
||
server: json!({
|
||
"type": "stdio",
|
||
"command": "echo"
|
||
}),
|
||
apps: McpApps {
|
||
claude: false,
|
||
codex: true,
|
||
gemini: false,
|
||
opencode: false,
|
||
hermes: false,
|
||
},
|
||
description: None,
|
||
homepage: None,
|
||
docs: None,
|
||
tags: Vec::new(),
|
||
},
|
||
);
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
|
||
let claude_json = cc_switch_lib::get_claude_mcp_path();
|
||
std::fs::write(&claude_json, "{ not valid json").expect("seed broken claude json");
|
||
|
||
let err = cc_switch_lib::McpService::sync_all_enabled(&state)
|
||
.expect_err("broken claude live must surface as an aggregated error");
|
||
let message = err.to_string();
|
||
assert!(
|
||
message.contains("claude"),
|
||
"aggregated error should name the failing app, got: {message}"
|
||
);
|
||
|
||
// Claude 的失败不能阻断 Codex:best-effort 必须继续投影其余应用。
|
||
let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
|
||
.expect("read config.toml after sync_all_enabled");
|
||
assert!(
|
||
live.contains("mcp_servers.echo-server"),
|
||
"codex projection must proceed despite the broken claude file, got: {live}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_switch_codex_official_accounts_write_auth_json() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let live_auth_a = json!({
|
||
"auth_mode": "chatgpt",
|
||
"OPENAI_API_KEY": null,
|
||
"tokens": {
|
||
"access_token": "official-a-live-token",
|
||
"account_id": "acct-a"
|
||
}
|
||
});
|
||
write_codex_live_atomic(&live_auth_a, Some("")).expect("seed official account A live auth");
|
||
|
||
let mut official_a = Provider::with_id(
|
||
"official-a".to_string(),
|
||
"Official A".to_string(),
|
||
json!({
|
||
"auth": {
|
||
"auth_mode": "chatgpt",
|
||
"OPENAI_API_KEY": null,
|
||
"tokens": {
|
||
"access_token": "stale-a-token",
|
||
"account_id": "acct-a"
|
||
}
|
||
},
|
||
"config": ""
|
||
}),
|
||
None,
|
||
);
|
||
official_a.category = Some("official".to_string());
|
||
|
||
let mut official_b = Provider::with_id(
|
||
"official-b".to_string(),
|
||
"Official B".to_string(),
|
||
json!({
|
||
"auth": {
|
||
"auth_mode": "chatgpt",
|
||
"OPENAI_API_KEY": null,
|
||
"tokens": {
|
||
"access_token": "official-b-token",
|
||
"account_id": "acct-b"
|
||
}
|
||
},
|
||
"config": ""
|
||
}),
|
||
None,
|
||
);
|
||
official_b.category = Some("official".to_string());
|
||
|
||
let mut initial_config = MultiAppConfig::default();
|
||
{
|
||
let manager = initial_config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.current = "official-a".to_string();
|
||
manager
|
||
.providers
|
||
.insert("official-a".to_string(), official_a);
|
||
manager
|
||
.providers
|
||
.insert("official-b".to_string(), official_b);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "official-b")
|
||
.expect("switch to official account B should write auth.json");
|
||
let auth_b: serde_json::Value =
|
||
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth B");
|
||
assert_eq!(
|
||
auth_b
|
||
.pointer("/tokens/access_token")
|
||
.and_then(|v| v.as_str()),
|
||
Some("official-b-token"),
|
||
"switching official accounts must replace auth.json with the selected account"
|
||
);
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "official-a")
|
||
.expect("switch back to official account A should use backfilled live auth");
|
||
let auth_a: serde_json::Value =
|
||
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth A");
|
||
assert_eq!(
|
||
auth_a
|
||
.pointer("/tokens/access_token")
|
||
.and_then(|v| v.as_str()),
|
||
Some("official-a-live-token"),
|
||
"backfill should preserve account A's latest live token for later official switches"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider_id() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" });
|
||
let provider_a_config = r#"model_provider = "rightcode"
|
||
model = "gpt-5.4"
|
||
|
||
[model_providers.rightcode]
|
||
name = "RightCode"
|
||
base_url = "https://rightcode.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#;
|
||
write_codex_live_atomic(&legacy_auth, Some(provider_a_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 = "provider-a".to_string();
|
||
manager.providers.insert(
|
||
"provider-a".to_string(),
|
||
Provider::with_id(
|
||
"provider-a".to_string(),
|
||
"RightCode".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "rightcode-key"},
|
||
"config": provider_a_config
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
manager.providers.insert(
|
||
"provider-b".to_string(),
|
||
Provider::with_id(
|
||
"provider-b".to_string(),
|
||
"AiHubMix".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "aihubmix-key"},
|
||
"config": r#"model_provider = "aihubmix"
|
||
model = "gpt-5.4"
|
||
profile = "work"
|
||
|
||
[model_providers.aihubmix]
|
||
name = "AiHubMix"
|
||
base_url = "https://aihubmix.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
|
||
[profiles.work]
|
||
model_provider = "aihubmix"
|
||
model = "gpt-5.4"
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
manager.providers.insert(
|
||
"provider-c".to_string(),
|
||
Provider::with_id(
|
||
"provider-c".to_string(),
|
||
"Vendor C".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "vendor-c-key"},
|
||
"config": r#"model_provider = "vendor_c"
|
||
model = "gpt-5.4"
|
||
|
||
[model_providers.vendor_c]
|
||
name = "Vendor C"
|
||
base_url = "https://vendor-c.example/v1"
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#
|
||
}),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "provider-b")
|
||
.expect("switch to provider b should succeed");
|
||
ProviderService::switch(&state, AppType::Codex, "provider-c")
|
||
.expect("switch to provider c should succeed");
|
||
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("read providers after switches");
|
||
let provider_b_config = providers
|
||
.get("provider-b")
|
||
.expect("provider b exists")
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.expect("provider b config");
|
||
let parsed: toml::Value = toml::from_str(provider_b_config).expect("parse provider b config");
|
||
|
||
assert_eq!(
|
||
parsed.get("model_provider").and_then(|v| v.as_str()),
|
||
Some("aihubmix"),
|
||
"backfill should restore provider b's storage-specific model_provider id"
|
||
);
|
||
assert!(
|
||
parsed
|
||
.get("model_providers")
|
||
.and_then(|v| v.get("aihubmix"))
|
||
.is_some(),
|
||
"provider b should keep its own model_providers table after backfill"
|
||
);
|
||
assert_eq!(
|
||
parsed
|
||
.get("profiles")
|
||
.and_then(|v| v.get("work"))
|
||
.and_then(|v| v.get("model_provider"))
|
||
.and_then(|v| v.as_str()),
|
||
Some("aihubmix"),
|
||
"profile overrides should be restored to provider b's storage-specific id"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() {
|
||
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 = "current-provider".to_string();
|
||
|
||
let mut provider = Provider::with_id(
|
||
"current-provider".to_string(),
|
||
"Current".to_string(),
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_AUTH_TOKEN": "real-token",
|
||
"ANTHROPIC_BASE_URL": "https://claude.example"
|
||
}
|
||
}),
|
||
None,
|
||
);
|
||
provider.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
|
||
manager
|
||
.providers
|
||
.insert("current-provider".to_string(), provider);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
state
|
||
.db
|
||
.set_config_snippet(
|
||
AppType::Claude.as_str(),
|
||
Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()),
|
||
)
|
||
.expect("set common config snippet");
|
||
|
||
let taken_over_live = json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:5000",
|
||
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
|
||
}
|
||
});
|
||
let settings_path = get_claude_settings_path();
|
||
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
|
||
std::fs::write(
|
||
&settings_path,
|
||
serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"),
|
||
)
|
||
.expect("write taken over live");
|
||
|
||
futures::executor::block_on(state.db.save_live_backup("claude", "{\"env\":{}}"))
|
||
.expect("seed live backup");
|
||
|
||
let mut proxy_config = futures::executor::block_on(state.db.get_proxy_config_for_app("claude"))
|
||
.expect("get proxy config");
|
||
proxy_config.enabled = true;
|
||
futures::executor::block_on(state.db.update_proxy_config_for_app(proxy_config))
|
||
.expect("enable takeover");
|
||
|
||
ProviderService::sync_current_provider_for_app(&state, AppType::Claude)
|
||
.expect("sync current provider should succeed");
|
||
|
||
let live_after: serde_json::Value =
|
||
read_json_file(&settings_path).expect("read live settings after sync");
|
||
assert_eq!(
|
||
live_after, taken_over_live,
|
||
"sync should not overwrite live config while takeover is active"
|
||
);
|
||
|
||
let backup = futures::executor::block_on(state.db.get_live_backup("claude"))
|
||
.expect("get live backup")
|
||
.expect("backup exists");
|
||
let backup_value: serde_json::Value =
|
||
serde_json::from_str(&backup.original_config).expect("parse backup value");
|
||
|
||
assert_eq!(
|
||
backup_value
|
||
.get("includeCoAuthoredBy")
|
||
.and_then(|v| v.as_bool()),
|
||
Some(false),
|
||
"restore backup should receive the updated effective config"
|
||
);
|
||
assert_eq!(
|
||
backup_value
|
||
.get("env")
|
||
.and_then(|v| v.get("ANTHROPIC_AUTH_TOKEN"))
|
||
.and_then(|v| v.as_str()),
|
||
Some("real-token"),
|
||
"restore backup should preserve the provider token rather than proxy placeholder"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switch_codex_provider_with_takeover_live_but_stopped_proxy_keeps_proxy_live_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
enable_codex_official_auth_preservation();
|
||
let _home = ensure_test_home();
|
||
|
||
let oauth_auth = json!({
|
||
"auth_mode": "chatgpt",
|
||
"tokens": {
|
||
"access_token": "oauth-access",
|
||
"id_token": "oauth-id"
|
||
}
|
||
});
|
||
let old_provider_config = r#"model_provider = "deepseek"
|
||
model = "deepseek-chat"
|
||
|
||
[model_providers.deepseek]
|
||
name = "DeepSeek"
|
||
base_url = "https://api.deepseek.com/v1"
|
||
wire_api = "responses"
|
||
experimental_bearer_token = "old-key"
|
||
"#;
|
||
let proxy_live_config = r#"model_provider = "deepseek"
|
||
model = "deepseek-chat"
|
||
|
||
[model_providers.deepseek]
|
||
name = "DeepSeek"
|
||
base_url = "http://127.0.0.1:15721/v1"
|
||
wire_api = "responses"
|
||
experimental_bearer_token = "PROXY_MANAGED"
|
||
"#;
|
||
write_codex_live_atomic(&oauth_auth, Some(proxy_live_config))
|
||
.expect("seed taken-over 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();
|
||
|
||
let mut old_provider = Provider::with_id(
|
||
"old-provider".to_string(),
|
||
"DeepSeek Old".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "old-key"},
|
||
"config": old_provider_config
|
||
}),
|
||
None,
|
||
);
|
||
old_provider.category = Some("custom".to_string());
|
||
manager
|
||
.providers
|
||
.insert("old-provider".to_string(), old_provider);
|
||
|
||
let mut new_provider = Provider::with_id(
|
||
"new-provider".to_string(),
|
||
"DeepSeek New".to_string(),
|
||
json!({
|
||
"auth": {"OPENAI_API_KEY": "new-key"},
|
||
"config": r#"model_provider = "deepseek-new"
|
||
model = "deepseek-reasoner"
|
||
|
||
[model_providers.deepseek-new]
|
||
name = "DeepSeek New"
|
||
base_url = "https://new.deepseek.example/v1"
|
||
wire_api = "responses"
|
||
"#
|
||
}),
|
||
None,
|
||
);
|
||
new_provider.category = Some("custom".to_string());
|
||
manager
|
||
.providers
|
||
.insert("new-provider".to_string(), new_provider);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
futures::executor::block_on(
|
||
state.db.save_live_backup(
|
||
"codex",
|
||
&serde_json::to_string(&json!({
|
||
"auth": oauth_auth,
|
||
"config": old_provider_config
|
||
}))
|
||
.expect("serialize backup"),
|
||
),
|
||
)
|
||
.expect("seed Codex live backup");
|
||
|
||
assert!(
|
||
!futures::executor::block_on(state.proxy_service.is_running()),
|
||
"fixture keeps the proxy server stopped"
|
||
);
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "new-provider")
|
||
.expect("switch should update takeover backup instead of writing normal live config");
|
||
|
||
let auth_after: serde_json::Value =
|
||
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
|
||
assert_eq!(
|
||
auth_after, oauth_auth,
|
||
"provider switch during takeover ownership must not rewrite Codex OAuth auth"
|
||
);
|
||
|
||
let live_config =
|
||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||
assert!(
|
||
live_config.contains("http://127.0.0.1:15721/v1"),
|
||
"live config should remain pointed at the local proxy"
|
||
);
|
||
assert!(
|
||
live_config.contains("PROXY_MANAGED"),
|
||
"live config should keep the proxy bearer placeholder"
|
||
);
|
||
assert!(
|
||
live_config.contains(r#"model_provider = "deepseek-new""#)
|
||
&& live_config.contains(r#"name = "DeepSeek New""#),
|
||
"live config should update the Codex-visible provider label during takeover"
|
||
);
|
||
assert!(
|
||
!live_config.contains("https://new.deepseek.example/v1"),
|
||
"normal provider base_url must not overwrite taken-over live config"
|
||
);
|
||
|
||
let backup = futures::executor::block_on(state.db.get_live_backup("codex"))
|
||
.expect("get Codex backup")
|
||
.expect("backup exists");
|
||
let backup_value: serde_json::Value =
|
||
serde_json::from_str(&backup.original_config).expect("parse backup");
|
||
assert_eq!(
|
||
backup_value.get("auth"),
|
||
Some(&auth_after),
|
||
"restore backup should preserve the official OAuth auth"
|
||
);
|
||
let backup_config = backup_value
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or_default();
|
||
assert!(
|
||
backup_config.contains("new-key") && backup_config.contains("deepseek-new"),
|
||
"restore backup should be rebuilt from the newly selected provider"
|
||
);
|
||
|
||
let current = state
|
||
.db
|
||
.get_current_provider(AppType::Codex.as_str())
|
||
.expect("get current provider");
|
||
assert_eq!(current.as_deref(), Some("new-provider"));
|
||
}
|
||
|
||
#[test]
|
||
fn explicitly_cleared_common_snippet_is_not_auto_extracted() {
|
||
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");
|
||
state
|
||
.db
|
||
.set_config_snippet_cleared(AppType::Claude.as_str(), true)
|
||
.expect("mark snippet explicitly cleared");
|
||
|
||
assert!(
|
||
!state
|
||
.db
|
||
.should_auto_extract_config_snippet(AppType::Claude.as_str())
|
||
.expect("check auto-extract eligibility"),
|
||
"explicitly cleared snippets should block auto-extraction"
|
||
);
|
||
|
||
state
|
||
.db
|
||
.set_config_snippet(AppType::Claude.as_str(), Some("{}".to_string()))
|
||
.expect("set snippet");
|
||
state
|
||
.db
|
||
.set_config_snippet_cleared(AppType::Claude.as_str(), false)
|
||
.expect("clear explicit-empty marker");
|
||
|
||
assert!(
|
||
!state
|
||
.db
|
||
.should_auto_extract_config_snippet(AppType::Claude.as_str())
|
||
.expect("check auto-extract after snippet saved"),
|
||
"existing snippets should also block auto-extraction"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_common_config_migration_flag_roundtrip() {
|
||
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");
|
||
|
||
assert!(
|
||
!state
|
||
.db
|
||
.is_legacy_common_config_migrated()
|
||
.expect("initial migration flag"),
|
||
"migration flag should default to false"
|
||
);
|
||
|
||
state
|
||
.db
|
||
.set_legacy_common_config_migrated(true)
|
||
.expect("set migration flag");
|
||
assert!(
|
||
state
|
||
.db
|
||
.is_legacy_common_config_migrated()
|
||
.expect("read migration flag"),
|
||
"migration flag should persist once set"
|
||
);
|
||
|
||
state
|
||
.db
|
||
.set_legacy_common_config_migrated(false)
|
||
.expect("clear migration flag");
|
||
assert!(
|
||
!state
|
||
.db
|
||
.is_legacy_common_config_migrated()
|
||
.expect("read migration flag after clear"),
|
||
"migration flag should be removable for tests/debugging"
|
||
);
|
||
}
|
||
|
||
#[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");
|
||
|
||
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||
let settings_path = home.join(".gemini").join("settings.json");
|
||
assert!(
|
||
settings_path.exists(),
|
||
"Gemini settings.json should exist at {}",
|
||
settings_path.display()
|
||
);
|
||
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
|
||
let value: serde_json::Value =
|
||
serde_json::from_str(&raw).expect("parse gemini 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");
|
||
|
||
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||
let settings_path = home.join(".gemini").join("settings.json");
|
||
assert!(
|
||
settings_path.exists(),
|
||
"Gemini settings.json should exist at {}",
|
||
settings_path.display()
|
||
);
|
||
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
|
||
let value: serde_json::Value =
|
||
serde_json::from_str(&raw).expect("parse gemini 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_preserves_env_vars() {
|
||
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": {
|
||
"GEMINI_MODEL": "gemini-2.5-pro"
|
||
}
|
||
}),
|
||
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");
|
||
|
||
// Verify env vars are preserved in ~/.gemini/.env
|
||
let env_path = home.join(".gemini").join(".env");
|
||
assert!(
|
||
env_path.exists(),
|
||
"Gemini .env should exist at {}",
|
||
env_path.display()
|
||
);
|
||
let env_content = std::fs::read_to_string(&env_path).expect("read gemini .env");
|
||
assert!(
|
||
env_content.contains("GEMINI_MODEL=gemini-2.5-pro"),
|
||
"GEMINI_MODEL should be preserved in .env, got: {env_content}"
|
||
);
|
||
|
||
// Verify OAuth security flag is still set correctly
|
||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||
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"),
|
||
"OAuth security flag should still be set"
|
||
);
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
|
||
/// 切走勾选了通用配置的 Claude 供应商时,应把它 live 里新增的可共享键
|
||
/// (用户直接在应用内装插件/改偏好)捕获进通用配置片段,并带到下一个供应商。
|
||
#[test]
|
||
fn switch_claude_syncs_new_shared_keys_from_live_into_common_config() {
|
||
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");
|
||
}
|
||
// A 的 live = A 私有密钥(含非 Anthropic 的 OpenRouter 凭据)+ 已共享的 theme
|
||
// + 用户刚在应用内新增的 enableAllProjectMcpServers
|
||
let live = json!({
|
||
"env": { "ANTHROPIC_API_KEY": "a-key", "OPENROUTER_API_KEY": "sk-or-leak" },
|
||
"theme": "dark",
|
||
"enableAllProjectMcpServers": true
|
||
});
|
||
std::fs::write(
|
||
&settings_path,
|
||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||
)
|
||
.expect("seed claude live config");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Claude)
|
||
.expect("claude manager");
|
||
manager.current = "a".to_string();
|
||
let mut provider_a = Provider::with_id(
|
||
"a".to_string(),
|
||
"A".to_string(),
|
||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||
None,
|
||
);
|
||
provider_a.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
manager.providers.insert("a".to_string(), provider_a);
|
||
let mut provider_b = Provider::with_id(
|
||
"b".to_string(),
|
||
"B".to_string(),
|
||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||
None,
|
||
);
|
||
provider_b.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
manager.providers.insert("b".to_string(), provider_b);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
state
|
||
.db
|
||
.set_config_snippet(
|
||
AppType::Claude.as_str(),
|
||
Some(r#"{"theme":"dark"}"#.to_string()),
|
||
)
|
||
.expect("seed common config snippet");
|
||
|
||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||
|
||
// 片段应捕获到新增键,并保留已有共享键,且绝不含密钥
|
||
let snippet = state
|
||
.db
|
||
.get_config_snippet(AppType::Claude.as_str())
|
||
.expect("read snippet")
|
||
.expect("snippet present");
|
||
let snippet_value: serde_json::Value =
|
||
serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||
assert_eq!(
|
||
snippet_value.get("enableAllProjectMcpServers"),
|
||
Some(&json!(true)),
|
||
"newly added shared key should be captured into common config"
|
||
);
|
||
assert_eq!(
|
||
snippet_value.get("theme").and_then(|v| v.as_str()),
|
||
Some("dark"),
|
||
"previously shared key should be preserved"
|
||
);
|
||
assert!(
|
||
snippet_value
|
||
.get("env")
|
||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||
.is_none(),
|
||
"secrets must never leak into the shared snippet"
|
||
);
|
||
assert!(
|
||
snippet_value
|
||
.get("env")
|
||
.and_then(|env| env.get("OPENROUTER_API_KEY"))
|
||
.is_none(),
|
||
"non-Anthropic Claude credentials must never leak into the shared snippet"
|
||
);
|
||
|
||
// 新增键应通过通用配置带到 B 的 live
|
||
let live_after: serde_json::Value =
|
||
read_json_file(&settings_path).expect("read live after switch");
|
||
assert_eq!(
|
||
live_after.get("enableAllProjectMcpServers"),
|
||
Some(&json!(true)),
|
||
"shared key should propagate to the next provider's live config"
|
||
);
|
||
assert!(
|
||
live_after
|
||
.get("env")
|
||
.and_then(|env| env.get("OPENROUTER_API_KEY"))
|
||
.is_none(),
|
||
"leaked credential must not be injected into the next provider's live"
|
||
);
|
||
assert_eq!(
|
||
live_after
|
||
.get("env")
|
||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||
.and_then(|v| v.as_str()),
|
||
Some("b-key"),
|
||
"live should reflect new provider's own auth"
|
||
);
|
||
}
|
||
|
||
/// 用户在应用内删掉一个已共享的键后,切换应把删除同步进通用配置,
|
||
/// 且不会在切到下一个供应商时被重新注入(否则会"删不掉")。
|
||
#[test]
|
||
fn switch_claude_syncs_deletions_from_live_into_common_config() {
|
||
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");
|
||
}
|
||
// live 里 theme 还在,但用户已删掉 enableAllProjectMcpServers
|
||
let live = json!({
|
||
"env": { "ANTHROPIC_API_KEY": "a-key" },
|
||
"theme": "dark"
|
||
});
|
||
std::fs::write(
|
||
&settings_path,
|
||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||
)
|
||
.expect("seed claude live config");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Claude)
|
||
.expect("claude manager");
|
||
manager.current = "a".to_string();
|
||
let mut provider_a = Provider::with_id(
|
||
"a".to_string(),
|
||
"A".to_string(),
|
||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||
None,
|
||
);
|
||
provider_a.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
manager.providers.insert("a".to_string(), provider_a);
|
||
let mut provider_b = Provider::with_id(
|
||
"b".to_string(),
|
||
"B".to_string(),
|
||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||
None,
|
||
);
|
||
provider_b.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
manager.providers.insert("b".to_string(), provider_b);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
// 片段里仍残留 enableAllProjectMcpServers(上次共享的)
|
||
state
|
||
.db
|
||
.set_config_snippet(
|
||
AppType::Claude.as_str(),
|
||
Some(r#"{"theme":"dark","enableAllProjectMcpServers":true}"#.to_string()),
|
||
)
|
||
.expect("seed common config snippet");
|
||
|
||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||
|
||
let snippet = state
|
||
.db
|
||
.get_config_snippet(AppType::Claude.as_str())
|
||
.expect("read snippet")
|
||
.expect("snippet present");
|
||
let snippet_value: serde_json::Value =
|
||
serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||
assert!(
|
||
snippet_value.get("enableAllProjectMcpServers").is_none(),
|
||
"deleted key should be removed from common config"
|
||
);
|
||
assert_eq!(
|
||
snippet_value.get("theme").and_then(|v| v.as_str()),
|
||
Some("dark"),
|
||
"untouched shared key should remain"
|
||
);
|
||
|
||
// 切到 B 后 live 不应再出现被删除的键
|
||
let live_after: serde_json::Value =
|
||
read_json_file(&settings_path).expect("read live after switch");
|
||
assert!(
|
||
live_after.get("enableAllProjectMcpServers").is_none(),
|
||
"deleted shared key must not be re-injected into the next provider"
|
||
);
|
||
}
|
||
|
||
/// Codex 版切换自动回写:live 里新增的共享键被捕获进通用配置片段并传递给
|
||
/// 下一个供应商;供应商专属字段、密钥与 cc-switch 注入产物绝不进片段;
|
||
/// 回填后旧供应商的存储配置不残留片段内容(autosync 先于 strip,值必然匹配)。
|
||
#[test]
|
||
fn switch_codex_syncs_shared_keys_from_live_into_common_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
// A 激活状态下的 live:A 专属路由 + 已共享的 [tui] + 用户刚加的
|
||
// disable_response_storage + cc-switch 注入产物 + MCP 同步投影
|
||
// + 顶层 wire_api(无 model_provider 时的 fallback 写法,属 A 的路由语义)
|
||
// + 历史错误格式 [mcp.servers](sync_all_enabled 清不掉的孤儿形态)
|
||
let live_config = r#"model = "gpt-5.5"
|
||
model_provider = "aprov"
|
||
wire_api = "chat"
|
||
experimental_bearer_token = "sk-a-live-secret"
|
||
model_catalog_json = "cc-switch-model-catalog.json"
|
||
web_search = "disabled"
|
||
disable_response_storage = true
|
||
|
||
[tui]
|
||
notifications = true
|
||
|
||
[model_providers.aprov]
|
||
name = "A Prov"
|
||
base_url = "https://a.example/v1"
|
||
wire_api = "responses"
|
||
|
||
[mcp_servers.echo]
|
||
type = "stdio"
|
||
command = "echo"
|
||
|
||
[mcp.servers.ghost-legacy]
|
||
command = "ghost-cmd"
|
||
"#;
|
||
write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk-a" }), Some(live_config))
|
||
.expect("seed codex live config");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.current = "a".to_string();
|
||
let mut provider_a = Provider::with_id(
|
||
"a".to_string(),
|
||
"A".to_string(),
|
||
json!({
|
||
"auth": { "OPENAI_API_KEY": "sk-a" },
|
||
"config": "model = \"gpt-5.5\"\nmodel_provider = \"aprov\"\n\n[model_providers.aprov]\nname = \"A Prov\"\nbase_url = \"https://a.example/v1\"\nwire_api = \"responses\"\n"
|
||
}),
|
||
None,
|
||
);
|
||
provider_a.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
manager.providers.insert("a".to_string(), provider_a);
|
||
let mut provider_b = Provider::with_id(
|
||
"b".to_string(),
|
||
"B".to_string(),
|
||
json!({
|
||
"auth": { "OPENAI_API_KEY": "sk-b" },
|
||
"config": "model = \"gpt-5.5\"\nmodel_provider = \"bprov\"\n\n[model_providers.bprov]\nname = \"B Prov\"\nbase_url = \"https://b.example/v1\"\nwire_api = \"responses\"\n"
|
||
}),
|
||
None,
|
||
);
|
||
provider_b.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
manager.providers.insert("b".to_string(), provider_b);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
state
|
||
.db
|
||
.set_config_snippet(
|
||
AppType::Codex.as_str(),
|
||
Some("[tui]\nnotifications = true\n".to_string()),
|
||
)
|
||
.expect("seed codex common config snippet");
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "b").expect("switch should succeed");
|
||
|
||
// 片段:捕获新增共享键、保留既有共享键;专属字段/密钥/注入产物一律不进
|
||
let snippet = state
|
||
.db
|
||
.get_config_snippet(AppType::Codex.as_str())
|
||
.expect("read snippet")
|
||
.expect("snippet present");
|
||
assert!(
|
||
snippet.contains("disable_response_storage = true"),
|
||
"newly added shared key should be captured, got: {snippet}"
|
||
);
|
||
assert!(
|
||
snippet.contains("notifications = true"),
|
||
"previously shared key should be preserved, got: {snippet}"
|
||
);
|
||
for forbidden in [
|
||
"experimental_bearer_token",
|
||
"sk-a-live-secret",
|
||
"model_catalog_json",
|
||
"web_search",
|
||
"mcp_servers",
|
||
"model_providers",
|
||
"model_provider",
|
||
"wire_api",
|
||
"ghost-legacy",
|
||
] {
|
||
assert!(
|
||
!snippet.contains(forbidden),
|
||
"'{forbidden}' must never enter the shared snippet, got: {snippet}"
|
||
);
|
||
}
|
||
|
||
// B 的 live:共享键传递到位,A 的密钥/投影不得跟过来
|
||
let live_after = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
|
||
.expect("read config.toml after switch");
|
||
assert!(
|
||
live_after.contains("disable_response_storage = true"),
|
||
"shared key should propagate to the next provider's live, got: {live_after}"
|
||
);
|
||
assert!(
|
||
live_after.contains("model_provider = \"bprov\""),
|
||
"live should be provider B's own config, got: {live_after}"
|
||
);
|
||
assert!(
|
||
!live_after.contains("sk-a-live-secret"),
|
||
"provider A's bearer token must not leak into B's live, got: {live_after}"
|
||
);
|
||
assert!(
|
||
!live_after.contains("mcp_servers"),
|
||
"no DB-enabled MCP servers, so live must not resurrect stale entries, got: {live_after}"
|
||
);
|
||
assert!(
|
||
!live_after.contains("ghost-legacy"),
|
||
"the legacy [mcp.servers] orphan must not propagate to B's live, got: {live_after}"
|
||
);
|
||
assert!(
|
||
!live_after.contains("wire_api = \"chat\""),
|
||
"provider A's top-level wire_api must not rewrite B's protocol, got: {live_after}"
|
||
);
|
||
|
||
// A 的存储配置:回填后不残留片段内容 / MCP 投影 / 注入产物
|
||
let providers = state
|
||
.db
|
||
.get_all_providers(AppType::Codex.as_str())
|
||
.expect("read providers after switch");
|
||
let stored_a = providers.get("a").expect("provider a exists");
|
||
let stored_a_config = stored_a
|
||
.settings_config
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or_default();
|
||
assert!(
|
||
stored_a_config.contains("model_provider = \"aprov\""),
|
||
"provider-owned routing must survive backfill, got: {stored_a_config}"
|
||
);
|
||
// 顶层 wire_api 是 A 自己的路由语义:不进片段,但回填时留在 A 的快照里
|
||
assert!(
|
||
stored_a_config.contains("wire_api = \"chat\""),
|
||
"provider-owned top-level wire_api must survive backfill, got: {stored_a_config}"
|
||
);
|
||
for forbidden in [
|
||
"disable_response_storage",
|
||
"notifications",
|
||
"mcp_servers",
|
||
"experimental_bearer_token",
|
||
"ghost-legacy",
|
||
] {
|
||
assert!(
|
||
!stored_a_config.contains(forbidden),
|
||
"'{forbidden}' must be stripped from the stored provider config on backfill, got: {stored_a_config}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Codex 版删除同步:用户在 live 里删掉一个已共享的键后,切换应把删除
|
||
/// 同步进通用配置,且不会在切到下一个供应商时被重新注入(否则"删不掉")。
|
||
#[test]
|
||
fn switch_codex_syncs_deletions_from_live_into_common_config() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
// 片段里有两个共享键,但用户已在 live 里删掉 disable_response_storage
|
||
let live_config = r#"model_provider = "aprov"
|
||
|
||
[tui]
|
||
notifications = true
|
||
|
||
[model_providers.aprov]
|
||
name = "A Prov"
|
||
base_url = "https://a.example/v1"
|
||
wire_api = "responses"
|
||
"#;
|
||
write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk-a" }), Some(live_config))
|
||
.expect("seed codex live config");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Codex)
|
||
.expect("codex manager");
|
||
manager.current = "a".to_string();
|
||
for (id, name, prov_key) in [("a", "A", "aprov"), ("b", "B", "bprov")] {
|
||
let mut provider = Provider::with_id(
|
||
id.to_string(),
|
||
name.to_string(),
|
||
json!({
|
||
"auth": { "OPENAI_API_KEY": format!("sk-{id}") },
|
||
"config": format!("model_provider = \"{prov_key}\"\n\n[model_providers.{prov_key}]\nname = \"{name} Prov\"\nbase_url = \"https://{id}.example/v1\"\nwire_api = \"responses\"\n")
|
||
}),
|
||
None,
|
||
);
|
||
provider.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
manager.providers.insert(id.to_string(), provider);
|
||
}
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
state
|
||
.db
|
||
.set_config_snippet(
|
||
AppType::Codex.as_str(),
|
||
Some("disable_response_storage = true\n\n[tui]\nnotifications = true\n".to_string()),
|
||
)
|
||
.expect("seed codex common config snippet");
|
||
|
||
ProviderService::switch(&state, AppType::Codex, "b").expect("switch should succeed");
|
||
|
||
let snippet = state
|
||
.db
|
||
.get_config_snippet(AppType::Codex.as_str())
|
||
.expect("read snippet")
|
||
.expect("snippet present");
|
||
assert!(
|
||
!snippet.contains("disable_response_storage"),
|
||
"deleted shared key must be removed from the snippet, got: {snippet}"
|
||
);
|
||
assert!(
|
||
snippet.contains("notifications = true"),
|
||
"kept shared key should remain in the snippet, got: {snippet}"
|
||
);
|
||
|
||
let live_after = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
|
||
.expect("read config.toml after switch");
|
||
assert!(
|
||
!live_after.contains("disable_response_storage"),
|
||
"deleted shared key must not be re-injected into the next provider, got: {live_after}"
|
||
);
|
||
assert!(
|
||
live_after.contains("notifications = true"),
|
||
"kept shared key should propagate to the next provider, got: {live_after}"
|
||
);
|
||
}
|
||
|
||
/// 未勾选"写入通用配置"的供应商,其 live 改动不应自动污染通用配置片段。
|
||
#[test]
|
||
fn switch_claude_does_not_sync_common_config_for_opted_out_provider() {
|
||
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 live = json!({
|
||
"env": { "ANTHROPIC_API_KEY": "a-key" },
|
||
"providerSpecific": "x"
|
||
});
|
||
std::fs::write(
|
||
&settings_path,
|
||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||
)
|
||
.expect("seed claude live config");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Claude)
|
||
.expect("claude manager");
|
||
manager.current = "a".to_string();
|
||
// A 未勾选通用配置(meta = None)
|
||
manager.providers.insert(
|
||
"a".to_string(),
|
||
Provider::with_id(
|
||
"a".to_string(),
|
||
"A".to_string(),
|
||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||
None,
|
||
),
|
||
);
|
||
manager.providers.insert(
|
||
"b".to_string(),
|
||
Provider::with_id(
|
||
"b".to_string(),
|
||
"B".to_string(),
|
||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||
None,
|
||
),
|
||
);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
state
|
||
.db
|
||
.set_config_snippet(
|
||
AppType::Claude.as_str(),
|
||
Some(r#"{"theme":"dark"}"#.to_string()),
|
||
)
|
||
.expect("seed common config snippet");
|
||
|
||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||
|
||
let snippet = state
|
||
.db
|
||
.get_config_snippet(AppType::Claude.as_str())
|
||
.expect("read snippet")
|
||
.expect("snippet present");
|
||
let snippet_value: serde_json::Value =
|
||
serde_json::from_str(&snippet).expect("snippet is valid JSON");
|
||
assert!(
|
||
snippet_value.get("providerSpecific").is_none(),
|
||
"opted-out provider's live changes must not pollute the shared snippet"
|
||
);
|
||
assert_eq!(
|
||
snippet_value.get("theme").and_then(|v| v.as_str()),
|
||
Some("dark"),
|
||
"snippet should stay unchanged for opted-out providers"
|
||
);
|
||
}
|
||
|
||
/// 用户显式清空过通用配置(_cleared)后,切换不应把片段重新塞回来。
|
||
#[test]
|
||
fn switch_claude_respects_explicitly_cleared_common_config() {
|
||
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 live = json!({
|
||
"env": { "ANTHROPIC_API_KEY": "a-key" },
|
||
"theme": "dark"
|
||
});
|
||
std::fs::write(
|
||
&settings_path,
|
||
serde_json::to_string_pretty(&live).expect("serialize live"),
|
||
)
|
||
.expect("seed claude live config");
|
||
|
||
let mut config = MultiAppConfig::default();
|
||
{
|
||
let manager = config
|
||
.get_manager_mut(&AppType::Claude)
|
||
.expect("claude manager");
|
||
manager.current = "a".to_string();
|
||
let mut provider_a = Provider::with_id(
|
||
"a".to_string(),
|
||
"A".to_string(),
|
||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||
None,
|
||
);
|
||
provider_a.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
manager.providers.insert("a".to_string(), provider_a);
|
||
let mut provider_b = Provider::with_id(
|
||
"b".to_string(),
|
||
"B".to_string(),
|
||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||
None,
|
||
);
|
||
provider_b.meta = Some(ProviderMeta {
|
||
common_config_enabled: Some(true),
|
||
..Default::default()
|
||
});
|
||
manager.providers.insert("b".to_string(), provider_b);
|
||
}
|
||
|
||
let state = create_test_state_with_config(&config).expect("create test state");
|
||
state
|
||
.db
|
||
.set_config_snippet_cleared(AppType::Claude.as_str(), true)
|
||
.expect("mark snippet cleared");
|
||
|
||
ProviderService::switch(&state, AppType::Claude, "b").expect("switch should succeed");
|
||
|
||
assert!(
|
||
state
|
||
.db
|
||
.get_config_snippet(AppType::Claude.as_str())
|
||
.expect("read snippet")
|
||
.is_none(),
|
||
"explicitly cleared snippet must not be resurrected by switch-away sync"
|
||
);
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
// v3.7.0+ 不再使用供应商特定文件(如 settings-*.json)
|
||
// 删除供应商只影响数据库记录,不清理这些旧格式文件
|
||
}
|
||
|
||
#[test]
|
||
fn provider_service_delete_current_provider_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::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:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_back() {
|
||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||
reset_test_fs();
|
||
let _home = ensure_test_home();
|
||
|
||
// 接管态 Claude Live,且 DB 中无备份(模拟切换 app_config_dir 后新库首启的场景)
|
||
let taken_over_live = json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
|
||
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
|
||
}
|
||
});
|
||
let settings_path = get_claude_settings_path();
|
||
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
|
||
std::fs::write(
|
||
&settings_path,
|
||
serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"),
|
||
)
|
||
.expect("write taken over live");
|
||
|
||
let state = create_test_state().expect("create test state");
|
||
|
||
// 模拟历史异常:接管态 Live 已被导入成 current provider(SSOT 被污染)
|
||
let provider = Provider::with_id(
|
||
"default".to_string(),
|
||
"default".to_string(),
|
||
taken_over_live.clone(),
|
||
None,
|
||
);
|
||
state
|
||
.db
|
||
.save_provider(AppType::Claude.as_str(), &provider)
|
||
.expect("save placeholder provider");
|
||
state
|
||
.db
|
||
.set_current_provider(AppType::Claude.as_str(), "default")
|
||
.expect("set current provider");
|
||
|
||
futures::executor::block_on(state.proxy_service.recover_from_crash())
|
||
.expect("recover from crash");
|
||
|
||
let live_after: serde_json::Value =
|
||
read_json_file(&settings_path).expect("read live settings after recovery");
|
||
let env = live_after.get("env").cloned().unwrap_or_else(|| json!({}));
|
||
assert_ne!(
|
||
env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()),
|
||
Some("PROXY_MANAGED"),
|
||
"recovery must not write the placeholder back to live"
|
||
);
|
||
assert!(
|
||
env.get("ANTHROPIC_BASE_URL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|url| !url.starts_with("http://127.0.0.1"))
|
||
.unwrap_or(true),
|
||
"recovery must drop the local proxy base URL"
|
||
);
|
||
}
|