use super::*;
#[test]
fn mcp_servers_parse_defaults_validate_and_serialize() {
let settings: Settings = serde_json::from_str(
r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","args":["server.js"],"env":{},"enabled":true,"timeout":30}}}"#,
)
.unwrap();
let config = settings.mcp_servers.get("mock").unwrap();
match config {
McpServerConfig::Stdio(stdio) => {
assert_eq!(stdio.command, "node");
assert_eq!(stdio.args, vec!["server.js"]);
assert!(stdio.env.is_empty());
assert!(stdio.enabled);
assert_eq!(stdio.timeout, Some(30));
}
McpServerConfig::Http(_) => panic!("expected stdio config"),
}
let absent: Settings = serde_json::from_str("{}").unwrap();
assert!(absent.mcp_servers.is_empty());
let serialized = serde_json::to_value(settings).unwrap();
assert_eq!(serialized["capabilities"]["mcp"]["mock"]["type"], "stdio");
}
#[test]
fn mcp_http_servers_parse_defaults_validate_serialize_and_redact_debug() {
let settings: Settings = serde_json::from_str(
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp?token=url-secret#frag","headers":{"Authorization":"{env:MCP_TOKEN}","X-Team":"platform"}}}}"#,
)
.unwrap();
let config = settings.mcp_servers.get("remote").unwrap();
match config {
McpServerConfig::Http(http) => {
assert_eq!(
http.url,
"https://mcp.example.test/mcp?token=url-secret#frag"
);
assert!(http.enabled);
assert_eq!(http.timeout, None);
assert_eq!(http.headers["Authorization"], "{env:MCP_TOKEN}");
assert!(http.oauth.is_none());
let debug = format!("{http:?}");
assert!(debug.contains("[REDACTED]"), "{debug}");
assert!(!debug.contains("platform"), "{debug}");
assert!(!debug.contains("MCP_TOKEN"), "{debug}");
assert!(!debug.contains("url-secret"), "{debug}");
assert!(!debug.contains("token="), "{debug}");
}
McpServerConfig::Stdio(_) => panic!("expected http config"),
}
let serialized = serde_json::to_value(settings).unwrap();
assert_eq!(serialized["capabilities"]["mcp"]["remote"]["type"], "http");
}
#[test]
fn mcp_http_servers_reject_invalid_url_headers_and_timeout() {
for raw in [
r#"{"mcp_servers":{"remote":{"type":"http","url":"ftp://mcp.example.test/mcp"}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://user:pass@mcp.example.test/mcp"}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Bad:Name":"x"}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Authorization":"literal"}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Authorization":"{env:1BAD}"}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","timeout":301}}}"#,
] {
assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
}
for raw in [
r#"{"mcp_servers":{"local":{"type":"http","url":"http://localhost:8787/mcp"}}}"#,
r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.1:8787/mcp"}}}"#,
r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.2:8787/mcp"}}}"#,
r#"{"mcp_servers":{"local":{"type":"http","url":"http://[::1]:8787/mcp"}}}"#,
] {
assert!(parse_validated_settings(raw).is_ok(), "rejected {raw}");
}
let remote =
r#"{"mcp_servers":{"remote":{"type":"http","url":"http://mcp.example.test/mcp"}}}"#;
assert!(
parse_validated_settings(remote).is_err(),
"accepted {remote}"
);
let userinfo = r#"{"mcp_servers":{"remote":{"type":"http","url":"https://user:pass@mcp.example.test/mcp"}}}"#;
assert!(
parse_validated_settings(userinfo).is_err(),
"accepted {userinfo}"
);
}
#[test]
fn mcp_http_oauth_config_parses_validates_serializes_and_redacts_debug() {
let settings: Settings = serde_json::from_str(
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"X-Team":"platform"},"oauth":{"client_id":"public-client","scopes":["search","offline_access"],"authorization_server":"https://auth.example.test"}}}}"#,
)
.unwrap();
let config = settings.mcp_servers.get("remote").unwrap();
let McpServerConfig::Http(http) = config else {
panic!("expected http config");
};
let oauth = http.oauth.as_ref().unwrap();
assert_eq!(oauth.client_id.as_deref(), Some("public-client"));
assert_eq!(oauth.scopes, vec!["search", "offline_access"]);
assert_eq!(
oauth.authorization_server.as_deref(),
Some("https://auth.example.test")
);
let debug = format!("{oauth:?}");
assert!(debug.contains("[REDACTED]"), "{debug}");
assert!(!debug.contains("public-client"), "{debug}");
let serialized = serde_json::to_value(settings).unwrap();
assert_eq!(
serialized["capabilities"]["mcp"]["remote"]["oauth"]["client_id"],
"public-client"
);
}
#[test]
fn mcp_http_oauth_rejects_authorization_headers_and_invalid_fields() {
for raw in [
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Authorization":"{env:MCP_TOKEN}"},"oauth":{}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Proxy-Authorization":"{env:MCP_TOKEN}"},"oauth":{}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"client_id":" "}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"scopes":[""]}}}}"#,
r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"scopes":["bad\u0007scope"]}}}}"#,
] {
assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
}
for raw in [
r#"{"mcp_servers":{"local":{"type":"http","url":"http://localhost:8787/mcp","oauth":{"authorization_server":"http://localhost:8788"}}}}"#,
r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.1:8787/mcp","oauth":{"authorization_server":"http://127.0.0.2:8788"}}}}"#,
] {
assert!(parse_validated_settings(raw).is_ok(), "rejected {raw}");
}
let remote = r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"authorization_server":"http://auth.example.test"}}}}"#;
assert!(
parse_validated_settings(remote).is_err(),
"accepted {remote}"
);
}
#[test]
fn settings_schema_generation_includes_mcp_http_config() {
let schema = schemars::schema_for!(Settings);
let schema_text = serde_json::to_string(&schema).unwrap();
assert!(schema_text.contains("McpHttpServerConfig"), "{schema_text}");
assert!(schema_text.contains("url"), "{schema_text}");
assert!(schema_text.contains("headers"), "{schema_text}");
assert!(schema_text.contains("McpOAuthConfig"), "{schema_text}");
assert!(schema_text.contains("client_id"), "{schema_text}");
assert!(schema_text.contains("scopes"), "{schema_text}");
assert!(
schema_text.contains("authorization_server"),
"{schema_text}"
);
}
#[test]
fn mcp_servers_reject_invalid_names_command_and_timeout() {
for raw in [
r#"{"mcp_servers":{"bad/name":{"type":"stdio","command":"node"}}}"#,
r#"{"mcp_servers":{"bad__name":{"type":"stdio","command":"node"}}}"#,
r#"{"mcp_servers":{"bad_":{"type":"stdio","command":"node"}}}"#,
r#"{"mcp_servers":{"mock":{"type":"stdio","command":" "}}}"#,
r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","timeout":0}}}"#,
r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","timeout":301}}}"#,
] {
assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
}
}
#[test]
fn lsp_settings_parse_defaults_validate_and_serialize() {
let absent: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(absent.lsp, LspSettings::default());
assert!(!absent.lsp.enabled);
assert!(absent.lsp.inject_diagnostics_on_edit);
assert_eq!(
absent.lsp.diagnostics_wait_ms,
DEFAULT_LSP_DIAGNOSTICS_WAIT_MS
);
assert_eq!(
absent.lsp.idle_shutdown_minutes,
DEFAULT_LSP_IDLE_SHUTDOWN_MINUTES
);
assert!(serde_json::to_value(&absent).unwrap().get("lsp").is_none());
let settings: Settings = serde_json::from_str(
r#"{"lsp":{"enabled":true,"inject_diagnostics_on_edit":false,"diagnostics_wait_ms":1500,"idle_shutdown_minutes":30,"servers":{"rust-analyzer":{"command":"rust-analyzer","args":["--log-file","ra.log"],"enabled":false}}}}"#,
)
.unwrap();
validate_settings(&settings).unwrap();
assert!(settings.lsp.enabled);
assert!(!settings.lsp.inject_diagnostics_on_edit);
assert_eq!(settings.lsp.diagnostics_wait_ms, 1500);
assert_eq!(settings.lsp.idle_shutdown_minutes, 30);
let rust_analyzer = settings.lsp.servers.get("rust-analyzer").unwrap();
assert_eq!(rust_analyzer.command, "rust-analyzer");
assert_eq!(rust_analyzer.args, vec!["--log-file", "ra.log"]);
assert!(!rust_analyzer.enabled);
let serialized = serde_json::to_value(settings).unwrap();
assert_eq!(serialized["capabilities"]["lsp"]["enabled"], true);
assert_eq!(
serialized["capabilities"]["lsp"]["servers"]["rust-analyzer"]["command"],
"rust-analyzer"
);
}
#[test]
fn lsp_settings_reject_invalid_values() {
for raw in [
r#"{"lsp":{"diagnostics_wait_ms":0}}"#,
r#"{"lsp":{"diagnostics_wait_ms":30001}}"#,
r#"{"lsp":{"idle_shutdown_minutes":0}}"#,
r#"{"lsp":{"idle_shutdown_minutes":241}}"#,
r#"{"lsp":{"servers":{"":{"command":"rust-analyzer"}}}}"#,
r#"{"lsp":{"servers":{"bad__name":{"command":"rust-analyzer"}}}}"#,
r#"{"lsp":{"servers":{"rust-analyzer":{"command":" "}}}}"#,
] {
assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
}
}
#[test]
fn lsp_settings_preserved_and_removed_by_settings_update() {
let mut raw = json!({
"lsp": {
"enabled": true,
"servers": {"rust-analyzer": {"command": "rust-analyzer"}}
},
"selected_primary_agent": "old"
});
let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
let before = settings.clone();
settings.selected_primary_agent = Some("new".to_string());
update_raw_from_settings(&mut raw, &before, &settings, SettingsScope::Global).unwrap();
assert_eq!(raw["capabilities"]["lsp"]["enabled"], true);
assert_eq!(
raw["capabilities"]["lsp"]["servers"]["rust-analyzer"]["command"],
"rust-analyzer"
);
assert_eq!(raw["agent"]["primary_agent"], "new");
let before = settings.clone();
settings.lsp = LspSettings::default();
update_raw_from_settings(&mut raw, &before, &settings, SettingsScope::Global).unwrap();
assert!(raw.get("lsp").is_none());
}
#[test]
fn mcp_servers_preserved_by_settings_update() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","args":["server.js"],"env":{},"enabled":true,"timeout":30}},"future_setting":true}"#,
)
.unwrap();
set_selected_model(&paths, "provider", "model").unwrap();
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
assert_eq!(value["capabilities"]["mcp"]["mock"]["command"], "node");
assert_eq!(value["future_setting"], true);
}
#[test]
fn set_mcp_server_enabled_updates_stdio_http_and_preserves_unknowns() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(
&paths.settings_file,
r#"{
"future_setting":{"keep":true},
"mcp_servers":{
"mock":{"type":"stdio","command":"node","enabled":true},
"remote":{"type":"http","url":"https://mcp.example.test/mcp","enabled":false}
}
}"#,
)
.unwrap();
set_mcp_server_enabled(&paths, "mock", false).unwrap();
set_mcp_server_enabled(&paths, "remote", true).unwrap();
let value = read_settings_value(&paths);
assert_eq!(value["future_setting"]["keep"], true);
assert_eq!(value["capabilities"]["mcp"]["mock"]["enabled"], false);
assert_eq!(value["capabilities"]["mcp"]["remote"]["enabled"], true);
}
#[test]
fn set_mcp_server_enabled_rejects_missing_or_invalid_name() {
let temp = tempfile::TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
fs::create_dir_all(&paths.root).unwrap();
fs::write(&paths.settings_file, r#"{"mcp_servers":{}}"#).unwrap();
let missing = set_mcp_server_enabled(&paths, "missing", true)
.unwrap_err()
.to_string();
assert!(
missing.contains("mcp server not found: missing"),
"{missing}"
);
let invalid = set_mcp_server_enabled(&paths, "bad/name", true)
.unwrap_err()
.to_string();
assert!(invalid.contains("mcp server name"), "{invalid}");
let trailing = set_mcp_server_enabled(&paths, "bad_", true)
.unwrap_err()
.to_string();
assert!(trailing.contains("must not end with '_'"), "{trailing}");
assert!(trailing.contains("ambiguous"), "{trailing}");
}