feat(privacy): implement privacy_self_check command (T7.6, FR-SEC-2)

Reports LLM endpoint/local-only status and enabled sync targets so the
UI can prove local-only handling, per the shape fixed in
docs/04-api-contracts.md. Sync targets stay empty until Phase 9 lands
SyncManager; list_sync_targets' not_implemented error is swallowed
rather than failing the whole self-check.
This commit is contained in:
iamdoubz
2026-07-01 17:01:40 -05:00
parent 3229476c02
commit 70c8c61af5
+130 -1
View File
@@ -1535,10 +1535,69 @@ pub async fn update_settings(patch: serde_json::Value) -> WaResult<Settings> {
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<SyncTargetInfo>,
) -> 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<String> = 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<serde_json::Value> = 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<serde_json::Value> {
Err(not_implemented("privacy_self_check"))
let settings = load_settings();
let sync_targets: Vec<SyncTargetInfo> = 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);
}
}