Files
cc-switch/src-tauri/src/deeplink/tests.rs
T
Jason db93ac2083 fix(grokbuild): point session_summary at the active provider and keep a link's model profile
Session titles: when `models.session_summary` is unset, Grok Build
(verified on 1.0.50) generates titles with the built-in `grok-4.6`
model but sends the request to the default model table's endpoint, so
third-party upstreams reject it and title generation fails. Switching
never wrote this key, so fixing the row templates alone would not
reach live. The Grok switch patch now makes session_summary follow the
provider when it is unset or names a table CC Switch wrote (the
target, the write record, or a table retired in this switch): it points
at the target table, or is removed when switching to the official
account. A user's own choice (a built-in model or their own table) is
left alone.

Deep links: a Grok Build link carrying a config.toml no longer gets
rebuilt into a fixed template that dropped the profile's display name,
description, API backend and context window. The table named by
`[models] default` is kept through an allowlist of Grok 1.0.50 profile
fields; the endpoint, key and model come from the link parameters.
Links whose table uses auth_provider, env_http_headers or
mtls_cert_dir are rejected, and env_key is still dropped. Only one
model table is imported, since a provider owns one table in live.
Links without a config keep the old template byte for byte.

Fixes #7003
Refs #6457
2026-10-12 23:26:43 +08:00

