diff --git a/dashboard/app.py b/dashboard/app.py index 55970b0..832a576 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -2046,6 +2046,27 @@ def _empty_payload(): # --- Static --- + +class _NoCacheHTMLStaticFiles(StaticFiles): + """StaticFiles that forces revalidation of the HTML app shell. + + Starlette's StaticFiles sends an ETag + Last-Modified but NO Cache-Control, + which lets browsers apply *heuristic* freshness and serve a stale index.html + without revalidating — so a rebuilt dashboard (new SSO routes, new service + cards, etc.) can keep showing the old shell until a hard refresh. We add + `Cache-Control: no-cache` to HTML responses only: the browser still caches + the shell but MUST revalidate the ETag every load, so a new build is picked + up immediately. Hashed/static assets keep their default long-cache behavior. + """ + + async def get_response(self, path, scope): + response = await super().get_response(path, scope) + content_type = response.headers.get("content-type", "") + if content_type.startswith("text/html"): + response.headers["Cache-Control"] = "no-cache" + return response + + static_dir = Path(__file__).parent / "static" if static_dir.exists(): - app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") + app.mount("/", _NoCacheHTMLStaticFiles(directory=str(static_dir), html=True), name="static") diff --git a/tests/test_services_and_throughput.py b/tests/test_services_and_throughput.py index 7589856..4da654c 100644 --- a/tests/test_services_and_throughput.py +++ b/tests/test_services_and_throughput.py @@ -170,3 +170,39 @@ def _boom(): assert data["detail"] == "Internal server error" # Must NOT contain the traceback assert "test boom" not in str(data) + + +# ── Static app-shell caching ───────────────────────────────────────────────── + +def test_index_html_sends_no_cache(client): + """The HTML app shell must revalidate every load, so a rebuilt dashboard + (new SSO routes / service cards) is picked up without a hard refresh.""" + r = client.get("/") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("text/html") + assert r.headers.get("cache-control") == "no-cache" + # Still a validated cache — the ETag is what the browser revalidates against. + assert r.headers.get("etag") + + +# ── SSO-routed service entries (obsidian regression) ───────────────────────── + +def test_obsidian_entry_is_sso_routed_like_codebase_memory(): + """The obsidian card must render the clean /obsidian/ SSO link, never a raw + :3000 URL. That requires the SAME entry shape as codebase-memory-ui: a `port` + (for the health probe), NO `url` key (so serviceOpenHref uses SSO_ROUTES), + and a hint that references the same-origin path, not a host:port.""" + from dashboard.services_catalog import SERVICES + + by_id = {s["id"]: s for s in SERVICES} + obsidian = by_id["obsidian"] + reference = by_id["codebase-memory-ui"] + + # No `url` key -> serviceOpenHref falls through to SSO_ROUTES['obsidian']. + assert "url" not in obsidian, "obsidian must not set `url` (would leak :3000)" + assert "url" not in reference # sanity: the working precedent has no url either + # Port exists for the health probe but is never rendered as a link/label. + assert obsidian["port"] == 3000 + # Hint (shown only when offline) must not expose a raw host:port. + assert ":3000" not in obsidian["hint"] + assert "/obsidian/" in obsidian["hint"]