Files
EasyCLIProxyAPI/src-tauri/src/tests/alias_save.rs
T

1035 lines
47 KiB
Rust

use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
const CURRENT: &str = "oauth-model-alias:\n codex:\n - name: gpt-test\n alias: my-alias\n fork: true\npayload:\n override:\n - models: [{name: my-alias, protocol: codex}]\n params: {reasoning.effort: high}\n";
#[test]
fn v8_management_yaml_is_projected_to_the_legacy_alias_editor_view() {
let input = "config-version: 8\naccess: {api-keys: [client-key]}\noauth:\n model-alias: {codex: [{name: upstream, alias: public, fork: true}]}\nrequests:\n payload: {override: [{models: [{name: public}], params: {reasoning.effort: high}}]}\napi-keys:\n codex:\n - name: codex-group\n base-url: https://codex.example/v1\n models: [{name: model-a}]\n keys:\n - {api-key: first, priority: 7}\n - {api-key: second, priority: null}\n openai-compatibility:\n - name: openai-group\n base-url: https://openai.example/v1\n keys: [{api-key: third}]\n - name: empty-openai-group\n base-url: https://empty.example/v1\n keys: []\n";
let view = yaml_json(&management_v8_yaml_to_legacy_view(input).unwrap());
assert_eq!(view["api-keys"], serde_json::json!(["client-key"]));
assert_eq!(view["oauth-model-alias"]["codex"][0]["alias"], "public");
assert_eq!(view["payload"]["override"][0]["params"]["reasoning.effort"], "high");
assert!(view["access"].get("api-keys").is_none());
assert!(view["oauth"].get("model-alias").is_none());
assert!(view["requests"].get("payload").is_none());
assert_eq!(view["codex-api-key"][0]["api-key"], "first");
assert_eq!(view["codex-api-key"][0]["priority"], 7);
assert_eq!(view["codex-api-key"][0]["models"][0]["name"], "model-a");
assert_eq!(view["codex-api-key"][1]["api-key"], "second");
assert!(view["codex-api-key"][1].get("priority").is_none());
assert_eq!(
view["openai-compatibility"][0]["api-key-entries"][0]["api-key"],
"third"
);
assert!(view["openai-compatibility"][1]
.get("api-key-entries")
.is_none());
assert_eq!(view["gemini-api-key"], serde_json::json!([]));
}
#[test]
fn non_v8_management_yaml_is_returned_without_reformatting() {
let input = "# mock legacy config\nport: 8317\n";
assert_eq!(management_v8_yaml_to_legacy_view(input).unwrap(), input);
}
#[test]
fn v8_provider_names_belong_to_groups_and_preserve_legacy_view_identity() {
let groups = serde_norway::from_str(
"- name: codex-1\n base-url: https://gateway.example/v1\n models: [{name: deepseek-chat, alias: my-deepseek}]\n keys:\n - {name: DeepSeek, api-key: first, weight: 2}\n - {api-key: second, priority: 0}\n",
)
.unwrap();
let records = flatten_v8_provider_groups("codex", &groups).unwrap();
let updated = group_legacy_provider_records("codex", &records).unwrap();
let json = serde_json::to_value(&updated).unwrap();
assert_eq!(json[0]["name"], "DeepSeek");
assert_eq!(json[0]["keys"][0]["weight"], 2);
assert!(json[0]["keys"][0].get("name").is_none());
assert_eq!(json[1]["name"], "codex-2");
assert!(json[1]["keys"][0].get("name").is_none());
assert_eq!(flatten_v8_provider_groups("codex", &updated).unwrap(), records);
}
#[test]
fn v8_alias_projection_normalizes_empty_parents_but_preserves_siblings() {
let project = |input| yaml_json(&management_v8_yaml_to_legacy_view(input).unwrap());
assert_eq!(
project("config-version: 8\n"),
project("config-version: 8\naccess: {}\noauth: {}\nrequests: {}\n")
);
let view = project("config-version: 8\noauth: {auth-dir: credentials, model-alias: {}}\nrequests: {proxy-url: direct, payload: {}}\n");
assert_eq!(view["oauth"]["auth-dir"], "credentials");
assert_eq!(view["requests"]["proxy-url"], "direct");
}
#[test]
fn v8_provider_fields_use_yaml_names_without_losing_explicit_false_or_extensions() {
for provider in ["codex", "claude", "gemini", "openai-compatibility"] {
let records = serde_norway::from_str(
"- name: provider\n api-key: test-key\n test-model: old\n testModel: old\n disable-cooling: false\n request-retry: 0\n models:\n - name: model\n max-context-length: 65536\n thinking: {zero_allowed: true, zero-allowed: false, dynamic_allowed: true, levels: [low, high]}\n",
).unwrap();
let groups = group_legacy_provider_records(provider, &records).unwrap();
let json = serde_json::to_value(&groups).unwrap();
assert_eq!(json[0]["disable-cooling"], false);
assert_eq!(json[0]["request-retry"], 0);
assert_eq!(json[0]["models"][0]["max-context-length"], 65536);
assert_eq!(json[0]["models"][0]["thinking"]["zero-allowed"], false);
assert_eq!(json[0]["models"][0]["thinking"]["dynamic-allowed"], true);
assert!(json[0]["models"][0]["thinking"].get("zero_allowed").is_none());
assert!(json[0]["models"][0]["thinking"].get("dynamic_allowed").is_none());
if provider == "openai-compatibility" {
assert!(json[0].get("test-model").is_none());
assert!(json[0].get("testModel").is_none());
}
let view = flatten_v8_provider_groups(provider, &groups).unwrap();
assert_eq!(group_legacy_provider_records(provider, &view).unwrap(), groups);
}
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_creates_first_oauth_and_payload_nodes() {
let core = MockCore::new_v8("config-version: 8\n", Failure::None);
let current = fetch_management_config_yaml(&core.config).await.unwrap();
let updated = format!("{current}{CURRENT}");
put_management_alias_config_changes(&core.config, &current, &updated)
.await
.unwrap();
let (persisted, _) = core.finish();
assert_eq!(persisted["oauth-model-alias"], yaml_json(CURRENT)["oauth-model-alias"]);
assert_eq!(persisted["payload"], yaml_json(CURRENT)["payload"]);
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_rolls_back_a_new_payload_parent() {
let initial = "oauth-model-alias: {codex: [{name: gpt-test, alias: my-alias, fork: true}]}\n";
let core = MockCore::new_v8(initial, Failure::YamlAfterWrite);
let current = fetch_management_config_yaml(&core.config).await.unwrap();
let updated = format!("{}payload: {{override: []}}\n", current.replace("my-alias", "renamed"));
let error = put_management_alias_config_changes(&core.config, &current, &updated)
.await
.unwrap_err();
assert!(error.contains("Original configuration was restored"), "{error}");
let (persisted, _) = core.finish();
assert_eq!(persisted, yaml_json(initial));
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Failure {
None,
YamlBeforeWrite,
SecondYamlBeforeWrite,
YamlAfterWrite,
OauthAfterWrite,
Rollback,
ForeignChange,
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_rejects_loss_of_api_access_before_any_write() {
let initial = format!("{CURRENT}codex-api-key:\n - api-key: test-key\n base-url: https://example.test\n models: [{{name: model-a}}]\nopenai-compatibility:\n - name: preserved\n disabled: true\n api-key-entries: [{{api-key: other-test-key}}]\n models: [{{name: model-b}}]\n");
for section in ["codex-api-key", "openai-compatibility"] {
for mode in ["missing", "empty", "credential"] {
let mut corrupted = yaml_json(&initial);
match mode {
"missing" => {
corrupted.as_object_mut().unwrap().remove(section);
}
"empty" => corrupted[section] = serde_json::json!([]),
_ => {
corrupted[section][0]
.as_object_mut()
.unwrap()
.retain(|key, _| !key.starts_with("api-key"));
}
}
let core = MockCore::new(&initial, Failure::None);
let result = put_management_alias_config_changes(
&core.config,
&initial,
&serde_norway::to_string(&corrupted).unwrap(),
)
.await;
let (persisted, requests) = core.finish();
assert!(
result.is_err(),
"accepted provider loss: {section}/{mode}"
);
assert_eq!(persisted, yaml_json(&initial));
assert!(!requests.iter().any(|request| request.starts_with("PUT")));
}
}
}
struct MockCore {
config: GuiConfigFile,
finished: Arc<AtomicBool>,
server: Option<std::thread::JoinHandle<(serde_json::Value, Vec<String>)>>,
}
impl MockCore {
fn new(initial: &str, failure: Failure) -> Self {
Self::with_response_layout(initial, failure, false)
}
fn new_v8(initial: &str, failure: Failure) -> Self {
Self::with_response_layout(initial, failure, true)
}
fn with_response_layout(initial: &str, failure: Failure, v8_response: bool) -> Self {
assert!(!current_core_tls_settings().unwrap().enabled);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
listener.set_nonblocking(true).unwrap();
let finished = Arc::new(AtomicBool::new(false));
let server_finished = finished.clone();
let mut persisted = yaml_json(initial);
let server = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + Duration::from_secs(20);
let mut requests = Vec::new();
let mut oauth_writes = 0;
let mut yaml_writes = 0;
while !server_finished.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
continue;
}
Err(error) => panic!("mock accept: {error}"),
};
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut bytes = Vec::new();
let (header_end, content_length) = loop {
let mut buffer = [0; 2048];
let read = stream.read(&mut buffer).unwrap();
assert!(read > 0);
bytes.extend_from_slice(&buffer[..read]);
if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
let header = String::from_utf8_lossy(&bytes[..end]);
let length: usize = header
.lines()
.find_map(|line| {
let (key, value) = line.split_once(':')?;
key.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse().unwrap())
})
.unwrap_or(0);
break (end + 4, length);
}
};
while bytes.len() < header_end + content_length {
let mut buffer = [0; 2048];
let read = stream.read(&mut buffer).unwrap();
assert!(read > 0);
bytes.extend_from_slice(&buffer[..read]);
}
let header = String::from_utf8_lossy(&bytes[..header_end]);
let first = header.lines().next().unwrap();
let mut parts = first.split_whitespace();
let method = parts.next().unwrap();
let path = parts.next().unwrap();
requests.push(format!("{method} {path}"));
let request_body = &bytes[header_end..header_end + content_length];
let mut status = "200 OK";
let mut response = "{\"status\":\"ok\"}".to_string();
if method == "GET" {
assert_eq!(path, "/v8/management/config.yaml");
response = if v8_response {
v8_yaml_from_legacy_json(&persisted)
} else {
serde_norway::to_string(&persisted).unwrap()
};
} else if path == "/v8/management/config/oauth/model-alias" {
oauth_writes += 1;
if failure == Failure::Rollback && oauth_writes == 2 {
status = "500 Internal Server Error";
} else {
persisted["oauth-model-alias"] =
serde_json::from_slice(request_body).unwrap();
if failure == Failure::OauthAfterWrite && oauth_writes == 1 {
status = "500 Internal Server Error";
}
}
} else {
yaml_writes += 1;
if (yaml_writes == 1
&& matches!(
failure,
Failure::YamlBeforeWrite | Failure::Rollback | Failure::ForeignChange
))
|| (yaml_writes == 2 && failure == Failure::SecondYamlBeforeWrite)
{
status = "500 Internal Server Error";
if failure == Failure::ForeignChange {
persisted["debug"] = serde_json::json!(true);
}
} else {
let suffix = path
.strip_prefix("/v8/management/config/")
.unwrap_or_else(|| panic!("unexpected management path: {path}"));
match (method, suffix) {
("PUT", "requests/payload") => {
persisted["payload"] =
serde_json::from_slice(request_body).unwrap();
}
("DELETE", "requests/payload") => {
persisted.as_object_mut().unwrap().remove("payload");
}
("PUT", provider_path)
if provider_path.starts_with("api-keys/") =>
{
let provider = provider_path
.strip_prefix("api-keys/")
.unwrap();
let legacy = V8_PROVIDER_FAMILIES
.iter()
.find_map(|(legacy, candidate)| {
(*candidate == provider).then_some(*legacy)
})
.unwrap();
let groups: serde_norway::Value =
serde_json::from_slice(request_body).unwrap();
if provider != "openai-compatibility" {
for group in groups.as_sequence().unwrap() {
for key in group["keys"].as_sequence().unwrap() {
assert!(key.get("name").is_none(), "v8 rejects credential names");
}
}
}
persisted[legacy] = serde_json::to_value(
flatten_v8_provider_groups(provider, &groups).unwrap(),
)
.unwrap();
}
("DELETE", provider_path)
if provider_path.starts_with("api-keys/") =>
{
let provider = provider_path
.strip_prefix("api-keys/")
.unwrap();
let legacy = V8_PROVIDER_FAMILIES
.iter()
.find_map(|(legacy, candidate)| {
(*candidate == provider).then_some(*legacy)
})
.unwrap();
persisted.as_object_mut().unwrap().remove(legacy);
}
_ => panic!("unexpected management request: {method} {path}"),
}
if yaml_writes == 1 && failure == Failure::YamlAfterWrite {
status = "500 Internal Server Error";
}
}
}
if status.starts_with("500") {
response = "{\"error\":\"write_failed\"}".to_string();
}
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap();
}
(persisted, requests)
});
Self {
config: GuiConfigFile {
port,
management_secret_key: "test-only".to_string(),
..Default::default()
},
finished,
server: Some(server),
}
}
fn finish(mut self) -> (serde_json::Value, Vec<String>) {
self.finished.store(true, Ordering::SeqCst);
self.server.take().unwrap().join().unwrap()
}
}
impl Drop for MockCore {
fn drop(&mut self) {
self.finished.store(true, Ordering::SeqCst);
if let Some(server) = self.server.take() {
let _ = server.join();
}
}
}
fn yaml_json(content: &str) -> serde_json::Value {
serde_json::to_value(serde_norway::from_str::<serde_norway::Value>(content).unwrap()).unwrap()
}
fn v8_yaml_from_legacy_json(legacy: &serde_json::Value) -> String {
let mut root = legacy.as_object().unwrap().clone();
root.insert("config-version".into(), serde_json::json!(8));
if let Some(client_keys) = root.remove("api-keys") {
root.entry("access")
.or_insert_with(|| serde_json::json!({}))["api-keys"] = client_keys;
}
if let Some(aliases) = root.remove("oauth-model-alias") {
root.entry("oauth")
.or_insert_with(|| serde_json::json!({}))["model-alias"] = aliases;
}
if let Some(payload) = root.remove("payload") {
root.entry("requests")
.or_insert_with(|| serde_json::json!({}))["payload"] = payload;
}
let mut upstreams = serde_json::Map::new();
for (legacy, provider) in V8_PROVIDER_FAMILIES {
let records: serde_norway::Value = serde_json::from_value(
root.remove(legacy).unwrap_or_else(|| serde_json::json!([])),
)
.unwrap();
upstreams.insert(
provider.into(),
serde_json::to_value(group_legacy_provider_records(provider, &records).unwrap())
.unwrap(),
);
}
root.insert("api-keys".into(), serde_json::Value::Object(upstreams));
serde_norway::to_string(&serde_json::Value::Object(root)).unwrap()
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_uses_only_targeted_writes_with_a_real_v8_config_view() {
let core = MockCore::new_v8(CURRENT, Failure::None);
let current = fetch_management_config_yaml(&core.config).await.unwrap();
let updated = current.replace("my-alias", "renamed");
put_management_alias_config_changes(&core.config, &current, &updated)
.await
.unwrap();
let (persisted, requests) = core.finish();
assert_eq!(persisted, yaml_json(&CURRENT.replace("my-alias", "renamed")));
assert!(requests
.iter()
.any(|request| request == "PUT /v8/management/config/oauth/model-alias"));
assert!(requests
.iter()
.any(|request| request == "PUT /v8/management/config/requests/payload"));
assert!(!requests
.iter()
.any(|request| request == "PUT /v8/management/config.yaml"));
}
#[tokio::test(flavor = "current_thread")]
async fn provider_alias_edits_round_trip_through_v8_key_groups() {
let initial = format!(
"{CURRENT}codex-api-key:\n - api-key: preserved-secret\n base-url: https://codex.example/v1\n models: [{{name: model-a}}]\n"
);
let core = MockCore::new_v8(&initial, Failure::None);
let current = fetch_management_config_yaml(&core.config).await.unwrap();
let updated = current.replace("name: model-a\n", "name: model-b\n");
put_management_alias_config_changes(&core.config, &current, &updated)
.await
.unwrap();
let (persisted, requests) = core.finish();
let mut expected = yaml_json(&initial);
expected["codex-api-key"][0]["models"][0]["name"] = serde_json::json!("model-b");
assert_eq!(persisted, expected);
assert_eq!(persisted["codex-api-key"][0]["api-key"], "preserved-secret");
assert!(requests
.iter()
.any(|request| request == "PUT /v8/management/config/api-keys/codex"));
}
#[tokio::test(flavor = "current_thread")]
async fn deepseek_alias_edits_keep_the_provider_name_out_of_v8_credentials() {
let initial = "codex-api-key:\n - name: DeepSeek\n api-key: preserved-secret\n base-url: https://gateway.example/v1\n models: [{name: deepseek-chat, alias: original}]\n";
let core = MockCore::new_v8(initial, Failure::None);
let current = fetch_management_config_yaml(&core.config).await.unwrap();
let updated = current.replace("alias: original", "alias: renamed");
put_management_alias_config_changes(&core.config, &current, &updated)
.await
.unwrap();
let (persisted, _) = core.finish();
let mut expected = yaml_json(initial);
expected["codex-api-key"][0]["models"][0]["alias"] = serde_json::json!("renamed");
assert_eq!(persisted, expected);
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_recovers_failures_before_and_after_writes() {
for failure in [
Failure::YamlBeforeWrite,
Failure::YamlAfterWrite,
Failure::OauthAfterWrite,
] {
let core = MockCore::new(CURRENT, failure);
let updated = CURRENT.replace("my-alias", "renamed");
let result = put_management_alias_config_changes(&core.config, CURRENT, &updated).await;
assert!(result.unwrap_err().contains("Original configuration was restored"));
let (persisted, requests) = core.finish();
assert_eq!(persisted, yaml_json(CURRENT), "{requests:?}");
assert_eq!(
requests
.iter()
.filter(|request| request.ends_with("oauth/model-alias"))
.count(),
2
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_restores_a_partial_v8_multi_path_write() {
let initial = format!(
"{CURRENT}codex-api-key:\n - api-key: preserved-secret\n models: [{{name: my-alias}}]\n"
);
let core = MockCore::new_v8(&initial, Failure::SecondYamlBeforeWrite);
let current = fetch_management_config_yaml(&core.config).await.unwrap();
let updated = current.replace("my-alias", "renamed");
assert!(put_management_alias_config_changes(&core.config, &current, &updated)
.await
.unwrap_err()
.contains("Original configuration was restored"));
let (persisted, requests) = core.finish();
assert_eq!(persisted, yaml_json(&initial));
assert!(requests
.iter()
.any(|request| request == "PUT /v8/management/config/requests/payload"));
assert!(requests
.iter()
.any(|request| request == "PUT /v8/management/config/api-keys/codex"));
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_reports_failed_rollback_without_claiming_restoration() {
let core = MockCore::new(CURRENT, Failure::Rollback);
let result = put_management_alias_config_changes(
&core.config,
CURRENT,
&CURRENT.replace("my-alias", "renamed"),
)
.await;
let error = result.unwrap_err();
assert!(error.contains("automatic restoration failed"));
assert!(error.contains("configuration may be partially written"));
let (persisted, _) = core.finish();
assert_eq!(
persisted["oauth-model-alias"]["codex"][0]["alias"],
"renamed"
);
assert_eq!(persisted["payload"], yaml_json(CURRENT)["payload"]);
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_does_not_rollback_over_external_changes() {
let core = MockCore::new(CURRENT, Failure::ForeignChange);
let result = put_management_alias_config_changes(
&core.config,
CURRENT,
&CURRENT.replace("my-alias", "renamed"),
)
.await;
assert!(result
.unwrap_err()
.contains("Other configuration changes were detected"));
let (persisted, requests) = core.finish();
assert_eq!(persisted["debug"], true);
assert_eq!(
requests
.iter()
.filter(|request| request.starts_with("PUT"))
.count(),
2
);
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_rejects_stale_snapshot_before_any_write() {
let core = MockCore::new(&format!("{CURRENT}debug: true\n"), Failure::None);
let result = put_management_alias_config_changes(
&core.config,
CURRENT,
&CURRENT.replace("my-alias", "renamed"),
)
.await;
assert!(result.unwrap_err().contains("Configuration changed"));
let (_, requests) = core.finish();
assert_eq!(requests, ["GET /v8/management/config.yaml"]);
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_succeeds_for_mixed_oauth_only_and_yaml_only_changes() {
for updated in [
CURRENT.replace("my-alias", "renamed"),
CURRENT.replacen("my-alias", "renamed", 1),
CURRENT.replace("high", "low"),
] {
let core = MockCore::new(CURRENT, Failure::None);
put_management_alias_config_changes(&core.config, CURRENT, &updated)
.await
.unwrap();
let (persisted, _) = core.finish();
assert_eq!(persisted, yaml_json(&updated));
}
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_serializes_concurrent_writers_and_rejects_the_stale_one() {
let core = MockCore::new(CURRENT, Failure::None);
let first = CURRENT.replace("my-alias", "first");
let second = CURRENT.replace("my-alias", "second");
let (a, b) = tokio::join!(
put_management_alias_config_changes(&core.config, CURRENT, &first),
put_management_alias_config_changes(&core.config, CURRENT, &second),
);
assert_ne!(a.is_ok(), b.is_ok());
let (persisted, requests) = core.finish();
assert_eq!(
persisted,
yaml_json(if a.is_ok() { &first } else { &second })
);
assert_eq!(
requests
.iter()
.filter(|request| request.starts_with("PUT"))
.count(),
2
);
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_can_retry_after_restoration_reformats_yaml() {
let context = model_alias_edit_context(CURRENT, "my-alias", &[]).unwrap();
let core = MockCore::new(CURRENT, Failure::YamlBeforeWrite);
let updated = CURRENT.replace("my-alias", "renamed");
assert!(
put_management_alias_config_changes(&core.config, CURRENT, &updated)
.await
.unwrap_err()
.contains("Original configuration was restored")
);
let restored = fetch_management_config_yaml(&core.config).await.unwrap();
validate_model_alias_revision(&restored, Some(&context.revision)).unwrap();
let source = resolve_model_alias_edit_source(&restored, "my-alias", &[]).unwrap();
let updated =
edit_model_alias_in_yaml(&restored, "my-alias", &source, "renamed", "high", false).unwrap();
put_management_alias_config_changes(&core.config, &restored, &updated)
.await
.unwrap();
let (persisted, _) = core.finish();
assert_eq!(persisted, yaml_json(&updated));
}
struct DesktopRestoreFixture {
home: PathBuf,
id: String,
core_a: String,
core_b: String,
}
impl DesktopRestoreFixture {
fn new() -> Self {
Self::build(false)
}
fn build(custom: bool) -> Self {
Self::build_with_direct_model(custom, false)
}
fn build_with_direct_model(custom: bool, direct: bool) -> Self {
let home = super::support::agent_test_home("desktop-restore");
let models = super::support::test_agent_models(&["model-a", "model-b"]);
let initial = "openai-compatibility:\n - name: provider\n models: [{name: model-a}, {name: model-b}]\n";
let mut id = None;
let mut core_versions = Vec::new();
for name in ["model-a", "model-b"] {
let mut mappings = ClaudeDesktopModelMappings::all(name);
if custom {
mappings.desktop_models = Some(vec![ClaudeDesktopModelMapping {
model: name.into(),
alias: if direct && name == "model-a" { String::new() } else { format!("claude-sonnet-4-6-{name}") }, context_1m: true,
}]);
mappings.sonnet = mappings.desktop_models.as_ref().unwrap()[0].source_or_alias().to_string();
}
core_versions.push(
ensure_claude_desktop_model_aliases_in_yaml(initial, &mappings, &models).unwrap(),
);
let _result = apply_agent_configuration_with_oauth(
AgentClient::ClaudeDesktop,
&home,
8317,
"test-key",
name,
AgentConfigurationOptions {
models: &models,
codex_catalog: None,
oauth_configuration: false,
claude_code_model_mappings: None,
claude_desktop_model_mappings: Some(&mappings),
},
)
.unwrap();
if name == "model-a" {
id = Some(create_backup("claude-desktop", &home).unwrap().id);
}
}
Self {
home,
id: id.unwrap(),
core_a: core_versions.remove(0),
core_b: core_versions.remove(0),
}
}
}
impl Drop for DesktopRestoreFixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.home);
}
}
#[tokio::test(flavor = "current_thread")]
async fn desktop_backup_restore_updates_core_routes_and_mapping_metadata() {
let fixture = DesktopRestoreFixture::new();
let core = MockCore::new(&fixture.core_b, Failure::None);
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id)
.await
.unwrap();
assert!(!plan.preview.differences.is_empty());
let revision = plan.preview.revision.clone();
execute_restore_plan(&core.config, plan, &revision)
.await
.unwrap();
assert_eq!(
current_desktop_mappings(&fixture.home)
.unwrap()
.opus,
"model-a"
);
let (persisted, _) = core.finish();
assert_eq!(persisted, yaml_json(&fixture.core_a));
}
#[tokio::test(flavor = "current_thread")]
async fn desktop_legacy_backup_restores_the_routes_referenced_by_its_profile() {
for source in ["claude-sonnet-test", LEGACY_CLAUDE_DESKTOP_MODEL_IDS[1]] {
let fixture = DesktopRestoreFixture::new();
let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home);
let before = config_images(&paths).unwrap();
let mut after = before.clone();
let mut profile: serde_json::Value = serde_json::from_slice(after[2].1.as_ref().unwrap()).unwrap();
profile["inferenceModels"] = serde_json::json!(LEGACY_CLAUDE_DESKTOP_MODEL_IDS
.map(|name| serde_json::json!({"name": name})));
after[2].1 = Some(serde_json::to_vec(&profile).unwrap());
let mappings = ClaudeDesktopModelMappings::all(source);
commit_config_with_mappings("claude-desktop", &paths, &before, &after,
"update", Some(source.into()), Some(mappings.clone())).unwrap();
let backup = create_backup("claude-desktop", &fixture.home).unwrap();
for routes_exist in [false, true] {
let initial = format!("{}claude-api-key:\n - models: [{{name: {source}}}]\n", fixture.core_b);
let mut initial = yaml_json(&initial);
if routes_exist {
for route in LEGACY_CLAUDE_DESKTOP_MODEL_IDS {
if route != source {
initial["claude-api-key"][0]["models"].as_array_mut().unwrap()
.push(serde_json::json!({
"name": source,
"alias": route,
"display-name": managed_claude_alias_display_name(route).unwrap(),
}));
}
}
}
let core = MockCore::new(&serde_norway::to_string(&initial).unwrap(), Failure::None);
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &backup.id)
.await.unwrap();
let revision = plan.preview.revision.clone();
execute_restore_plan(&core.config, plan, &revision).await.unwrap();
assert_eq!(config_images(&paths).unwrap(), after);
assert_eq!(current_desktop_mappings(&fixture.home).unwrap(), mappings);
let (persisted, _) = core.finish();
let models = persisted["claude-api-key"][0]["models"].as_array().unwrap();
for route in LEGACY_CLAUDE_DESKTOP_MODEL_IDS {
assert!(models.iter().any(|model| model["name"] == source
&& model.get("alias").unwrap_or(&model["name"]) == route), "missing {route}: {persisted}");
}
assert!(models.iter().any(|model| model["name"] == source && model.get("alias").is_none()));
assert!(persisted["openai-compatibility"][0]["models"].as_array().unwrap()
.iter().all(|model| model.get("alias").is_none()));
}
}
}
#[tokio::test(flavor = "current_thread")]
async fn desktop_backup_keeps_a_direct_legacy_id_alongside_a_new_family_route() {
let fixture = DesktopRestoreFixture::new();
let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home);
let before = config_images(&paths).unwrap();
let mut after = before.clone();
let direct = LEGACY_CLAUDE_DESKTOP_MODEL_IDS[0];
let mut profile: serde_json::Value = serde_json::from_slice(after[2].1.as_ref().unwrap()).unwrap();
profile["inferenceModels"] = serde_json::json!([
{"name": CLAUDE_DESKTOP_OPUS_MODEL_ID, "labelOverride": "gpt-one"},
{"name": direct},
{"name": CLAUDE_DESKTOP_HAIKU_MODEL_ID, "labelOverride": "gpt-two"},
]);
after[2].1 = Some(serde_json::to_vec(&profile).unwrap());
let mappings = ClaudeDesktopModelMappings {
opus: "gpt-one".into(),
sonnet: direct.into(),
haiku: "gpt-two".into(),
..ClaudeDesktopModelMappings::all("")
};
commit_config_with_mappings("claude-desktop", &paths, &before, &after,
"update", Some(direct.into()), Some(mappings)).unwrap();
let backup = create_backup("claude-desktop", &fixture.home).unwrap();
let initial = format!("openai-compatibility:\n - name: provider\n models: [{{name: gpt-one}}, {{name: gpt-two}}, {{name: {direct}}}]\n");
let core = MockCore::new(&initial, Failure::None);
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &backup.id)
.await.unwrap();
let revision = plan.preview.revision.clone();
execute_restore_plan(&core.config, plan, &revision).await.unwrap();
assert_eq!(config_images(&paths).unwrap(), after);
let (persisted, _) = core.finish();
let models = persisted["openai-compatibility"][0]["models"].as_array().unwrap();
assert!(models.iter().any(|model| model["name"] == direct && model.get("alias").is_none()));
for (alias, source) in [(CLAUDE_DESKTOP_OPUS_MODEL_ID, "gpt-one"), (CLAUDE_DESKTOP_HAIKU_MODEL_ID, "gpt-two")] {
assert!(models.iter().any(|model| model["name"] == source && model["alias"] == alias));
}
}
#[tokio::test(flavor = "current_thread")]
async fn desktop_custom_backup_restores_aliases_profile_and_mapping_metadata() {
let fixture = DesktopRestoreFixture::build(true);
let core = MockCore::new(&fixture.core_b, Failure::None);
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id)
.await.unwrap();
assert!(plan.preview.differences.iter().any(|difference|
difference.field == "modelMappings.claude-sonnet-4-6-model-a"));
assert!(plan.preview.differences.iter().any(|difference|
difference.field == "modelMappings.claude-sonnet-4-6-model-b"));
let revision = plan.preview.revision.clone();
execute_restore_plan(&core.config, plan, &revision).await.unwrap();
let mappings = current_desktop_mappings(&fixture.home).unwrap();
let entries = mappings.desktop_models.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].model, "model-a");
assert_eq!(entries[0].alias, "claude-sonnet-4-6-model-a");
assert!(entries[0].context_1m);
let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home);
let profile: serde_json::Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap();
assert_eq!(profile["inferenceModels"][0]["name"], entries[0].alias);
assert_eq!(profile["inferenceModels"][0]["labelOverride"], "model-a");
let (persisted, _) = core.finish();
assert_eq!(persisted, yaml_json(&fixture.core_a));
}
#[tokio::test(flavor = "current_thread")]
async fn desktop_direct_model_backup_restores_without_an_alias_route() {
let fixture = DesktopRestoreFixture::build_with_direct_model(true, true);
let core = MockCore::new(&fixture.core_b, Failure::None);
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id)
.await.unwrap();
let revision = plan.preview.revision.clone();
execute_restore_plan(&core.config, plan, &revision).await.unwrap();
let mappings = current_desktop_mappings(&fixture.home).unwrap();
let entry = &mappings.desktop_models.as_ref().unwrap()[0];
assert!(entry.alias.is_empty());
assert_eq!(entry.model, "model-a");
assert_eq!(mappings.sonnet, entry.model);
let paths = agent_config_paths(AgentClient::ClaudeDesktop, &fixture.home);
let profile: serde_json::Value = serde_json::from_slice(&fs::read(&paths[2]).unwrap()).unwrap();
assert_eq!(profile["inferenceModels"][0]["name"], entry.model);
assert_eq!(profile["inferenceModels"][0]["labelOverride"], entry.model);
let (persisted, _) = core.finish();
assert_eq!(persisted, yaml_json(&fixture.core_a));
assert!(!persisted.to_string().contains("\"alias\""));
}
#[tokio::test(flavor = "current_thread")]
async fn desktop_backup_restore_rejects_core_changes_since_preview() {
let fixture = DesktopRestoreFixture::new();
let core = MockCore::new(&fixture.core_b, Failure::None);
let preview = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id)
.await
.unwrap();
let changed = fixture
.core_b
.replace("model-b", "externally-changed-model");
put_management_alias_config_changes(&core.config, &fixture.core_b, &changed)
.await
.unwrap();
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id)
.await
.unwrap();
assert!(
execute_restore_plan(&core.config, plan, &preview.preview.revision)
.await
.unwrap_err()
.contains("changed after preview")
);
assert_eq!(
current_desktop_mappings(&fixture.home)
.unwrap()
.opus,
"model-b"
);
assert_eq!(core.finish().0, yaml_json(&changed));
}
#[tokio::test(flavor = "current_thread")]
async fn desktop_backup_restore_rolls_back_core_when_local_files_change() {
let fixture = DesktopRestoreFixture::new();
let core = MockCore::new(&fixture.core_b, Failure::None);
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id)
.await
.unwrap();
let revision = plan.preview.revision.clone();
let paths = config_paths("claude-desktop", &fixture.home).unwrap();
fs::write(&paths[0], r#"{"deploymentMode":"3p","external":true}"#).unwrap();
let before = config_images(&paths).unwrap();
assert!(execute_restore_plan(&core.config, plan, &revision)
.await
.unwrap_err()
.contains("Original configuration was restored"));
assert_eq!(config_images(&paths).unwrap(), before);
assert_eq!(
current_desktop_mappings(&fixture.home)
.unwrap()
.opus,
"model-b"
);
assert_eq!(core.finish().0, yaml_json(&fixture.core_b));
}
#[tokio::test(flavor = "current_thread")]
async fn desktop_backup_restore_does_not_write_local_files_when_core_save_fails() {
let fixture = DesktopRestoreFixture::new();
let core = MockCore::new(&fixture.core_b, Failure::YamlAfterWrite);
let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id)
.await
.unwrap();
let revision = plan.preview.revision.clone();
let paths = config_paths("claude-desktop", &fixture.home).unwrap();
let before = config_images(&paths).unwrap();
assert!(execute_restore_plan(&core.config, plan, &revision)
.await
.is_err());
assert_eq!(config_images(&paths).unwrap(), before);
assert_eq!(
current_desktop_mappings(&fixture.home)
.unwrap()
.opus,
"model-b"
);
assert_eq!(core.finish().0, yaml_json(&fixture.core_b));
}
#[tokio::test(flavor = "current_thread")]
async fn alias_transaction_rolls_back_when_followup_commit_fails() {
let core = MockCore::new(CURRENT, Failure::None);
let updated = CURRENT.replace("my-alias", "renamed");
let result = commit_management_alias_config_changes(&core.config, CURRENT, &updated, || {
Err::<(), _>("local history write failed".into())
})
.await;
assert!(result.unwrap_err().contains("local history write failed"));
assert_eq!(core.finish().0, yaml_json(CURRENT));
}
#[tokio::test(flavor = "current_thread")]
async fn desktop_update_and_template_preserve_core_access_and_rollback_failed_local_changes() {
let initial = "debug: true\napi-keys: [client-key]\nopenai-compatibility:\n - name: provider\n base-url: https://provider.test/v1\n api-key-entries: [{api-key: provider-secret}]\n custom: {nested: keep}\n models: [{name: model-a}]\ncodex-api-key:\n - api-key: unrelated-secret\n base-url: https://codex.test\n models: [{name: unrelated-model}]\n";
let legacy = initial.replace(
"{name: model-a}",
&format!("{{name: model-a, alias: {CLAUDE_DESKTOP_OPUS_MODEL_ID}}}"),
);
let models = super::support::test_agent_models(&["model-a"]);
let mappings = ClaudeDesktopModelMappings::all("model-a");
for initial in [initial, legacy.as_str()] {
for template in [false, true] {
for fail in [false, true] {
let home = super::support::agent_test_home("desktop-protected-update");
let paths = config_paths("claude-desktop", &home).unwrap();
if template {
fs::create_dir_all(paths[0].parent().unwrap()).unwrap();
fs::write(&paths[0], "broken-client-config").unwrap();
}
let before = config_images(&paths).unwrap();
let core = MockCore::new(initial, Failure::None);
let result = commit_agent_with_core(&core.config, Some(&mappings), &models, || {
if fail {
return Err("local write rejected".into());
}
if template {
reset_agent_configuration_to_default_with_oauth(AgentDefaultConfiguration {
client: AgentClient::ClaudeDesktop,
home: &home,
port: 8317,
api_key: "client-key",
model: "model-a",
models: &models,
codex_catalog: None,
oauth_configuration: false,
claude_code_model_mappings: None,
claude_desktop_model_mappings: Some(&mappings),
})
} else {
apply_agent_configuration_with_oauth(
AgentClient::ClaudeDesktop,
&home,
8317,
"client-key",
"model-a",
AgentConfigurationOptions {
models: &models,
codex_catalog: None,
oauth_configuration: false,
claude_code_model_mappings: None,
claude_desktop_model_mappings: Some(&mappings),
},
)
}
})
.await;
let (persisted, _) = core.finish();
if fail {
assert!(result.is_err());
assert_eq!(persisted, yaml_json(initial));
assert_eq!(before, config_images(&paths).unwrap());
} else {
result.unwrap();
assert_eq!(persisted["api-keys"], yaml_json(initial)["api-keys"]);
assert_eq!(
persisted["codex-api-key"],
yaml_json(initial)["codex-api-key"]
);
let mut provider = persisted["openai-compatibility"][0].clone();
provider.as_object_mut().unwrap().remove("models");
let mut original = yaml_json(initial)["openai-compatibility"][0].clone();
original.as_object_mut().unwrap().remove("models");
assert_eq!(provider, original);
assert_eq!(persisted["debug"], true);
assert_eq!(current_desktop_mappings(&home).unwrap().opus, "model-a");
}
assert_eq!(test_backup_count(AgentClient::ClaudeDesktop, &home), 0);
fs::remove_dir_all(home).unwrap();
}
}
}
}
#[tokio::test(flavor = "current_thread")]
async fn alias_transaction_checks_core_before_a_local_only_commit() {
let current = format!("{CURRENT}debug: true\n");
let core = MockCore::new(&current, Failure::None);
let committed = AtomicBool::new(false);
let result = commit_management_alias_config_changes(&core.config, CURRENT, CURRENT, || {
committed.store(true, Ordering::SeqCst);
Ok(())
})
.await;
assert!(result.is_err());
assert!(!committed.load(Ordering::SeqCst));
assert_eq!(core.finish().0, yaml_json(&current));
}