mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
1090 lines
36 KiB
Rust
1090 lines
36 KiB
Rust
#![cfg(test)]
|
|
|
|
//! 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::{import_provider_from_deeplink, 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¬es=Test%20notes";
|
|
|
|
let request = parse_deeplink_url(url).unwrap();
|
|
|
|
assert_eq!(request.notes, Some("Test notes".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn pi_provider_deeplink_requires_and_preserves_explicit_native_identity() {
|
|
use super::provider::build_provider_from_request;
|
|
|
|
let request = parse_deeplink_url(
|
|
"ccswitch://v1/import?resource=provider&app=pi&name=Pi%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test&model=opaque-model&api=future-native-api",
|
|
)
|
|
.expect("parse explicit Pi provider link");
|
|
assert_eq!(request.app.as_deref(), Some("pi"));
|
|
assert_eq!(request.api.as_deref(), Some("future-native-api"));
|
|
assert_eq!(request.model.as_deref(), Some("opaque-model"));
|
|
|
|
let provider = build_provider_from_request(&AppType::Pi, &request).expect("build Pi provider");
|
|
assert_eq!(
|
|
provider.settings_config,
|
|
serde_json::json!({
|
|
"name": "Pi Provider",
|
|
"baseUrl": "https://api.example.com/v1",
|
|
"apiKey": "sk-test",
|
|
"api": "future-native-api",
|
|
"models": [{
|
|
"id": "opaque-model",
|
|
"name": "opaque-model"
|
|
}]
|
|
}),
|
|
"deeplinks must not invent a model, protocol, capability, pricing, or limit field"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pi_provider_deeplink_rejects_implicit_model_or_protocol() {
|
|
let missing_api = "ccswitch://v1/import?resource=provider&app=pi&name=Pi&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&model=opaque-model";
|
|
assert!(parse_deeplink_url(missing_api)
|
|
.expect_err("Pi api must be explicit")
|
|
.to_string()
|
|
.contains("'api'"));
|
|
|
|
let missing_model = "ccswitch://v1/import?resource=provider&app=pi&name=Pi&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&api=openai-responses";
|
|
assert!(parse_deeplink_url(missing_model)
|
|
.expect_err("Pi model must be explicit")
|
|
.to_string()
|
|
.contains("'model'"));
|
|
}
|
|
|
|
#[test]
|
|
fn pi_prompt_deeplink_is_accepted_by_the_shared_prompt_path() {
|
|
let content = BASE64_STANDARD.encode("Pinned Pi AGENTS content");
|
|
let url = format!(
|
|
"ccswitch://v1/import?resource=prompt&app=pi&name=Pi%20AGENTS&content={content}&enabled=false"
|
|
);
|
|
let request = parse_deeplink_url(&url).expect("parse Pi prompt deeplink");
|
|
assert_eq!(request.app.as_deref(), Some("pi"));
|
|
assert_eq!(request.content.as_deref(), Some(content.as_str()));
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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()),
|
|
api: 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();
|
|
|
|
// 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,
|
|
api: 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,
|
|
api: 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,
|
|
api: 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_not_enabled_merely_by_carrying_code() {
|
|
use super::provider::build_provider_from_request;
|
|
|
|
// deeplink 是第三方构造、经浏览器抵达的不可信载荷。「带了代码」不是用户的
|
|
// 启用决定——否则一条链接就能让这段 JS 在用户从未勾选的情况下进入启用态。
|
|
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 still be created");
|
|
|
|
assert!(
|
|
!script.enabled,
|
|
"缺省必须是未启用;`带了代码`不构成用户的启用决定"
|
|
);
|
|
// 代码本身仍要保留:确认框要展示它,用户之后也可在应用内手动开启。
|
|
assert_eq!(script.code, code);
|
|
}
|
|
|
|
#[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,
|
|
api: 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,
|
|
api: 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,
|
|
api: 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,
|
|
api: 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()),
|
|
api: None,
|
|
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,
|
|
api: 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]
|
|
#[serial_test::serial]
|
|
fn provider_deeplink_creates_all_initial_endpoints_in_one_aggregate() {
|
|
let _test_home = TestHomeGuard::new();
|
|
let request = parse_deeplink_url(
|
|
"ccswitch://v1/import?resource=provider&app=claude&name=Endpoint%20Aggregate&endpoint=https%3A%2F%2Fprimary.example.com,https%3A%2F%2Fsecond.example.com%2F,https%3A%2F%2Fthird.example.com&apiKey=sk-test",
|
|
)
|
|
.expect("parse provider deeplink");
|
|
let state = AppState::new(Arc::new(Database::memory().expect("create memory db")));
|
|
|
|
let provider_id =
|
|
import_provider_from_deeplink(&state, request).expect("import provider aggregate");
|
|
let aggregate = state
|
|
.db
|
|
.get_provider_aggregate(AppType::Claude.as_str(), &provider_id)
|
|
.expect("read provider aggregate")
|
|
.expect("provider exists");
|
|
|
|
assert_eq!(aggregate.endpoints.len(), 2);
|
|
assert_eq!(
|
|
aggregate.endpoints["https://second.example.com"].url,
|
|
"https://second.example.com"
|
|
);
|
|
assert_eq!(
|
|
aggregate.endpoints["https://third.example.com"].url,
|
|
"https://third.example.com"
|
|
);
|
|
assert!(aggregate
|
|
.endpoints
|
|
.values()
|
|
.all(|endpoint| endpoint.added_at.is_some() && endpoint.last_used.is_none()));
|
|
}
|
|
|
|
#[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())
|
|
);
|
|
}
|