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>
2026-07-05 17:57:45 -06:00
|
|
|
"""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",
|
chore(wzdx): remove two dead config fields (#150)
Both were fully plumbed and read by nothing.
api_key -- self-documented as dead at config.py ("Keyless: api_key is
retained but unused"), yet wired end-to-end: a GUI ManagedSecret field, a
SECRET_FIELDS entry, an EXPECTED_SECRETS entry, a secrets_store mapping and
label, and a line in .env.example. env/wzdx.py assigned self._api_key and
never read it again. So an operator could go get an API key, paste it into
the secure secrets manager, and have it do precisely nothing -- the ritual
looked complete end to end, which is what made it worth removing rather
than leaving.
endpoints -- default ["/get/event"], exposed as an editable list in the
dashboard, never read. Copy-paste from Roads511Config.endpoints (which IS
read, at env/roads511.py; Roads511 is untouched here). WZDx discovers feeds
via the FHWA registry_url/states instead.
WZDx's actual fetch behavior is unchanged; this removes dead config only.
Note for existing installs: anyone with WZDX_API_KEY set in
/data/secrets/.env will simply have an ignored env var. Harmless -- it was
already ignored.
Suite: 2337 passed, 6 failed (the pre-existing set: stale SCHEMA_VERSION x3,
expired TLE fixtures x2, one order-dependent), 72 skipped -- exact baseline
match, no new failures.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:07:46 -06:00
|
|
|
"TOMTOM_API_KEY", "FIRMS_MAP_KEY", "ROADS511_API_KEY",
|
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>
2026-07-05 17:57:45 -06:00
|
|
|
"SMTP_PASSWORD", "MESHMONITOR_API_TOKEN", "MQTT_PASSWORD",
|
|
|
|
|
}
|
|
|
|
|
assert set(secrets_store.SECRET_LABELS.keys()) == expected
|