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:
+130
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user