mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 17:04:45 +02:00
feat(v0.6-tail-3): enforce OR-not-AND continuously -- close USGS direct-lookup leak + flag environmental config changes as restart-required
Gap 1 -- env_routes.lookup_usgs_site no longer creates a temporary
USGSStreamsAdapter to hit USGS.gov directly. When the env_store has no
native usgs adapter (because usgs.feed_source != native), the endpoint
returns HTTP 404 with a body that says "site lookup unavailable in
central-feed mode; values must be entered manually or sourced from
Central". This closes the AND-mode anti-pattern Central's v0.10.2
report flagged: meshai was in central-feed mode for usgs but the
lookup helper would still call USGS.gov directly the first time the
dashboard opened the Add-Gauge form.
Gap 2 -- config_routes.RESTART_REQUIRED_SECTIONS gains "environmental"
and the PUT handler now diffs the section before/after, returning
{saved, restart_required, changed_keys}. restart_required is true only
when there are actual changes AND the section is in the restart-required
set, so a no-op PUT to environmental never raises a false alarm.
Frontend wiring:
- New RestartBanner component (yellow top-of-main banner) listens to a
meshai:restart-required CustomEvent + cross-tab storage event,
persists across navigations via localStorage, shows changed_keys
preview + Restart-now button (POSTs /api/system/restart) + dismiss.
- Layout.tsx mounts <RestartBanner /> above {children} so it surfaces
on every page.
- Config.tsx saveSection() now calls notifyRestartRequired(changed_keys)
alongside its existing setRestartRequired(true) when the API flags
the section.
- GaugeSites.tsx probes /api/config/environmental at mount and shows a
"USGS lookup" button next to the site_id input. The button is
disabled with an explanatory tooltip when usgs.feed_source != native,
and gracefully renders the 404 detail when the API returns 404 in
central-feed mode -- enter-manually UX, no silent fallback.
Tests -- tests/test_or_arch_continuous.py (11 cases, all passing):
- USGS lookup 404 with no env_store / no native usgs adapter
- 502 on native-adapter exception
- 200 + payload on native-adapter happy path
- environmental in RESTART_REQUIRED_SECTIONS
- PUT environmental with changed feed_source -> restart_required:true
+ changed_keys list including foo.feed_source dotted path
- PUT bot (non-restart section) -> restart_required:false
- No-op PUT to bot / environmental -> restart_required:false, empty
changed_keys
- _diff_keys helper unit tests (nested dicts, list-element changes)
Why this matters: per the Spokane post-mortem and Central's v0.10.2
response, both sides need belt-and-suspenders against transient
AND-modes. meshai's static OR enforcement at env_store boot is the
runtime guard; this commit makes the GUI honor it continuously --
the lookup helper can't sneak past it any more, and the user is told
explicitly that an environmental config change does not take effect
until the container restarts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
24763947c3
commit
f89e9c11fb
10 changed files with 641 additions and 151 deletions
181
tests/test_or_arch_continuous.py
Normal file
181
tests/test_or_arch_continuous.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""v0.6-tail-3 tests: env_routes 404 + config_routes restart_required diff."""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from meshai.config import Config
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Gap 1 -- env_routes lookup_usgs_site 404 when feed_source != native
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _build_app_with_env_store(env_store):
|
||||
"""Mount the env router on a minimal FastAPI app w/ env_store on state."""
|
||||
from meshai.dashboard.api.env_routes import router as env_router
|
||||
app = FastAPI()
|
||||
app.state.env_store = env_store
|
||||
app.state.config = Config()
|
||||
app.include_router(env_router, prefix="/api")
|
||||
return app
|
||||
|
||||
|
||||
def test_usgs_lookup_404_when_no_env_store():
|
||||
app = _build_app_with_env_store(env_store=None)
|
||||
client = TestClient(app)
|
||||
r = client.get("/api/env/usgs/lookup/13139510")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_usgs_lookup_404_when_no_native_usgs_adapter():
|
||||
"""Central-feed mode: env_store exists but adapters['usgs'] is missing."""
|
||||
env_store = SimpleNamespace(_adapters={}) # no usgs adapter
|
||||
app = _build_app_with_env_store(env_store)
|
||||
client = TestClient(app)
|
||||
r = client.get("/api/env/usgs/lookup/13139510")
|
||||
assert r.status_code == 404
|
||||
body = r.json()
|
||||
# Body must explain the central-feed mode reason so the UI can switch
|
||||
# the form to manual-entry mode.
|
||||
assert "central" in body.get("detail", "").lower()
|
||||
assert "manual" in body.get("detail", "").lower()
|
||||
|
||||
|
||||
def test_usgs_lookup_calls_native_adapter_when_available():
|
||||
"""When the env_store has a native usgs adapter, lookup proxies to it."""
|
||||
fake_adapter = MagicMock()
|
||||
fake_adapter.lookup_site = MagicMock(return_value={
|
||||
"site_id": "13139510",
|
||||
"name": "Big Lost River near Mackay",
|
||||
"lat": 43.91,
|
||||
"lon": -113.62,
|
||||
})
|
||||
env_store = SimpleNamespace(_adapters={"usgs": fake_adapter})
|
||||
app = _build_app_with_env_store(env_store)
|
||||
client = TestClient(app)
|
||||
r = client.get("/api/env/usgs/lookup/13139510")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["name"] == "Big Lost River near Mackay"
|
||||
fake_adapter.lookup_site.assert_called_once_with("13139510")
|
||||
|
||||
|
||||
def test_usgs_lookup_502_on_adapter_exception():
|
||||
"""A failure inside the native adapter surfaces as 502, not 200+error."""
|
||||
fake_adapter = MagicMock()
|
||||
fake_adapter.lookup_site = MagicMock(side_effect=RuntimeError("upstream timeout"))
|
||||
env_store = SimpleNamespace(_adapters={"usgs": fake_adapter})
|
||||
app = _build_app_with_env_store(env_store)
|
||||
client = TestClient(app)
|
||||
r = client.get("/api/env/usgs/lookup/13139510")
|
||||
assert r.status_code == 502
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Gap 2 -- config_routes RESTART_REQUIRED_SECTIONS + changed_keys diff
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_app(tmp_path, monkeypatch):
|
||||
"""FastAPI app w/ config_routes mounted; uses a tmp config dir."""
|
||||
from meshai.dashboard.api.config_routes import router as config_router
|
||||
|
||||
# save_section needs a real config dir to write to.
|
||||
cfg_dir = tmp_path / "cfg"
|
||||
cfg_dir.mkdir()
|
||||
(cfg_dir / "config.yaml").write_text("# stub\n")
|
||||
|
||||
app = FastAPI()
|
||||
app.state.config = Config()
|
||||
app.state.config_path = str(cfg_dir / "config.yaml")
|
||||
app.include_router(config_router, prefix="/api")
|
||||
return app
|
||||
|
||||
|
||||
def test_environmental_in_restart_required_sections():
|
||||
from meshai.dashboard.api.config_routes import RESTART_REQUIRED_SECTIONS
|
||||
assert "environmental" in RESTART_REQUIRED_SECTIONS
|
||||
|
||||
|
||||
def test_put_environmental_returns_restart_required_with_changed_keys(config_app):
|
||||
"""A PUT to environmental that changes feed_source returns
|
||||
restart_required=true + the dotted changed_keys list."""
|
||||
client = TestClient(config_app)
|
||||
|
||||
# Fetch current environmental.
|
||||
cur = client.get("/api/config/environmental")
|
||||
assert cur.status_code == 200
|
||||
body = cur.json()
|
||||
# Mutate firms.feed_source from "native" (default) to "central".
|
||||
body["firms"]["feed_source"] = "central"
|
||||
|
||||
r = client.put("/api/config/environmental", json=body)
|
||||
assert r.status_code == 200
|
||||
result = r.json()
|
||||
assert result["saved"] is True
|
||||
assert result["restart_required"] is True
|
||||
# The dotted key must be present.
|
||||
assert any(k.endswith("firms.feed_source") for k in result["changed_keys"]), (
|
||||
f"changed_keys missing firms.feed_source: {result['changed_keys']}"
|
||||
)
|
||||
|
||||
|
||||
def test_put_non_restart_section_returns_restart_required_false(config_app):
|
||||
"""A PUT to bot (not restart-required) returns restart_required=false."""
|
||||
client = TestClient(config_app)
|
||||
cur = client.get("/api/config/bot").json()
|
||||
cur["name"] = "NEW_NAME"
|
||||
r = client.put("/api/config/bot", json=cur)
|
||||
assert r.status_code == 200
|
||||
result = r.json()
|
||||
assert result["restart_required"] is False
|
||||
assert "bot.name" in result["changed_keys"]
|
||||
|
||||
|
||||
def test_put_with_no_changes_returns_empty_changed_keys(config_app):
|
||||
"""A no-op PUT returns restart_required=false + empty changed_keys."""
|
||||
client = TestClient(config_app)
|
||||
cur = client.get("/api/config/bot").json()
|
||||
r = client.put("/api/config/bot", json=cur)
|
||||
assert r.status_code == 200
|
||||
result = r.json()
|
||||
assert result["restart_required"] is False
|
||||
assert result["changed_keys"] == []
|
||||
|
||||
|
||||
def test_put_environmental_no_change_does_not_flag_restart(config_app):
|
||||
"""Even though environmental is restart-required, an unchanged PUT must
|
||||
not show restart_required=true. The restart-required state is
|
||||
contingent on real diff."""
|
||||
client = TestClient(config_app)
|
||||
cur = client.get("/api/config/environmental").json()
|
||||
r = client.put("/api/config/environmental", json=cur)
|
||||
assert r.status_code == 200
|
||||
result = r.json()
|
||||
assert result["restart_required"] is False
|
||||
assert result["changed_keys"] == []
|
||||
|
||||
|
||||
def test_diff_keys_nested():
|
||||
"""Spot-check the _diff_keys helper on nested dicts + lists."""
|
||||
from meshai.dashboard.api.config_routes import _diff_keys
|
||||
before = {"a": 1, "b": {"x": 1, "y": 2}, "c": [1, 2, 3]}
|
||||
after = {"a": 1, "b": {"x": 9, "y": 2}, "c": [1, 2, 3, 4]}
|
||||
keys = _diff_keys(before, after, prefix="root")
|
||||
assert "root.b.x" in keys
|
||||
assert "root.c" in keys # list length differs -> bracketless
|
||||
assert "root.a" not in keys
|
||||
|
||||
|
||||
def test_diff_keys_list_element_changes():
|
||||
from meshai.dashboard.api.config_routes import _diff_keys
|
||||
before = {"a": [1, 2, 3]}
|
||||
after = {"a": [1, 9, 3]}
|
||||
keys = _diff_keys(before, after, prefix="cfg")
|
||||
assert "cfg.a[1]" in keys
|
||||
Loading…
Add table
Add a link
Reference in a new issue