diff --git a/api/routes.py b/api/routes.py index 85b404ae..4cdb91c5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -13574,7 +13574,7 @@ def handle_get(handler, parsed) -> bool: if parsed.path in ("/session/manifest.json", "/session/manifest.webmanifest"): return _serve_manifest(handler) - if parsed.path in ("/", "/index.html") or parsed.path.startswith("/session/"): + if parsed.path in ("/", "/index.html", "/sessions") or parsed.path.startswith("/session/"): try: from api.extensions import inject_extension_tags diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5840a449..f3335f98 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -211,6 +211,16 @@ For a foreground `python3 bootstrap.py`, stop it with Ctrl-C and start it again. --- +## 404 after login when password auth is enabled + +**Symptom.** After enabling password authentication (`HERMES_WEBUI_PASSWORD`), logging in redirects to `/sessions` and the browser shows a `404 not found` error instead of the chat interface. + +**Why.** The server-side redirect after login targets `/sessions` (plural), but that path was missing from the explicit SPA-shell allowlist in `handle_get()`. Without auth the bug is invisible because the SPA handles `/sessions` client-side and the server route is never hit — only the server-side post-login redirect exposes it. + +**Fix.** `/sessions` is now included alongside `/` and `/index.html` in the set of paths that serve the SPA shell. No configuration change is needed. + +--- + ## Other troubleshooting This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: **Symptom → Why → Diagnostic commands → Fix → When to file a bug**. diff --git a/tests/test_sessions_route.py b/tests/test_sessions_route.py new file mode 100644 index 00000000..efec3f65 --- /dev/null +++ b/tests/test_sessions_route.py @@ -0,0 +1,91 @@ +"""Regression test for PR #7598 — /sessions must serve the SPA shell, not 404. + +Bug shape (pre-fix): + When password auth is enabled, the server redirects to /sessions after a successful + login. However, /sessions (plural) was missing from the explicit path allowlist in + handle_get(), so the server returned a 404 "not found" instead of serving index.html. + + The bug was invisible without auth because the SPA handles /sessions client-side and + the server route was never hit — only the server-side redirect after login exposed it. + +Fix: handle_get() now includes "/sessions" alongside "/" and "/index.html" in the set +of paths that serve the SPA shell. +""" + +from urllib.parse import urlparse + + +class _FakeHandler: + def __init__(self): + self.status = None + self.sent_headers = [] + self.body = bytearray() + self.wfile = self + self.headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, name, value): + self.sent_headers.append((name, value)) + + def end_headers(self): + pass + + def write(self, data): + self.body.extend(data) + + def header(self, name): + for key, value in self.sent_headers: + if key.lower() == name.lower(): + return value + return None + + +def test_sessions_route_serves_spa_shell(): + """/sessions (plural) must serve the HTML index, not a 404. + + This is the exact failure mode PR #7598 fixes: after login with auth enabled, + the server redirects to /sessions, which was missing from the SPA-shell + allowlist in handle_get(). + """ + from api.routes import handle_get + + handler = _FakeHandler() + parsed = urlparse("http://example.com/sessions") + assert handle_get(handler, parsed) is not False + assert handler.status == 200 + ct = handler.header("Content-Type") or "" + assert ct.startswith("text/html"), f"expected text/html, got {ct!r}" + assert b"" in bytes(handler.body[:200]).lower() + + +def test_sessions_route_with_query_string_serves_spa_shell(): + """/sessions?foo=bar must still serve the HTML index. + + Ensures query-string variants of /sessions are handled correctly because + matching uses the parsed path (without query string). + """ + from api.routes import handle_get + + handler = _FakeHandler() + parsed = urlparse("http://example.com/sessions?limit=50&offset=0") + assert handle_get(handler, parsed) is not False + assert handler.status == 200 + ct = handler.header("Content-Type") or "" + assert ct.startswith("text/html"), f"expected text/html, got {ct!r}" + + +def test_sessions_route_does_not_return_404(): + """Regression guard: /sessions must never return 404. + + Before the fix, handle_get() returned False for /sessions, which the + caller converted into a 404 'not found' JSON response. + """ + from api.routes import handle_get + + handler = _FakeHandler() + parsed = urlparse("http://example.com/sessions") + result = handle_get(handler, parsed) + assert result is not False, "/sessions must be handled (not 404)" + assert handler.status == 200