diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6b988bb..ae0cb54 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1535,10 +1535,69 @@ pub async fn update_settings(patch: serde_json::Value) -> WaResult { Ok(merged) } +/// Pure assembly of the `privacy_self_check` response (shape fixed by +/// docs/04-api-contracts.md) from already-loaded settings + sync targets, so +/// it's unit-testable without touching the settings file or a DB. +fn privacy_self_check_json( + settings: &Settings, + sync_targets: Vec, +) -> serde_json::Value { + let provider = llm_provider_from_settings(settings); + let (llm_endpoint, llm_is_local) = match &provider { + Some(p) => (settings.llm_endpoint.clone(), p.is_local()), + None => (String::new(), true), // off: no endpoint, trivially no egress + }; + + let mut allowlisted_hosts: Vec = Vec::new(); + if !llm_is_local { + if let Some(host) = reqwest::Url::parse(&llm_endpoint) + .ok() + .and_then(|u| u.host_str().map(str::to_string)) + { + allowlisted_hosts.push(host); + } + } + for target in sync_targets.iter().filter(|t| t.enabled) { + if let Some(host) = &target.host { + allowlisted_hosts.push(host.clone()); + } + } + allowlisted_hosts.sort(); + allowlisted_hosts.dedup(); + + let sync_targets_json: Vec = sync_targets + .iter() + .map(|t| { + serde_json::json!({ + "name": t.name, + "host": t.host.clone().unwrap_or_default(), + "thirdParty": t.third_party, + "tls": t.base_url.as_deref().is_some_and(|u| u.starts_with("https://")), + }) + }) + .collect(); + + serde_json::json!({ + "llmEndpoint": llm_endpoint, + "llmIsLocal": llm_is_local, + "syncEnabled": settings.sync_enabled, + "syncTargets": sync_targets_json, + "allowlistedHosts": allowlisted_hosts, + }) +} + /// Reports current egress + LLM endpoint so the UI can prove local-only handling (FR-SEC-2). +/// Sync targets/hosts are empty until Phase 9 lands `SyncManager`; `list_sync_targets` is still +/// `not_implemented` so its error is swallowed here rather than failing the whole self-check. #[tauri::command] pub async fn privacy_self_check() -> WaResult { - Err(not_implemented("privacy_self_check")) + let settings = load_settings(); + let sync_targets: Vec = if settings.sync_enabled { + list_sync_targets().await.unwrap_or_default() + } else { + Vec::new() + }; + Ok(privacy_self_check_json(&settings, sync_targets)) } #[cfg(test)] @@ -1641,4 +1700,74 @@ mod tests { let unknown = build_prompt(&meeting_fixture(), Some("no-such-template")); assert!(unknown.template.is_none()); } + + fn sync_target_fixture( + name: &str, + enabled: bool, + host: &str, + third_party: bool, + tls: bool, + ) -> SyncTargetInfo { + SyncTargetInfo { + id: name.to_string(), + name: name.to_string(), + kind: SyncKind::WebDav, + provider_hint: Some("nextcloud".to_string()), + base_url: Some(format!("{}://{}", if tls { "https" } else { "http" }, host)), + remote_base_path: "/WhispAssist".to_string(), + username: Some("alice".to_string()), + enabled, + third_party, + host: Some(host.to_string()), + } + } + + #[test] + fn privacy_self_check_default_settings_is_fully_local_with_empty_allowlist() { + let result = privacy_self_check_json(&default_settings(), Vec::new()); + assert_eq!(result["llmIsLocal"], true); + assert_eq!(result["llmEndpoint"], "http://localhost:11434"); + assert_eq!(result["syncEnabled"], false); + assert_eq!(result["syncTargets"], serde_json::json!([])); + assert_eq!(result["allowlistedHosts"], serde_json::json!([])); + } + + #[test] + fn privacy_self_check_remote_llm_endpoint_is_not_local_and_joins_the_allowlist() { + let mut settings = default_settings(); + settings.llm_provider = "custom".to_string(); + settings.llm_endpoint = "https://api.example.com".to_string(); + + let result = privacy_self_check_json(&settings, Vec::new()); + assert_eq!(result["llmIsLocal"], false); + assert_eq!( + result["allowlistedHosts"], + serde_json::json!(["api.example.com"]) + ); + } + + #[test] + fn privacy_self_check_only_enabled_sync_targets_join_the_allowlist() { + let mut settings = default_settings(); + settings.sync_enabled = true; + let targets = vec![ + sync_target_fixture("Nextcloud", true, "cloud.example.org", false, true), + sync_target_fixture( + "Old Disabled Target", + false, + "stale.example.org", + false, + true, + ), + ]; + + let result = privacy_self_check_json(&settings, targets); + assert_eq!( + result["allowlistedHosts"], + serde_json::json!(["cloud.example.org"]) + ); + assert_eq!(result["syncTargets"].as_array().unwrap().len(), 2); + assert_eq!(result["syncTargets"][0]["thirdParty"], false); + assert_eq!(result["syncTargets"][0]["tls"], true); + } }