meshai/tests/test_dashboard_config_save.py

40 lines
1.7 KiB
Python
Raw Normal View History

fix(dashboard): v0.4 C.2.1 -- route PUT /config to multi-file save_section (Rule 17 persistence unblocked) C.2 surfaced that GUI config saves were broken in the prod multi-file layout. This fixes it. Pre-existing v0.3-era bug (predates C.2; affected EVERY config section, not just environmental). Save flow (before -> after): before: PUT /api/config/{section} -> config.py::load_config(config.yaml) [monolithic, vanilla YAML] -> blows up on the !include orchestrator ("could not determine a constructor for the tag '!include'"), then config.py::save_config (same !include-blind path). Every save 500'd; nothing persisted. after: PUT validates the body by coercing to the section dataclass (runs __post_init__ validators, e.g. feed_source), then persists via config_loader.py::save_section(section, dict, config_dir) -- the multi-file / !include-aware writer. It writes ONLY the section's target file (env_feeds.yaml for environmental, notifications.yaml, llm.yaml, ...), strips SECRET_FIELDS (traffic.api_key, firms.map_key) and extracts LOCAL_FIELDS (ducting lat/lon -> local.yaml). The orchestrator config.yaml and its !include directives are never re-parsed. Live app.state.config is kept in sync via setattr when the section isn't restart-required (no disk reload needed). Also: save_section now tolerates a top-level LIST section (mesh_sources) -- it cleans each item for secrets and writes the list directly instead of assuming a dict (which would have crashed). Other callers of save_config are untouched (it remains valid for the monolithic single-file path). Files: meshai/dashboard/api/config_routes.py (PUT handler + import), meshai/config_loader.py (save_section list guard), tests/test_dashboard_config_save.py (new). Verification: - (A) py_compile clean on config_routes.py + config_loader.py. - (C) pytest -q: 272 passed (269 + 3 new -- save_section writes env_feeds, strips secret fields, handles the mesh_sources list section). - (D) Rebuilt prod; ran the C.2 round-trip again, now SUCCESS: backup env_feeds.yaml (md5 dde5d634...), GET then PUT /api/config/environmental -> {"saved":true,"restart_required":false} (NO !include error); disk reflected it (feed_source on all 10 adapters + central block written); restored from backup -> md5 matches original -> DISK_PRISTINE_RESTORED. - (E) Rule 17 round-trip confirmed: the GUI can now SAVE config that round-trips to disk in the multi-file !include layout, secrets staying in .env and local fields in local.yaml. C.3 (quake -> central flip) is now unblocked: feed_source can be flipped and saved from the GUI. Follow-up (non-blocking): mesh_sources per-item secret stripping (mesh_sources.*.api_token) isn't matched by the section-relative check in the new list path; mesh_sources files are volume-only (not git) and this was no worse before, but worth tightening when mesh_sources GUI save is exercised. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:17:30 +00:00
"""v0.4 C.2.1: dashboard config save routes through the multi-file save_section
(the !include-aware writer), not the monolithic save_config."""
import yaml
from meshai.config_loader import save_section
def test_save_section_writes_env_feeds(tmp_path):
(tmp_path / "env_feeds.yaml").write_text("enabled: true\nnws:\n enabled: true\n")
res = save_section("environmental", {
"enabled": True,
"nws": {"enabled": True, "feed_source": "native"},
"usgs_quake": {"enabled": False, "feed_source": "native"},
}, tmp_path)
assert res["saved"] is True
written = yaml.safe_load((tmp_path / "env_feeds.yaml").read_text())
assert written["nws"]["feed_source"] == "native"
assert written["usgs_quake"]["feed_source"] == "native"
# only env_feeds.yaml was written; no config.yaml / orchestrator touched
assert not (tmp_path / "config.yaml").exists()
def test_save_section_strips_secret_fields(tmp_path):
res = save_section("environmental", {
"enabled": True,
"traffic": {"enabled": True, "api_key": "SECRET123"},
}, tmp_path)
written = yaml.safe_load((tmp_path / "env_feeds.yaml").read_text())
assert "api_key" not in written.get("traffic", {})
assert "traffic.api_key" in res["rejected_secrets"]
def test_save_section_handles_list_section(tmp_path):
# mesh_sources is a top-level list section -- must not crash (C.2.1 guard)
res = save_section("mesh_sources", [{"name": "a", "type": "mqtt", "enabled": True}], tmp_path)
assert res["saved"] is True
written = yaml.safe_load((tmp_path / "mesh_sources.yaml").read_text())
assert isinstance(written, list)
assert written[0]["name"] == "a"