feat(secrets): GUI-managed .env secrets store — keys are config, but gitignored (#47)

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 <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-05 17:57:45 -06:00 committed by GitHub
commit 3495eb31de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 477 additions and 21 deletions

5
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -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=

View file

@ -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<boolean | null>(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 (
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
{label}
{isSet === null ? (
<span className="text-xs px-2 py-0.5 rounded ml-2 bg-slate-800 text-slate-500">Loading</span>
) : isSet ? (
<span className="text-xs px-2 py-0.5 rounded ml-2 bg-green-500/10 text-green-400">Set</span>
) : (
<span className="text-xs px-2 py-0.5 rounded ml-2 bg-slate-800 text-slate-500">Not set</span>
)}
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={show ? 'text' : 'password'}
value={value}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShow(!show)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
>
{show ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<button
type="button"
onClick={handleSave}
disabled={!value.trim() || saving}
className="flex items-center gap-1.5 px-4 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
<p className="text-xs text-slate-600 font-mono">{envVar}</p>
{restartMsg && <p className="text-xs text-yellow-400">{restartMsg}</p>}
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
)
}

View file

@ -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<string, string>)[(data.backend || '').toLowerCase()] || 'LLM_API_KEY'
return (
<div className="space-y-4">
<SectionDescription text={SECTION_DESCRIPTIONS.llm} />
@ -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)."
/>
</div>
<TextInput
<ManagedSecret
envVar={llmEnvVar}
label="API Key"
value={data.api_key}
onChange={(v) => 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"
/>
<TextInput
label="Base URL"

View file

@ -14,6 +14,7 @@ import {
} from '@/lib/api'
import { TOGGLE_FAMILY_META, type NotificationToggle, type NotificationsConfig } from './Notifications'
import AdapterConfig, { CURATED_KEYS } from './AdapterConfig'
import { ManagedSecret } from '@/components/ManagedSecret'
type FeedSource = 'native' | 'central'
@ -1153,7 +1154,7 @@ const save = async () => {
</div>
)
case 'traffic': return (<>
<TextInput label="API Key" value={env.traffic.api_key} onChange={(v) => up({ traffic: { ...env.traffic, api_key: v } })} type="password" helper="developer.tomtom.com" />
<ManagedSecret envVar="TOMTOM_API_KEY" label="API Key" helper="developer.tomtom.com" />
<NumberInput label="Tick Seconds" value={env.traffic.tick_seconds} onChange={(v) => up({ traffic: { ...env.traffic, tick_seconds: v } })} min={60} />
<div className="text-xs text-[#666] mt-2">Corridors:</div>
{(env.traffic.corridors || []).map((c, i) => (
@ -1197,7 +1198,7 @@ const save = async () => {
</>)
case 'roads511': return (<>
<TextInput label="Base URL" value={env.roads511.base_url} onChange={(v) => up({ roads511: { ...env.roads511, base_url: v } })} placeholder="https://511.yourstate.gov/api/v2" />
<TextInput label="API Key" value={env.roads511.api_key} onChange={(v) => up({ roads511: { ...env.roads511, api_key: v } })} type="password" helper="Leave empty if not required" />
<ManagedSecret envVar="ROADS511_API_KEY" label="API Key" helper="Leave unset if 511 needs no key" />
<NumberInput label="Tick Seconds" value={env.roads511.tick_seconds} onChange={(v) => up({ roads511: { ...env.roads511, tick_seconds: v } })} min={60} />
<ListInput label="Endpoints" value={env.roads511.endpoints} onChange={(v) => up({ roads511: { ...env.roads511, endpoints: v } })} helper="e.g., /get/event" />
<div className="grid grid-cols-4 gap-2">
@ -1254,7 +1255,7 @@ const save = async () => {
{env.wzdx?.feed_source !== 'central' && (
<>
<TextInput label="Base URL" value={env.wzdx?.base_url ?? ''} onChange={(v) => up({ wzdx: { ...env.wzdx!, base_url: v } })} placeholder="https://511.yourstate.gov/api/v2" />
<TextInput label="API Key" value={env.wzdx?.api_key ?? ''} onChange={(v) => up({ wzdx: { ...env.wzdx!, api_key: v } })} type="password" helper="Leave empty if not required" />
<ManagedSecret envVar="WZDX_API_KEY" label="API Key" helper="Leave unset if not required" />
<NumberInput label="Tick Seconds" value={env.wzdx?.tick_seconds ?? 300} onChange={(v) => up({ wzdx: { ...env.wzdx!, tick_seconds: v } })} min={60} />
<ListInput label="Endpoints" value={env.wzdx?.endpoints ?? ['/get/event']} onChange={(v) => up({ wzdx: { ...env.wzdx!, endpoints: v } })} helper="e.g., /get/event" />
<div className="grid grid-cols-4 gap-2">
@ -1312,7 +1313,7 @@ const save = async () => {
</div>
</>)
case 'firms': return (<>
<TextInput label="MAP Key" value={env.firms.map_key} onChange={(v) => 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/" />
<ManagedSecret envVar="FIRMS_MAP_KEY" label="MAP Key" helper="NASA FIRMS MAP_KEY" />
<NumberInput label="Tick Seconds" value={env.firms.tick_seconds} onChange={(v) => up({ firms: { ...env.firms, tick_seconds: v } })} min={300} />
<SelectInput label="Satellite Source" value={env.firms.source} onChange={(v) => 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)' }]} />
<div className="grid grid-cols-3 gap-4">

View file

@ -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 })}
/>
<TextInput
<ManagedSecret
envVar="SMTP_PASSWORD"
label="Password"
value={rule.smtp_password || ''}
onChange={(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)"
/>
</div>
<Toggle
@ -1763,7 +1762,7 @@ function OtherChannelsGrid({
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
<TextInput label="Username" value={t.smtp_user || ''} onChange={(v) => upd(key, { smtp_user: v })} />
<TextInput label="Password" value={t.smtp_password || ''} onChange={(v) => upd(key, { smtp_password: v })} type="password" />
<ManagedSecret envVar="SMTP_PASSWORD" label="Password" helper="SMTP password (App Password for Gmail)" />
<Toggle label="Use TLS" checked={t.smtp_tls ?? true} onChange={(v) => upd(key, { smtp_tls: v })} />
<TextInput label="From address" value={t.from_address || ''} onChange={(v) => upd(key, { from_address: v })} placeholder="alerts@example.com" />
</div>

View file

@ -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

View file

@ -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:

View file

@ -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}

View file

@ -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)

View file

@ -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()
]

View file

@ -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