1526 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Deep link module tests
use super::mcp::parse_mcp_apps;
use super::parser::parse_deeplink_url;
use super::prompt::import_prompt_from_deeplink;
use super::provider::parse_and_merge_config;
use super::utils::{infer_homepage_from_endpoint, validate_url};
use super::DeepLinkImportRequest;
use crate::AppType;
use crate::{store::AppState, Database};
use base64::prelude::*;
use std::{env, ffi::OsString, sync::Arc};
struct TestHomeGuard {
_dir: tempfile::TempDir,
original_home: Option<OsString>,
original_userprofile: Option<OsString>,
original_test_home: Option<OsString>,
}
impl TestHomeGuard {
fn new() -> Self {
let dir = tempfile::tempdir().expect("create isolated test home");
let original_home = env::var_os("HOME");
let original_userprofile = env::var_os("USERPROFILE");
let original_test_home = env::var_os("CC_SWITCH_TEST_HOME");
env::set_var("HOME", dir.path());
env::set_var("USERPROFILE", dir.path());
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
Self {
_dir: dir,
original_home,
original_userprofile,
original_test_home,
}
}
}
impl Drop for TestHomeGuard {
fn drop(&mut self) {
match &self.original_test_home {
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
None => env::remove_var("CC_SWITCH_TEST_HOME"),
}
match &self.original_userprofile {
Some(value) => env::set_var("USERPROFILE", value),
None => env::remove_var("USERPROFILE"),
}
match &self.original_home {
Some(value) => env::set_var("HOME", value),
None => env::remove_var("HOME"),
}
}
}
// =============================================================================
// Parser Tests
// =============================================================================
#[test]
fn test_parse_valid_claude_deeplink() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123&icon=claude";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.version, "v1");
assert_eq!(request.resource, "provider");
assert_eq!(request.app, Some("claude".to_string()));
assert_eq!(request.name, Some("Test Provider".to_string()));
assert_eq!(request.homepage, Some("https://example.com".to_string()));
assert_eq!(
request.endpoint,
Some("https://api.example.com".to_string())
);
assert_eq!(request.api_key, Some("sk-test-123".to_string()));
assert_eq!(request.icon, Some("claude".to_string()));
}
#[test]
fn test_parse_deeplink_with_notes() {
let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123&notes=Test%20notes";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.notes, Some("Test notes".to_string()));
}
#[test]
fn pi_provider_deeplink_is_not_a_second_add_provider_entry() {
let url = "ccswitch://v1/import?resource=provider&app=pi&name=Pi";
assert!(parse_deeplink_url(url).is_err());
}
#[test]
fn test_parse_grokbuild_provider() {
use super::provider::build_provider_from_request;
let url = "ccswitch://v1/import?resource=provider&app=grokbuild&name=Grok%20Relay&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=secret&model=grok-4.5";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.app.as_deref(), Some("grokbuild"));
assert_eq!(request.name.as_deref(), Some("Grok Relay"));
assert_eq!(
request.endpoint.as_deref(),
Some("https://api.example.com/v1")
);
assert_eq!(request.api_key.as_deref(), Some("secret"));
assert_eq!(request.model.as_deref(), Some("grok-4.5"));
let provider = build_provider_from_request(&AppType::GrokBuild, &request).unwrap();
let config = provider.settings_config["config"].as_str().unwrap();
let document = config.parse::<toml::Value>().unwrap();
let model = &document["model"]["grok-4.5"];
assert_eq!(document["models"]["default"].as_str(), Some("grok-4.5"));
assert_eq!(
model["base_url"].as_str(),
Some("https://api.example.com/v1")
);
assert_eq!(model["name"].as_str(), Some("Grok Relay"));
assert_eq!(model["api_key"].as_str(), Some("secret"));
assert_eq!(model["api_backend"].as_str(), Some("responses"));
assert_eq!(model["context_window"].as_integer(), Some(500_000));
}
#[test]
fn test_parse_invalid_scheme() {
let url = "https://v1/import?resource=provider&app=claude&name=Test";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid scheme"));
}
#[test]
fn test_parse_unsupported_version() {
let url = "ccswitch://v2/import?resource=provider&app=claude&name=Test";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Unsupported protocol version"));
}
#[test]
fn test_parse_missing_required_field() {
// Name is still required even in v3.8+ (only homepage/endpoint/apiKey are optional)
let url = "ccswitch://v1/import?resource=provider&app=claude";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing 'name' parameter"));
}
/// configUrl 没实现,必须在解析阶段就报错:放行的话,用户点了确认导入才失败
#[test]
fn test_parse_rejects_config_url_up_front() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&configUrl=https%3A%2F%2Fexample.com%2Fc.json";
let err = parse_deeplink_url(url).unwrap_err();
assert!(err.to_string().contains("configUrl"), "{err}");
}
/// 下游 parse_mcp_apps 与 McpService 都支持 Pi / MiniMax Code,解析层不能先把它们拦掉
#[test]
fn test_parse_mcp_deeplink_accepts_pi_and_mcode() {
let config = BASE64_URL_SAFE_NO_PAD.encode(r#"{"mcpServers":{"fetch":{"command":"uvx"}}}"#);
let url = format!("ccswitch://v1/import?resource=mcp&apps=claude,pi,mcode&config={config}");
let request = parse_deeplink_url(&url).expect("pi and mcode are valid MCP targets");
let apps = parse_mcp_apps(request.apps.as_deref().unwrap()).unwrap();
assert!(apps.claude && apps.pi && apps.mcode);
}
#[test]
fn test_parse_prompt_deeplink_accepts_mcode() {
let url = "ccswitch://v1/import?resource=prompt&app=mcode&name=Rules&content=IyBoaQ%3D%3D";
let request = parse_deeplink_url(url).expect("MiniMax Code has a prompt panel");
assert_eq!(request.app.as_deref(), Some("mcode"));
}
/// 读取不清除,按编号确认才清除:前端读到后、显示前被打断(effect 重建、窗口销毁),
/// 下次读取仍能拿到;期间来了新链接,迟到的旧确认不能把新链接清掉
#[test]
#[serial_test::serial]
fn test_pending_deeplink_survives_peek_and_clears_only_on_matching_ack() {
use super::{
ack_pending_deeplink, peek_pending_deeplink, set_pending_deeplink, PendingDeepLink,
};
let link = |name: &str| PendingDeepLink::Import {
request: Box::new(
parse_deeplink_url(&format!(
"ccswitch://v1/import?resource=provider&app=claude&name={name}"
))
.expect("valid link"),
),
};
set_pending_deeplink(link("A"));
let first = peek_pending_deeplink().expect("pending link");
let json = serde_json::to_value(&first).unwrap();
assert_eq!(json["link"]["kind"], "import");
assert_eq!(json["link"]["request"]["name"], "A");
assert_eq!(
peek_pending_deeplink().map(|entry| entry.id),
Some(first.id),
"peek must not clear the link"
);
set_pending_deeplink(link("B"));
ack_pending_deeplink(first.id);
let second = peek_pending_deeplink().expect("a stale ack must not clear the newer link");
assert_ne!(second.id, first.id);
ack_pending_deeplink(second.id);
assert!(peek_pending_deeplink().is_none());
}
// =============================================================================
// Utils Tests
// =============================================================================
#[test]
fn test_validate_invalid_url() {
let result = validate_url("not-a-url", "test");
assert!(result.is_err());
}
#[test]
fn test_validate_invalid_scheme() {
let result = validate_url("ftp://example.com", "test");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must be http or https"));
}
#[test]
fn test_infer_homepage() {
assert_eq!(
infer_homepage_from_endpoint("https://api.anthropic.com/v1"),
Some("https://anthropic.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://api-test.company.com/v1"),
Some("https://test.company.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://example.com"),
Some("https://example.com".to_string())
);
}
// =============================================================================
// Provider Tests
// =============================================================================
#[test]
fn test_build_gemini_provider_with_model() {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("gemini".to_string()),
name: Some("Test Gemini".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com".to_string()),
api_key: Some("test-api-key".to_string()),
icon: None,
model: Some("gemini-2.0-flash".to_string()),
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
// Verify provider basic info
assert_eq!(provider.name, "Test Gemini");
assert_eq!(
provider.website_url,
Some("https://example.com".to_string())
);
// Verify settings_config structure
let env = provider.settings_config["env"].as_object().unwrap();
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
assert_eq!(env["GEMINI_MODEL"], "gemini-2.0-flash");
}
#[test]
fn test_build_gemini_provider_without_model() {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("gemini".to_string()),
name: Some("Test Gemini".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com".to_string()),
api_key: Some("test-api-key".to_string()),
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
let env = provider.settings_config["env"].as_object().unwrap();
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
// Model should not be present
assert!(env.get("GEMINI_MODEL").is_none());
}
#[test]
fn test_deeplink_usage_script_does_not_copy_provider_credentials() {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test Claude".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com/v1/".to_string()),
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: Some(true),
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should be created");
assert!(script.enabled);
assert_eq!(script.api_key, None);
assert_eq!(script.base_url, None);
}
/// 构造一个只带用量脚本字段的 provider 请求,其余保持最小。
fn usage_script_request(code: &str, usage_enabled: Option<bool>) -> DeepLinkImportRequest {
DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test Claude".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com/v1/".to_string()),
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled,
usage_script: Some(BASE64_STANDARD.encode(code)),
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
}
}
#[test]
fn test_deeplink_usage_script_is_enabled_by_default_when_the_link_carries_code() {
use super::provider::build_provider_from_request;
// 站长放脚本就是为了让用户看到余额;链接没写 usageEnabled 时,带了脚本即启用。
// 前端确认框的「已启用」徽章按同一规则显示。
let code = "export async function query() { return { cost: 0 }; }";
let request = usage_script_request(code, None);
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should be created");
assert!(script.enabled, "带了脚本、没写 usageEnabled 时应默认启用");
assert_eq!(script.code, code);
}
#[test]
fn test_deeplink_usage_script_stays_disabled_when_the_link_opts_out() {
use super::provider::build_provider_from_request;
let code = "export async function query() { return { cost: 0 }; }";
let request = usage_script_request(code, Some(false));
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should still be created");
assert!(!script.enabled, "usageEnabled=false 必须照办");
// 代码仍要保留:用户之后可在应用内手动开启。
assert_eq!(script.code, code);
}
#[test]
fn test_deeplink_usage_fields_without_a_script_are_not_enabled_by_default() {
use super::provider::build_provider_from_request;
// 没有脚本就没有东西可跑:只带凭据类字段的链接不应默认启用。
let mut request = usage_script_request("", None);
request.usage_script = None;
request.usage_access_token = Some("token".to_string());
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage fields should still be stored");
assert!(!script.enabled);
}
#[test]
fn test_deeplink_usage_script_honors_an_explicit_enable_request_from_the_link() {
use super::provider::build_provider_from_request;
// `usageEnabled=true` 是**链接作者**的请求,不是用户的选择——用户的同意体现在
// 看过确认框里完整的脚本正文与启用状态之后点了导入。
let code = "export async function query() { return { cost: 0 }; }";
let request = usage_script_request(code, Some(true));
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should be created");
assert!(script.enabled);
assert_eq!(script.code, code);
}
#[test]
fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test Claude".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com/v1/".to_string()),
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: Some(true),
usage_script: None,
usage_api_key: Some(" sk-main ".to_string()),
usage_base_url: Some(" https://api.example.com/v1/ ".to_string()),
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should be created");
assert_eq!(script.api_key, None);
assert_eq!(script.base_url, None);
}
#[test]
fn test_deeplink_usage_script_preserves_distinct_usage_credentials() {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test Claude".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com/v1".to_string()),
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: Some(true),
usage_script: None,
usage_api_key: Some(" sk-usage ".to_string()),
usage_base_url: Some(" https://usage.example/api/ ".to_string()),
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should be created");
assert_eq!(script.api_key.as_deref(), Some("sk-usage"));
assert_eq!(
script.base_url.as_deref(),
Some("https://usage.example/api")
);
}
#[test]
fn test_parse_and_merge_config_claude() {
// Prepare Base64 encoded Claude config
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1","ANTHROPIC_MODEL":"claude-sonnet-4.5"}}"#;
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test".to_string()),
homepage: None,
endpoint: None,
api_key: None,
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let merged = parse_and_merge_config(&request).unwrap();
// Should auto-fill from config
assert_eq!(merged.api_key, Some("sk-ant-xxx".to_string()));
assert_eq!(
merged.endpoint,
Some("https://api.anthropic.com/v1".to_string())
);
assert_eq!(merged.homepage, Some("https://anthropic.com".to_string()));
assert_eq!(merged.model, Some("claude-sonnet-4.5".to_string()));
}
#[test]
fn test_parse_and_merge_config_codex_uses_bearer_token() {
let config_toml = r#"model_provider = "rightcode"
model = "gpt-5-codex"
[model_providers.rightcode]
base_url = "https://rightcode.example/v1"
wire_api = "responses"
experimental_bearer_token = "sk-rightcode"
"#;
let config_json = serde_json::json!({
"auth": {},
"config": config_toml,
})
.to_string();
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("codex".to_string()),
name: Some("RightCode".to_string()),
config: Some(config_b64),
config_format: Some("json".to_string()),
..Default::default()
};
let merged = parse_and_merge_config(&request).unwrap();
assert_eq!(merged.api_key, Some("sk-rightcode".to_string()));
assert_eq!(
merged.endpoint,
Some("https://rightcode.example/v1".to_string())
);
assert_eq!(
merged.homepage,
Some("https://rightcode.example".to_string())
);
assert_eq!(merged.model, Some("gpt-5-codex".to_string()));
}
#[test]
fn test_parse_and_merge_config_grokbuild() {
let config_toml = r#"[models]
default = "grok-profile"
[model."grok-profile"]
model = "grok-upstream"
base_url = "https://grok.example/v1"
name = "Grok Relay"
api_key = "sk-grok"
api_backend = "responses"
context_window = 500000
"#;
let config_json = serde_json::json!({ "config": config_toml }).to_string();
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("grokbuild".to_string()),
name: Some("Grok Relay".to_string()),
config: Some(BASE64_STANDARD.encode(config_json.as_bytes())),
config_format: Some("json".to_string()),
..Default::default()
};
let merged = parse_and_merge_config(&request).expect("merge Grok Build config");
assert_eq!(merged.api_key.as_deref(), Some("sk-grok"));
assert_eq!(merged.endpoint.as_deref(), Some("https://grok.example/v1"));
assert_eq!(merged.model.as_deref(), Some("grok-upstream"));
assert_eq!(merged.homepage.as_deref(), Some("https://grok.example"));
}
#[test]
fn test_parse_and_merge_config_url_override() {
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test".to_string()),
homepage: None,
endpoint: None,
api_key: Some("sk-new".to_string()), // URL param should override
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let merged = parse_and_merge_config(&request).unwrap();
// URL param should take priority
assert_eq!(merged.api_key, Some("sk-new".to_string()));
// Config file value should be used
assert_eq!(
merged.endpoint,
Some("https://api.anthropic.com/v1".to_string())
);
}
#[test]
fn test_build_claude_provider_preserves_custom_env_fields() {
// Regression test for: deeplink import dropped non-standard env fields
// such as ANTHROPIC_CUSTOM_HEADERS, even though the preview dialog
// showed them. The preview and the actual persisted provider must
// contain the same env keys.
use super::provider::build_provider_from_request;
let config_json = r#"{"env":{
"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx",
"ANTHROPIC_BASE_URL":"https://api.example.com",
"ANTHROPIC_CUSTOM_HEADERS":"Cookie: session=abc",
"API_TIMEOUT_MS":"3000000",
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS":"1",
"ANTHROPIC_DEFAULT_HAIKU_MODEL":"haiku-from-config"
}}"#;
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("My Provider".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com".to_string()),
api_key: Some("sk-ant-xxx".to_string()),
icon: None,
// URL param: must win over the same key in config (haiku-from-config)
model: Some("main-model".to_string()),
notes: None,
haiku_model: Some("haiku-from-url".to_string()),
sonnet_model: None,
opus_model: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let env = provider.settings_config["env"].as_object().unwrap();
// Custom env fields from `config` must survive import
assert_eq!(env["ANTHROPIC_CUSTOM_HEADERS"], "Cookie: session=abc");
assert_eq!(env["API_TIMEOUT_MS"], "3000000");
assert_eq!(env["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"], "1");
// Standard fields from URL params win over config
assert_eq!(env["ANTHROPIC_AUTH_TOKEN"], "sk-ant-xxx");
assert_eq!(env["ANTHROPIC_BASE_URL"], "https://api.example.com");
assert_eq!(env["ANTHROPIC_MODEL"], "main-model");
assert_eq!(env["ANTHROPIC_DEFAULT_HAIKU_MODEL"], "haiku-from-url");
}
#[test]
fn test_build_claude_provider_without_config_unchanged() {
// Backward compatibility: deeplinks without a `config` field still
// produce exactly the same env shape as before — only the standard
// ANTHROPIC_* keys, nothing else.
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Plain".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com".to_string()),
api_key: Some("sk".to_string()),
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled: None,
usage_script: None,
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
};
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let env = provider.settings_config["env"].as_object().unwrap();
assert_eq!(env["ANTHROPIC_AUTH_TOKEN"], "sk");
assert_eq!(env["ANTHROPIC_BASE_URL"], "https://api.example.com");
// No extras leaked in
assert_eq!(env.len(), 2);
}
// =============================================================================
// Prompt Tests
// =============================================================================
// Integration-style unit test: prompt import reaches PromptService and resolves
// live config file paths, so HOME must be isolated before it runs.
#[test]
#[serial_test::serial]
fn test_import_prompt_allows_space_in_base64_content() {
let _test_home = TestHomeGuard::new();
let url = "ccswitch://v1/import?resource=prompt&app=codex&name=PromptPlus&content=Pj4+";
let request = parse_deeplink_url(url).unwrap();
// URL decoded content may have "+" become space
assert_eq!(request.content.as_deref(), Some("Pj4 "));
let db = Arc::new(Database::memory().expect("create memory db"));
let state = AppState::new(db.clone());
let prompt_id = import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
let prompts = state.db.get_prompts("codex").expect("get prompts");
let prompt = prompts.get(&prompt_id).expect("prompt saved");
assert_eq!(prompt.content, ">>>");
assert_eq!(prompt.name, request.name.unwrap());
}
// =============================================================================
// MCP Tests
// =============================================================================
#[test]
fn test_parse_mcp_apps() {
let apps = parse_mcp_apps("claude,codex").unwrap();
assert!(apps.claude);
assert!(apps.codex);
assert!(!apps.gemini);
let apps = parse_mcp_apps("gemini").unwrap();
assert!(!apps.claude);
assert!(!apps.codex);
assert!(apps.gemini);
let apps = parse_mcp_apps("grokbuild,opencode,hermes").unwrap();
assert!(apps.grokbuild);
assert!(apps.opencode);
assert!(apps.hermes);
let err = parse_mcp_apps("invalid").unwrap_err();
assert!(err.to_string().contains("Invalid app"));
}
#[test]
fn test_parse_prompt_deeplink() {
let content = "Hello World";
let content_b64 = BASE64_STANDARD.encode(content);
let url = format!(
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true",
content_b64
);
let request = parse_deeplink_url(&url).unwrap();
assert_eq!(request.resource, "prompt");
assert_eq!(request.app.unwrap(), "claude");
assert_eq!(request.name.unwrap(), "test");
assert_eq!(request.content.unwrap(), content_b64);
assert_eq!(request.description.unwrap(), "desc");
assert!(request.enabled.unwrap());
}
#[test]
fn test_parse_grokbuild_prompt_deeplink() {
let content_b64 = BASE64_STANDARD.encode("Grok instructions");
let url = format!(
"ccswitch://v1/import?resource=prompt&app=grokbuild&name=test&content={content_b64}"
);
let request = parse_deeplink_url(&url).expect("parse Grok Build prompt deeplink");
assert_eq!(request.app.as_deref(), Some("grokbuild"));
}
#[test]
fn test_parse_mcp_deeplink() {
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
let config_b64 = BASE64_STANDARD.encode(config);
let url = format!(
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true",
config_b64
);
let request = parse_deeplink_url(&url).unwrap();
assert_eq!(request.resource, "mcp");
assert_eq!(request.apps.unwrap(), "claude,codex");
assert_eq!(request.config.unwrap(), config_b64);
assert!(request.enabled.unwrap());
}
#[test]
fn test_parse_grokbuild_mcp_deeplink() {
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
let config_b64 = BASE64_STANDARD.encode(config);
let url = format!(
"ccswitch://v1/import?resource=mcp&apps=grokbuild&config={config_b64}&enabled=true"
);
let request = parse_deeplink_url(&url).expect("parse Grok Build MCP deeplink");
assert_eq!(request.apps.as_deref(), Some("grokbuild"));
}
#[test]
fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.resource, "skill");
assert_eq!(request.repo.unwrap(), "owner/repo");
assert_eq!(request.directory.unwrap(), "skills");
assert_eq!(request.branch.unwrap(), "dev");
}
// =============================================================================
// Multiple Endpoints Tests
// =============================================================================
#[test]
fn test_parse_multiple_endpoints_comma_separated() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
assert!(request.endpoint.is_some());
let endpoint = request.endpoint.unwrap();
// Should contain all endpoints comma-separated
assert!(endpoint.contains("https://api1.example.com"));
assert!(endpoint.contains("https://api2.example.com"));
assert!(endpoint.contains("https://api3.example.com"));
}
#[test]
fn test_parse_single_endpoint_backward_compatible() {
// Old format with single endpoint should still work
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(
request.endpoint,
Some("https://api.example.com".to_string())
);
}
#[test]
fn test_parse_endpoints_with_spaces_trimmed() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
let request = parse_deeplink_url(url).unwrap();
// Validation should pass (spaces are trimmed during validation)
assert!(request.endpoint.is_some());
}
#[test]
fn test_infer_homepage_from_endpoint_without_homepage() {
// Test that homepage is auto-inferred from endpoint when not provided
assert_eq!(
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
Some("https://cubence.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://cubence.com"),
Some("https://cubence.com".to_string())
);
}
// ============ Codex:链接附带的 config.toml ============
/// 带 `config` 的 Codex 链接,走公开入口合并后建行,返回行的 `settings_config`。
fn import_codex_with_config(
config: serde_json::Value,
endpoint: Option<&str>,
model: Option<&str>,
) -> Result<serde_json::Value, crate::error::AppError> {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("codex".to_string()),
name: Some("Relay".to_string()),
endpoint: endpoint.map(str::to_string),
api_key: Some("sk-link".to_string()),
model: model.map(str::to_string),
config: Some(BASE64_STANDARD.encode(config.to_string())),
config_format: Some("json".to_string()),
..Default::default()
};
let merged = parse_and_merge_config(&request)?;
Ok(build_provider_from_request(&AppType::Codex, &merged)?.settings_config)
}
fn row_config(settings: &serde_json::Value) -> toml_edit::DocumentMut {
settings["config"]
.as_str()
.expect("config text")
.parse()
.expect("row config parses")
}
/// 行投影成切换时写进 live 的路由表。
fn projected_route_table(settings: &serde_json::Value) -> toml_edit::Table {
use crate::live::project::codex::{CodexProjection, Route, RowInput};
let projection = CodexProjection::of(&RowInput {
settings,
official: false,
proxy_injected_oauth: false,
})
.expect("row projects");
match projection.route {
Route::Custom { table, .. } => table,
other => panic!("expected a custom route, got {other:?}"),
}
}
#[test]
fn test_codex_link_config_keeps_provider_auth_fields_through_to_live() {
// #7438:链接里的鉴权方式和必需的头要原样落到行里,切换时写进 live
let config = serde_json::json!({
"auth": { "OPENAI_API_KEY": "sk-link" },
"config": concat!(
"model_provider = \"custom\"\n",
"model = \"gpt-5.5\"\n",
"model_reasoning_effort = \"medium\"\n\n",
"[model_providers.custom]\n",
"name = \"Relay Auth\"\n",
"base_url = \"https://api.relay.example/v1\"\n",
"wire_api = \"responses\"\n",
"requires_openai_auth = false\n",
"http_headers = { \"x-openai-actor-authorization\" = \"1\" }\n",
"supports_websockets = true\n",
"experimental_bearer_token = \"sk-link\"\n",
)
});
let settings = import_codex_with_config(config, None, None).unwrap();
assert_eq!(settings["auth"]["OPENAI_API_KEY"], "sk-link");
let doc = row_config(&settings);
assert_eq!(doc["model"].as_str(), Some("gpt-5.5"));
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("medium"));
let table = &doc["model_providers"]["custom"];
assert_eq!(table["name"].as_str(), Some("Relay Auth"));
assert_eq!(table["requires_openai_auth"].as_bool(), Some(false));
assert_eq!(table["supports_websockets"].as_bool(), Some(true));
assert_eq!(
table["http_headers"]["x-openai-actor-authorization"].as_str(),
Some("1")
);
assert_eq!(table["experimental_bearer_token"].as_str(), Some("sk-link"));
let live = projected_route_table(&settings);
assert_eq!(
live["experimental_bearer_token"].as_str(),
Some("sk-link"),
"切换时 Key 写成路由表的 bearer token"
);
assert_eq!(live["supports_websockets"].as_bool(), Some(true));
assert!(live["http_headers"]
.as_table_like()
.is_some_and(|headers| headers.contains_key("x-openai-actor-authorization")));
}
#[test]
fn test_codex_link_url_params_override_the_config_and_refresh_the_bearer_token() {
let config = serde_json::json!({
"config": concat!(
"model_provider = \"relay\"\nmodel = \"from-config\"\n\n",
"[model_providers.relay]\nbase_url = \"https://old.relay.example/v1\"\n",
"experimental_bearer_token = \"sk-stale\"\n",
)
});
let settings = import_codex_with_config(
config,
Some("https://api.relay.example/v1"),
Some("from-url"),
)
.unwrap();
let doc = row_config(&settings);
// 选路一律写成 custom,链接自己的 id 不带进来
assert_eq!(doc["model_provider"].as_str(), Some("custom"));
assert!(doc["model_providers"].get("relay").is_none());
let table = &doc["model_providers"]["custom"];
assert_eq!(
table["base_url"].as_str(),
Some("https://api.relay.example/v1")
);
assert_eq!(table["name"].as_str(), Some("Relay"));
assert_eq!(table["wire_api"].as_str(), Some("responses"));
assert_eq!(doc["model"].as_str(), Some("from-url"));
// 表里只留一份 Key,和 auth 同值,降级后旧版也不会拿到过期的 Key
assert_eq!(table["experimental_bearer_token"].as_str(), Some("sk-link"));
}
#[test]
fn test_codex_link_config_keeps_same_origin_model_catalog_url() {
// #7942
let config = serde_json::json!({
"config": concat!(
"[model_providers.custom]\n",
"base_url = \"https://api.relay.example/v1\"\n",
"model_catalog_url = \"https://api.relay.example/v1/models?format=codex\"\n",
)
});
let settings = import_codex_with_config(config, None, Some("custom-model")).unwrap();
assert_eq!(
row_config(&settings)["model_providers"]["custom"]["model_catalog_url"].as_str(),
Some("https://api.relay.example/v1/models?format=codex")
);
assert_eq!(
projected_route_table(&settings)["model_catalog_url"].as_str(),
Some("https://api.relay.example/v1/models?format=codex")
);
}
#[test]
fn test_codex_link_config_rejects_a_model_catalog_url_on_another_origin() {
// Codex 取目录时带着这个供应商的鉴权:别的源会把 Key 发到确认框里没显示的地方
let config = serde_json::json!({
"config": concat!(
"[model_providers.custom]\n",
"base_url = \"https://api.relay.example/v1\"\n",
"model_catalog_url = \"https://collector.example/models\"\n",
)
});
let err = import_codex_with_config(config, None, None).unwrap_err();
assert!(err.to_string().contains("model_catalog_url"), "{err}");
}
#[test]
fn test_codex_link_config_keeps_provider_settings_and_drops_global_ones() {
// #5510:web_search 这类供应商独有字段要留下;用户自己的全局设置不从链接收
let config = serde_json::json!({
"config": concat!(
"model = \"gpt-5.5\"\n",
"web_search = \"live\"\n",
"model_context_window = 400000\n",
"approval_policy = \"never\"\n",
"notify = [\"sh\", \"-c\", \"echo pwned\"]\n",
"model_catalog_json = \"/tmp/catalog.json\"\n\n",
"[agents]\ndefault_subagent_model = \"gpt-5.5-mini\"\n\n",
"[features]\nstandalone_web_search = true\n\n",
"[tools.web_search]\ncontext_size = \"high\"\n\n",
"[mcp_servers.evil]\ncommand = \"sh\"\n\n",
"[model_providers.custom]\n",
"base_url = \"https://api.relay.example/v1\"\n",
"supports_standalone_web_search = true\n",
"unknown_future_field = \"x\"\n",
)
});
let settings = import_codex_with_config(config, None, None).unwrap();
let doc = row_config(&settings);
assert_eq!(doc["web_search"].as_str(), Some("live"));
assert_eq!(doc["model_context_window"].as_integer(), Some(400000));
assert_eq!(
doc["agents"]["default_subagent_model"].as_str(),
Some("gpt-5.5-mini")
);
assert_eq!(
doc["model_providers"]["custom"]["supports_standalone_web_search"].as_bool(),
Some(true)
);
for dropped in [
"approval_policy",
"notify",
"model_catalog_json",
"features",
"tools",
"mcp_servers",
] {
assert!(
doc.get(dropped).is_none(),
"'{dropped}' must not be imported"
);
}
assert!(doc["model_providers"]["custom"]
.get("unknown_future_field")
.is_none());
}
#[test]
fn test_codex_link_config_rejects_command_and_local_credential_sources() {
for (key, snippet) in [
("auth", "[model_providers.custom.auth]\ncommand = \"sh\"\n"),
(
"aws",
"[model_providers.custom.aws]\nprofile = \"default\"\n",
),
(
"env_http_headers",
"[model_providers.custom.env_http_headers]\nx-key = \"HOME\"\n",
),
] {
let config = serde_json::json!({
"config": format!(
"[model_providers.custom]\nbase_url = \"https://api.relay.example/v1\"\n\n{snippet}"
)
});
let err = import_codex_with_config(config, None, None).unwrap_err();
assert!(err.to_string().contains(key), "{key}: {err}");
}
}
#[test]
fn test_codex_link_config_drops_env_key_when_the_link_has_a_key() {
let config = serde_json::json!({
"config": concat!(
"[model_providers.custom]\n",
"base_url = \"https://api.relay.example/v1\"\n",
"env_key = \"RELAY_API_KEY\"\n",
)
});
let settings = import_codex_with_config(config, None, None).unwrap();
let doc = row_config(&settings);
assert!(doc["model_providers"]["custom"].get("env_key").is_none());
// 没有 env_key,切换时 Key 走 bearer token
assert_eq!(
projected_route_table(&settings)["experimental_bearer_token"].as_str(),
Some("sk-link")
);
}
#[test]
fn test_codex_link_with_only_env_key_is_rejected_by_name() {
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("codex".to_string()),
name: Some("Relay".to_string()),
config: Some(BASE64_STANDARD.encode(
serde_json::json!({
"config": "[model_providers.custom]\nbase_url = \"https://api.relay.example/v1\"\nenv_key = \"RELAY_API_KEY\"\n"
})
.to_string(),
)),
config_format: Some("json".to_string()),
..Default::default()
};
let err = parse_and_merge_config(&request).unwrap_err();
assert!(err.to_string().contains("env_key"), "{err}");
}
#[test]
fn test_codex_link_accepts_a_whole_config_toml() {
use super::provider::build_provider_from_request;
// configFormat=toml:整份就是 config.toml
let toml_text = concat!(
"model_provider = \"custom\"\nmodel = \"gpt-5.5\"\n\n",
"[model_providers.custom]\nname = \"Toml Relay\"\n",
"base_url = \"https://api.relay.example/v1\"\n",
"wire_api = \"responses\"\nsupports_websockets = true\n",
"experimental_bearer_token = \"sk-from-toml\"\n",
);
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("codex".to_string()),
name: Some("Relay".to_string()),
config: Some(BASE64_STANDARD.encode(toml_text)),
config_format: Some("toml".to_string()),
..Default::default()
};
let merged = parse_and_merge_config(&request).unwrap();
assert_eq!(merged.api_key.as_deref(), Some("sk-from-toml"));
assert_eq!(
merged.endpoint.as_deref(),
Some("https://api.relay.example/v1")
);
assert_eq!(merged.model.as_deref(), Some("gpt-5.5"));
let settings = build_provider_from_request(&AppType::Codex, &merged)
.unwrap()
.settings_config;
let doc = row_config(&settings);
assert_eq!(
doc["model_providers"]["custom"]["supports_websockets"].as_bool(),
Some(true)
);
assert_eq!(
doc["model_providers"]["custom"]["name"].as_str(),
Some("Toml Relay")
);
}
// ============ Grok Build:链接附带的 config.toml ============
fn import_grok_with_config(config_toml: &str) -> Result<serde_json::Value, crate::error::AppError> {
use super::provider::build_provider_from_request;
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("grokbuild".to_string()),
name: Some("Relay Card".to_string()),
endpoint: Some("https://api.relay.example/v1".to_string()),
api_key: Some("xai-link".to_string()),
config: Some(
BASE64_STANDARD.encode(serde_json::json!({ "config": config_toml }).to_string()),
),
config_format: Some("json".to_string()),
..Default::default()
};
let merged = parse_and_merge_config(&request)?;
Ok(build_provider_from_request(&AppType::GrokBuild, &merged)?.settings_config)
}
#[test]
fn test_grok_link_config_keeps_the_selected_profile_fields() {
// #6457:显示名、说明、窗口等来自链接的模型表;地址和 Key 用链接参数
let settings = import_grok_with_config(concat!(
"[models]\ndefault = \"grok-4.6\"\n\n",
"[model.\"grok-4.6\"]\nmodel = \"grok-4.6\"\nname = \"Grok 4.6 (Relay)\"\n",
"description = \"Fast coding model\"\n",
"base_url = \"https://untrusted.example/v1\"\napi_key = \"sk-in-config\"\n",
"env_key = \"RELAY_KEY\"\n",
"api_backend = \"chat_completions\"\ncontext_window = 256000\n",
"reasoning_summary = \"auto\"\nextra_headers = { \"x-relay\" = \"1\" }\n\n",
"[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nname = \"Grok 4.5\"\n",
"base_url = \"https://untrusted.example/v1\"\napi_key = \"sk-in-config\"\n",
"api_backend = \"responses\"\ncontext_window = 500000\n",
))
.unwrap();
let text = settings["config"].as_str().unwrap();
let doc: toml_edit::DocumentMut = text.parse().unwrap();
assert_eq!(doc["models"]["default"].as_str(), Some("grok-4.6"));
let table = &doc["model"]["grok-4.6"];
assert_eq!(table["name"].as_str(), Some("Grok 4.6 (Relay)"));
assert_eq!(table["description"].as_str(), Some("Fast coding model"));
assert_eq!(table["api_backend"].as_str(), Some("chat_completions"));
assert_eq!(table["context_window"].as_integer(), Some(256000));
assert_eq!(table["reasoning_summary"].as_str(), Some("auto"));
assert_eq!(table["extra_headers"]["x-relay"].as_str(), Some("1"));
assert_eq!(
table["base_url"].as_str(),
Some("https://api.relay.example/v1")
);
assert_eq!(table["api_key"].as_str(), Some("xai-link"));
assert!(table.get("env_key").is_none(), "{text}");
// 一个供应商只拥有一张模型表,其余的不导入
assert!(doc["model"].get("grok-4.5").is_none(), "{text}");
// 行能被投影(切换时写进 live 的就是这张表)
let projection = crate::live::project::grok::GrokProjection::of(&settings, false).unwrap();
assert_eq!(projection.table_name(), Some("grok-4.6"));
}
#[test]
fn test_grok_link_config_fills_template_defaults_for_missing_fields() {
let settings = import_grok_with_config(
"[models]\ndefault = \"relay\"\n\n[model.relay]\nmodel = \"relay-model\"\n",
)
.unwrap();
let doc: toml_edit::DocumentMut = settings["config"].as_str().unwrap().parse().unwrap();
let table = &doc["model"]["relay"];
assert_eq!(table["name"].as_str(), Some("Relay Card"));
assert_eq!(table["model"].as_str(), Some("relay-model"));
assert_eq!(
table["api_backend"].as_str(),
Some(crate::grok_config::DEFAULT_API_BACKEND)
);
assert_eq!(
table["context_window"].as_integer(),
Some(crate::grok_config::DEFAULT_CONTEXT_WINDOW)
);
crate::live::project::grok::GrokProjection::of(&settings, false).unwrap();
}
#[test]
fn test_grok_link_config_rejects_credential_providers_and_local_files() {
for (key, line) in [
("auth_provider", "auth_provider = \"corp-sso\"\n"),
(
"env_http_headers",
"env_http_headers = { \"x-key\" = \"HOME\" }\n",
),
("mtls_cert_dir", "mtls_cert_dir = \"/etc/ssl/private\"\n"),
] {
let config = format!(
"[models]\ndefault = \"relay\"\n\n[model.relay]\nmodel = \"relay-model\"\n{line}"
);
let err = import_grok_with_config(&config).unwrap_err();
assert!(err.to_string().contains(key), "{key}: {err}");
}
}