From 3495eb31ded90f6af296888c1d2bc0bd253da8cd Mon Sep 17 00:00:00 2001 From: malice Date: Sun, 5 Jul 2026 17:57:45 -0600 Subject: [PATCH] =?UTF-8?q?feat(secrets):=20GUI-managed=20.env=20secrets?= =?UTF-8?q?=20store=20=E2=80=94=20keys=20are=20config,=20but=20gitignored?= =?UTF-8?q?=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API keys/secrets now live in /data/secrets/.env (gitignored, never in config YAML), while remaining fully editable from the dashboard. Config YAML holds only ${VAR} references. Backend: - meshai/secrets_store.py: get_status (SET/NOT-SET, never values), set_secret, delete_secret over /data/secrets/.env (resolved like load_config); authoritative SECRET_FIELD_TO_ENV map (traffic→TOMTOM_API_KEY, firms→FIRMS_MAP_KEY, roads511→ROADS511_API_KEY, wzdx→WZDX_API_KEY, smtp→SMTP_PASSWORD, mesh_sources→MESHMONITOR_API_TOKEN) + backend-dependent llm_env_var - dashboard/api/secrets_routes.py: GET /api/secrets (status only), PUT/DELETE /api/secrets/{env_var} (validated, restart_required); registered in server.py - config_loader: save_section preserves ${VAR} secret refs on section save (never rejects them); EXPECTED_SECRETS += ROADS511_API_KEY, WZDX_API_KEY - config.example.yaml + docker-entrypoint default config use ${VAR} refs; first-run bootstraps /data/secrets/.env; .gitignore covers it Frontend: - components/ManagedSecret.tsx: masked, Set/Not-set badge, reveal, Save->PUT, "restart required"; carries no config value so secrets never enter a section save payload - wired into Environment (tomtom/roads511/wzdx/firms), Config LLM tab (env var by backend), Notifications (smtp) Restart required after a secret change (env read at config-load). 11 store tests; suite at 10-failure baseline (1714 passed). Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- .gitignore | 5 + work/config.example.yaml | 8 +- work/config/.env.example | 2 + .../src/components/ManagedSecret.tsx | 117 +++++++++++++++ work/dashboard-frontend/src/pages/Config.tsx | 11 +- .../src/pages/Environment.tsx | 9 +- .../src/pages/Notifications.tsx | 11 +- work/docker-entrypoint.sh | 4 +- work/meshai/config_loader.py | 8 + work/meshai/dashboard/api/secrets_routes.py | 42 ++++++ work/meshai/dashboard/server.py | 2 + work/meshai/secrets_store.py | 142 ++++++++++++++++++ work/tests/test_secrets_store.py | 137 +++++++++++++++++ 13 files changed, 477 insertions(+), 21 deletions(-) create mode 100644 work/dashboard-frontend/src/components/ManagedSecret.tsx create mode 100644 work/meshai/dashboard/api/secrets_routes.py create mode 100644 work/meshai/secrets_store.py create mode 100644 work/tests/test_secrets_store.py diff --git a/.gitignore b/.gitignore index c958ad6..dc50393 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,8 @@ local.yaml data/*.sqlite data/*.sqlite-wal data/*.sqlite-shm + +# Secrets (values live here, never committed) +/data/secrets/.env +data/secrets/.env +work/data/secrets/.env diff --git a/work/config.example.yaml b/work/config.example.yaml index 78dd49d..e55f5fc 100644 --- a/work/config.example.yaml +++ b/work/config.example.yaml @@ -48,7 +48,7 @@ context: # === LLM BACKEND === llm: backend: openai # openai | anthropic | google - api_key: "" # API key (or use LLM_API_KEY env var) + api_key: ${GOOGLE_API_KEY} # Secret: set via dashboard or /data/secrets/.env (var depends on backend: GOOGLE_API_KEY/OPENAI_API_KEY/ANTHROPIC_API_KEY) base_url: https://api.openai.com/v1 # API base URL model: gpt-4o-mini # Model name timeout: 30 # Request timeout (seconds) @@ -184,7 +184,7 @@ environmental: traffic: enabled: false tick_seconds: 300 - api_key: "" # Get key at developer.tomtom.com + api_key: ${TOMTOM_API_KEY} # Secret: set via dashboard or /data/secrets/.env (get key at developer.tomtom.com) corridors: [] # Example corridors: # - name: "I-84 Twin Falls" @@ -195,7 +195,7 @@ environmental: roads511: enabled: false tick_seconds: 300 - api_key: "" + api_key: ${ROADS511_API_KEY} # Secret: set via dashboard or /data/secrets/.env (leave var empty if 511 needs no key) base_url: "" # e.g. "https://511.idaho.gov/api/v2" endpoints: ["/get/event"] bbox: [] # [west, south, east, north] @@ -206,7 +206,7 @@ environmental: firms: enabled: false tick_seconds: 1800 # 30 min default - map_key: "" # Required - NASA FIRMS MAP_KEY + map_key: ${FIRMS_MAP_KEY} # Secret: set via dashboard or /data/secrets/.env (NASA FIRMS MAP_KEY) source: "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT bbox: [] # [west, south, east, north] - Required day_range: 1 # 1-10 days of data diff --git a/work/config/.env.example b/work/config/.env.example index 9d24d13..86585e4 100644 --- a/work/config/.env.example +++ b/work/config/.env.example @@ -14,6 +14,8 @@ MQTT_PASSWORD= # Environmental Feed Keys TOMTOM_API_KEY= FIRMS_MAP_KEY= +ROADS511_API_KEY= +WZDX_API_KEY= # Notification Credentials SMTP_PASSWORD= diff --git a/work/dashboard-frontend/src/components/ManagedSecret.tsx b/work/dashboard-frontend/src/components/ManagedSecret.tsx new file mode 100644 index 0000000..39e654a --- /dev/null +++ b/work/dashboard-frontend/src/components/ManagedSecret.tsx @@ -0,0 +1,117 @@ +import { useState, useEffect } from 'react' +import { Eye, EyeOff } from 'lucide-react' + +interface SecretEntry { + env_var: string + is_set: boolean + fields?: string[] + label?: string +} + +export function ManagedSecret({ envVar, label = 'API Key', helper = '', info: _info = '' }: { + envVar: string + label?: string + helper?: string + info?: string +}) { + const [isSet, setIsSet] = useState(null) // null = loading + const [value, setValue] = useState('') + const [show, setShow] = useState(false) + const [saving, setSaving] = useState(false) + const [restartMsg, setRestartMsg] = useState('') + const [error, setError] = useState('') + + const fetchStatus = async () => { + try { + const res = await fetch('/api/secrets') + if (res.ok) { + const data: SecretEntry[] = await res.json() + const entry = data.find((e) => e.env_var === envVar) + setIsSet(entry ? entry.is_set : false) + } + } catch { + setIsSet(false) + } + } + + useEffect(() => { + fetchStatus() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [envVar]) + + const handleSave = async () => { + if (!value.trim()) return + setSaving(true) + setError('') + setRestartMsg('') + try { + const res = await fetch('/api/secrets/' + encodeURIComponent(envVar), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value }), + }) + if (res.ok) { + setValue('') + setShow(false) + setRestartMsg('Saved — restart required to take effect') + await fetchStatus() + } else { + const body = await res.text() + setError('Save failed: ' + (body || String(res.status))) + } + } catch { + setError('Save failed: network error') + } finally { + setSaving(false) + } + } + + const placeholder = isSet + ? 'env set — enter a new value to change' + : 'not set — enter a value' + + return ( +
+ +
+
+ setValue(e.target.value)} + placeholder={placeholder} + className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent placeholder-slate-600" + /> + +
+ +
+ {helper &&

{helper}

} +

{envVar}

+ {restartMsg &&

{restartMsg}

} + {error &&

{error}

} +
+ ) +} diff --git a/work/dashboard-frontend/src/pages/Config.tsx b/work/dashboard-frontend/src/pages/Config.tsx index f1bbda9..e9dc8a2 100644 --- a/work/dashboard-frontend/src/pages/Config.tsx +++ b/work/dashboard-frontend/src/pages/Config.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { notifyRestartRequired } from '@/components/RestartBanner' +import { ManagedSecret } from '@/components/ManagedSecret' import { useDirty } from '@/context/DirtyContext' import NodePicker from '@/components/NodePicker' import ChannelPicker from '@/components/ChannelPicker' @@ -1011,6 +1012,7 @@ function CommandsSection({ data, onChange }: { data: CommandsConfig; onChange: ( } function LLMSection({ data, onChange }: { data: LLMConfig; onChange: (d: LLMConfig) => void }) { + const llmEnvVar = ({ openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', google: 'GOOGLE_API_KEY' } as Record)[(data.backend || '').toLowerCase()] || 'LLM_API_KEY' return (
@@ -1036,13 +1038,10 @@ function LLMSection({ data, onChange }: { data: LLMConfig; onChange: (d: LLMConf info="The specific model to use. Common choices: gpt-4o-mini (fast, cheap), gpt-4o (better, costs more), claude-sonnet-4-20250514 (Anthropic equivalent). For local models via Ollama, use the model name you pulled (e.g. llama3.1)." />
- onChange({ ...data, api_key: v })} - type="password" - helper="Supports ${ENV_VAR} syntax" - info="Your API key from the provider. You can also use ${ENV_VAR} syntax to read from an environment variable instead of storing the key in the config file." + helper="Secret stored in /data/secrets/.env; config holds the ${VAR} ref" /> { ) case 'traffic': return (<> - up({ traffic: { ...env.traffic, api_key: v } })} type="password" helper="developer.tomtom.com" /> + up({ traffic: { ...env.traffic, tick_seconds: v } })} min={60} />
Corridors:
{(env.traffic.corridors || []).map((c, i) => ( @@ -1197,7 +1198,7 @@ const save = async () => { ) case 'roads511': return (<> up({ roads511: { ...env.roads511, base_url: v } })} placeholder="https://511.yourstate.gov/api/v2" /> - up({ roads511: { ...env.roads511, api_key: v } })} type="password" helper="Leave empty if not required" /> + up({ roads511: { ...env.roads511, tick_seconds: v } })} min={60} /> up({ roads511: { ...env.roads511, endpoints: v } })} helper="e.g., /get/event" />
@@ -1254,7 +1255,7 @@ const save = async () => { {env.wzdx?.feed_source !== 'central' && ( <> up({ wzdx: { ...env.wzdx!, base_url: v } })} placeholder="https://511.yourstate.gov/api/v2" /> - up({ wzdx: { ...env.wzdx!, api_key: v } })} type="password" helper="Leave empty if not required" /> + up({ wzdx: { ...env.wzdx!, tick_seconds: v } })} min={60} /> up({ wzdx: { ...env.wzdx!, endpoints: v } })} helper="e.g., /get/event" />
@@ -1312,7 +1313,7 @@ const save = async () => {
) case 'firms': return (<> - up({ firms: { ...env.firms, map_key: v } })} type="password" helper="firms.modaps.eosdis.nasa.gov/api/area/" infoLink="https://firms.modaps.eosdis.nasa.gov/api/area/" /> + up({ firms: { ...env.firms, tick_seconds: v } })} min={300} /> up({ firms: { ...env.firms, source: v } })} options={[{ value: 'VIIRS_SNPP_NRT', label: 'VIIRS SNPP (NRT)' }, { value: 'VIIRS_NOAA20_NRT', label: 'VIIRS NOAA-20 (NRT)' }, { value: 'MODIS_NRT', label: 'MODIS (NRT)' }]} />
diff --git a/work/dashboard-frontend/src/pages/Notifications.tsx b/work/dashboard-frontend/src/pages/Notifications.tsx index 021ddf1..6d017e6 100644 --- a/work/dashboard-frontend/src/pages/Notifications.tsx +++ b/work/dashboard-frontend/src/pages/Notifications.tsx @@ -9,6 +9,7 @@ import { import ChannelPicker from '@/components/ChannelPicker' import NodePicker from '@/components/NodePicker' import { useDirty } from '@/context/DirtyContext' +import { ManagedSecret } from '@/components/ManagedSecret' // Types interface NotificationRuleConfig { @@ -1341,12 +1342,10 @@ function NotificationRuleCard({ value={rule.smtp_user || ''} onChange={(v) => onChange({ ...rule, smtp_user: v })} /> - onChange({ ...rule, smtp_password: v })} - type="password" - info="Gmail users: use an App Password from myaccount.google.com/apppasswords" + helper="SMTP password (App Password for Gmail)" />
upd(key, { smtp_host: v })} placeholder="smtp.example.com" /> upd(key, { smtp_port: v })} /> upd(key, { smtp_user: v })} /> - upd(key, { smtp_password: v })} type="password" /> + upd(key, { smtp_tls: v })} /> upd(key, { from_address: v })} placeholder="alerts@example.com" />
diff --git a/work/docker-entrypoint.sh b/work/docker-entrypoint.sh index 69cd103..979db11 100755 --- a/work/docker-entrypoint.sh +++ b/work/docker-entrypoint.sh @@ -52,7 +52,7 @@ context: llm: backend: openai - api_key: "" + api_key: ${GOOGLE_API_KEY} # secret: set via dashboard or /data/secrets/.env base_url: https://api.openai.com/v1 model: gpt-4o-mini timeout: 30 @@ -66,6 +66,8 @@ meshmonitor: enabled: false inject_into_prompt: true EOF + mkdir -p /data/secrets + [ -f /data/secrets/.env ] || : > /data/secrets/.env echo "Default config created at $MESHAI_CONFIG. Edit it or configure via the dashboard." fi diff --git a/work/meshai/config_loader.py b/work/meshai/config_loader.py index a2b61f2..62379f4 100644 --- a/work/meshai/config_loader.py +++ b/work/meshai/config_loader.py @@ -95,6 +95,8 @@ EXPECTED_SECRETS: list[str] = [ "MQTT_PASSWORD", "TOMTOM_API_KEY", "FIRMS_MAP_KEY", + "ROADS511_API_KEY", + "WZDX_API_KEY", "SMTP_PASSWORD", ] @@ -740,6 +742,12 @@ def save_section( for key, value in d.items(): field_path = f"{path}.{key}" if path else key if _is_secret_field(section_name, field_path): + # GUI-managed secrets: config holds only ${VAR} refs. If the + # incoming value is itself a ${VAR} reference, always preserve + # it (secret values live in /data/secrets/.env, never here). + if isinstance(value, str) and _VAR_RE.match(value): + cleaned[key] = value + continue ref = _ondisk_ref(field_path) m = _VAR_RE.match(ref) if isinstance(ref, str) else None if m: diff --git a/work/meshai/dashboard/api/secrets_routes.py b/work/meshai/dashboard/api/secrets_routes.py new file mode 100644 index 0000000..1bc5d10 --- /dev/null +++ b/work/meshai/dashboard/api/secrets_routes.py @@ -0,0 +1,42 @@ +"""Secrets management API routes. + +Secret VALUES never appear in responses or logs — only set/delete/status. +""" + +import logging + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from meshai import secrets_store + +router = APIRouter(tags=["secrets"]) +log = logging.getLogger(__name__) + + +class SecretUpdate(BaseModel): + value: str + + +@router.get("/secrets") +async def list_secrets(): + """List all managed secrets with set/unset status (no values).""" + return secrets_store.list_secrets() + + +@router.put("/secrets/{env_var}") +async def set_secret(env_var: str, body: SecretUpdate): + """Set a managed secret value.""" + if env_var not in secrets_store.SECRET_LABELS: + raise HTTPException(status_code=400, detail="unknown secret var") + secrets_store.set_secret(env_var, body.value) + return {"ok": True, "restart_required": True} + + +@router.delete("/secrets/{env_var}") +async def delete_secret(env_var: str): + """Delete a managed secret from the .env file.""" + if env_var not in secrets_store.SECRET_LABELS: + raise HTTPException(status_code=400, detail="unknown secret var") + secrets_store.delete_secret(env_var) + return {"ok": True, "restart_required": True} diff --git a/work/meshai/dashboard/server.py b/work/meshai/dashboard/server.py index 071926d..1c14b79 100644 --- a/work/meshai/dashboard/server.py +++ b/work/meshai/dashboard/server.py @@ -1,6 +1,7 @@ """FastAPI server for MeshAI dashboard.""" from meshai.dashboard.api.adapter_config_routes import router as adapter_config_router from meshai.dashboard.api.curation_routes import router as curation_router +from meshai.dashboard.api.secrets_routes import router as secrets_router import asyncio import logging @@ -68,6 +69,7 @@ def create_app() -> FastAPI: app.include_router(alert_router, prefix="/api") app.include_router(notification_router, prefix="/api") + app.include_router(secrets_router, prefix="/api") app.include_router(debug_router, prefix="/api") # WebSocket router (no prefix, path is /ws/live) app.include_router(ws_router) diff --git a/work/meshai/secrets_store.py b/work/meshai/secrets_store.py new file mode 100644 index 0000000..1fe3b3b --- /dev/null +++ b/work/meshai/secrets_store.py @@ -0,0 +1,142 @@ +"""GUI-managed secrets store for MeshAI. + +Secret VALUES live in a .env file (default /data/secrets/.env). +Config YAML holds only ${VAR} references — never raw values. +This module NEVER returns or logs secret values. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from dotenv import set_key, unset_key, dotenv_values + + +def secret_env_path(config_dir: Path = Path("/data/config")) -> Path: + """Resolve the secrets .env path the same way load_config does; ensure parent dir exists.""" + p = Path(config_dir).parent / "secrets" / ".env" + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +# config dotted-field -> env var name. llm.api_key is backend-dependent (see llm_env_var). +SECRET_FIELD_TO_ENV: dict[str, str] = { + "environmental.traffic.api_key": "TOMTOM_API_KEY", + "environmental.firms.map_key": "FIRMS_MAP_KEY", + "environmental.roads511.api_key": "ROADS511_API_KEY", + "environmental.wzdx.api_key": "WZDX_API_KEY", + "notifications.toggles.*.smtp_password": "SMTP_PASSWORD", + "notifications.rules.*.smtp_password": "SMTP_PASSWORD", + "mesh_sources.*.api_token": "MESHMONITOR_API_TOKEN", +} + +# llm.api_key backend -> env var (mirrors config_loader.py backend dict; else LLM_API_KEY) +LLM_BACKEND_TO_ENV: dict[str, str] = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "google": "GOOGLE_API_KEY", +} + + +def llm_env_var(backend: str | None) -> str: + return LLM_BACKEND_TO_ENV.get((backend or "").lower(), "LLM_API_KEY") + + +# Human labels for every managed env var. +SECRET_LABELS: dict[str, str] = { + "GOOGLE_API_KEY": "Google (Gemini) API key", + "OPENAI_API_KEY": "OpenAI API key", + "ANTHROPIC_API_KEY": "Anthropic API key", + "LLM_API_KEY": "LLM API key (generic)", + "TOMTOM_API_KEY": "TomTom API key", + "FIRMS_MAP_KEY": "NASA FIRMS MAP_KEY", + "ROADS511_API_KEY": "511 Roads API key", + "WZDX_API_KEY": "WZDx API key", + "SMTP_PASSWORD": "SMTP password", + "MESHMONITOR_API_TOKEN": "MeshMonitor API token", + "MQTT_PASSWORD": "MQTT password", +} + + +def _managed_vars() -> list[str]: + """Return list of managed env var names (stable insertion order).""" + return list(SECRET_LABELS) + + +def get_status(config_dir: Path = Path("/data/config")) -> dict[str, bool]: + """For each managed var, True if set (non-empty) in .env OR os.environ. + + NEVER returns values — only booleans. + """ + path = secret_env_path(config_dir) + try: + env_file = dotenv_values(path) if path.exists() else {} + except Exception: + env_file = {} + + result: dict[str, bool] = {} + for var in _managed_vars(): + file_val = env_file.get(var, "") + env_val = os.environ.get(var, "") + result[var] = bool(file_val) or bool(env_val) + return result + + +def set_secret(var: str, value: str, config_dir: Path = Path("/data/config")) -> None: + """Write var=value into the secrets .env file. + + Raises ValueError if var is not a managed secret. + """ + if var not in SECRET_LABELS: + raise ValueError(f"Unknown secret var: {var!r}. Allowed: {list(SECRET_LABELS)}") + path = secret_env_path(config_dir) + set_key(str(path), var, value) + + +def delete_secret(var: str, config_dir: Path = Path("/data/config")) -> None: + """Remove var from the secrets .env file (no-op if file/key missing). + + Raises ValueError if var is not a managed secret. + """ + if var not in SECRET_LABELS: + raise ValueError(f"Unknown secret var: {var!r}. Allowed: {list(SECRET_LABELS)}") + path = secret_env_path(config_dir) + try: + unset_key(str(path), var) + except (KeyError, FileNotFoundError): + pass + + +def _env_to_fields() -> dict[str, list[str]]: + """Reverse of SECRET_FIELD_TO_ENV: env var -> list of dotted config fields. + + Also includes llm.api_key for all four LLM env vars. + """ + result: dict[str, list[str]] = {} + for field, var in SECRET_FIELD_TO_ENV.items(): + result.setdefault(var, []).append(field) + + # LLM keys all map to llm.api_key + for llm_var in ("GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_API_KEY"): + result.setdefault(llm_var, []).append("llm.api_key") + + return result + + +def list_secrets(config_dir: Path = Path("/data/config")) -> list[dict]: + """Build the API payload for GET /secrets. + + Each item: {"env_var": str, "is_set": bool, "fields": list[str], "label": str}. + Values are NEVER included. + """ + status = get_status(config_dir) + field_map = _env_to_fields() + return [ + { + "env_var": var, + "is_set": status[var], + "fields": field_map.get(var, []), + "label": SECRET_LABELS[var], + } + for var in _managed_vars() + ] diff --git a/work/tests/test_secrets_store.py b/work/tests/test_secrets_store.py new file mode 100644 index 0000000..0d8ae76 --- /dev/null +++ b/work/tests/test_secrets_store.py @@ -0,0 +1,137 @@ +"""Tests for meshai.secrets_store — no /data access, no env leakage.""" + +import json +import pytest + +from meshai import secrets_store + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _cfg(tmp_path): + """Return a config_dir under tmp_path (parent gets a sibling secrets/).""" + d = tmp_path / "config" + d.mkdir(parents=True, exist_ok=True) + return d + + +# --------------------------------------------------------------------------- +# Round-trip: set -> status True -> delete -> status False +# --------------------------------------------------------------------------- + +def test_roundtrip_set_get_delete(tmp_path): + cfg = _cfg(tmp_path) + assert secrets_store.get_status(config_dir=cfg)["TOMTOM_API_KEY"] is False + + secrets_store.set_secret("TOMTOM_API_KEY", "abc", config_dir=cfg) + assert secrets_store.get_status(config_dir=cfg)["TOMTOM_API_KEY"] is True + + secrets_store.delete_secret("TOMTOM_API_KEY", config_dir=cfg) + assert secrets_store.get_status(config_dir=cfg)["TOMTOM_API_KEY"] is False + + +# --------------------------------------------------------------------------- +# Unknown var raises ValueError +# --------------------------------------------------------------------------- + +def test_set_unknown_raises(tmp_path): + cfg = _cfg(tmp_path) + with pytest.raises(ValueError, match="Unknown secret var"): + secrets_store.set_secret("NOPE_KEY", "x", config_dir=cfg) + + +def test_delete_unknown_raises(tmp_path): + cfg = _cfg(tmp_path) + with pytest.raises(ValueError, match="Unknown secret var"): + secrets_store.delete_secret("NOPE_KEY", config_dir=cfg) + + +# --------------------------------------------------------------------------- +# Values never leak +# --------------------------------------------------------------------------- + +def test_get_status_no_values(tmp_path): + cfg = _cfg(tmp_path) + secrets_store.set_secret("TOMTOM_API_KEY", "abc", config_dir=cfg) + status = secrets_store.get_status(config_dir=cfg) + # All values must be booleans + for v in status.values(): + assert isinstance(v, bool), f"Expected bool, got {type(v)}: {v!r}" + # The literal secret value must not appear anywhere + assert "abc" not in str(status) + + +def test_list_secrets_no_values(tmp_path): + cfg = _cfg(tmp_path) + secrets_store.set_secret("TOMTOM_API_KEY", "abc", config_dir=cfg) + items = secrets_store.list_secrets(config_dir=cfg) + dumped = json.dumps(items) + assert "abc" not in dumped, "Secret value leaked into list_secrets output" + + +# --------------------------------------------------------------------------- +# list_secrets shape +# --------------------------------------------------------------------------- + +def test_list_secrets_shape(tmp_path): + cfg = _cfg(tmp_path) + items = secrets_store.list_secrets(config_dir=cfg) + required_keys = {"env_var", "is_set", "fields", "label"} + for item in items: + assert required_keys == set(item.keys()), f"Item missing keys: {item}" + + tomtom = next(i for i in items if i["env_var"] == "TOMTOM_API_KEY") + assert tomtom["fields"] == ["environmental.traffic.api_key"] + assert isinstance(tomtom["is_set"], bool) + assert isinstance(tomtom["label"], str) + + +# --------------------------------------------------------------------------- +# delete on missing file is a no-op (not an error) +# --------------------------------------------------------------------------- + +def test_delete_missing_file_noop(tmp_path): + cfg = _cfg(tmp_path) + # .env file does not exist yet — should not raise + secrets_store.delete_secret("SMTP_PASSWORD", config_dir=cfg) + + +# --------------------------------------------------------------------------- +# llm_env_var backend mapping +# --------------------------------------------------------------------------- + +def test_llm_env_var_known_backends(): + assert secrets_store.llm_env_var("openai") == "OPENAI_API_KEY" + assert secrets_store.llm_env_var("anthropic") == "ANTHROPIC_API_KEY" + assert secrets_store.llm_env_var("google") == "GOOGLE_API_KEY" + + +def test_llm_env_var_unknown_falls_back(): + assert secrets_store.llm_env_var("ollama") == "LLM_API_KEY" + assert secrets_store.llm_env_var(None) == "LLM_API_KEY" + assert secrets_store.llm_env_var("") == "LLM_API_KEY" + + +# --------------------------------------------------------------------------- +# _env_to_fields: LLM vars include llm.api_key +# --------------------------------------------------------------------------- + +def test_env_to_fields_llm(): + m = secrets_store._env_to_fields() + for var in ("GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_API_KEY"): + assert "llm.api_key" in m.get(var, []), f"Missing llm.api_key for {var}" + + +# --------------------------------------------------------------------------- +# SECRET_LABELS completeness +# --------------------------------------------------------------------------- + +def test_secret_labels_keys(): + expected = { + "GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_API_KEY", + "TOMTOM_API_KEY", "FIRMS_MAP_KEY", "ROADS511_API_KEY", "WZDX_API_KEY", + "SMTP_PASSWORD", "MESHMONITOR_API_TOKEN", "MQTT_PASSWORD", + } + assert set(secrets_store.SECRET_LABELS.keys()) == expected