Files
hermes-webui/tests/test_session_static_assets.py
T
Hermes Bot 8f58688b66 test: lock /session/static MIME-type + auth fix; drop unused import
- Add tests/test_session_static_assets.py (5 tests):
  * /session/static/style.css must return text/css (not text/html)
  * /session/static/ui.js must return application/javascript
  * /session/<id> still serves the HTML index (catch-all not weakened)
  * Path-traversal still sandboxed after prefix strip
  * /session/static/* matches /static/* auth-exemption policy
- Drop unused 'from urllib.parse import urlparse as _up' import from
  PR #1505's added block (parsed._replace already gives a usable result).

Co-authored-by: Rick Chew <rickchew@users.noreply.github.com>
2026-05-03 05:20:19 +00:00

132 lines
5.0 KiB
Python

"""Regression tests for PR #1505 — /session/static/* must serve static assets, not the HTML index.
Bug shape (pre-fix):
Browsers visiting /session/<id> resolved relative `<link rel="stylesheet" href="static/style.css">`
references against `/session/`, producing requests like /session/static/style.css. The
catch-all `parsed.path.startswith("/session/")` matched FIRST and returned the HTML index
with Content-Type: text/html, which strict-MIME browsers refused to apply as a stylesheet.
Fix: handle_get() now intercepts /session/static/* BEFORE the catch-all and delegates to
_serve_static() with the /session prefix stripped. check_auth() also exempts /session/static/*
from auth (same policy as /static/*).
These tests pin both the routing fix AND the auth exemption so a future refactor of either
path can't silently re-introduce the MIME-type bug.
"""
from types import SimpleNamespace
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_session_static_css_returns_text_css_mime(monkeypatch):
"""/session/<id>/static/style.css must return Content-Type: text/css, not text/html.
This is the exact failure mode PR #1505 fixes: strict-MIME browsers refuse to apply
a stylesheet served as text/html.
"""
from api.routes import handle_get
handler = _FakeHandler()
parsed = urlparse("http://example.com/session/static/style.css")
assert handle_get(handler, parsed) is True
assert handler.status == 200
ct = handler.header("Content-Type") or ""
assert ct.startswith("text/css"), f"expected text/css, got {ct!r}"
# Sanity: real CSS bytes, not the 100KB HTML index page
assert b"<!doctype html>" not in handler.body[:200].lower()
def test_session_static_js_returns_javascript_mime(monkeypatch):
"""/session/<id>/static/ui.js must return application/javascript, not text/html."""
from api.routes import handle_get
handler = _FakeHandler()
parsed = urlparse("http://example.com/session/static/ui.js")
assert handle_get(handler, parsed) is True
assert handler.status == 200
ct = handler.header("Content-Type") or ""
assert ct.startswith("application/javascript"), f"expected application/javascript, got {ct!r}"
def test_session_html_route_still_serves_index():
"""Sibling regression: /session/<id> (no /static/) must still return the HTML index.
The new /session/static/ guard is positioned before the catch-all; this test ensures
the catch-all itself wasn't accidentally reordered or weakened.
"""
from api.routes import handle_get
handler = _FakeHandler()
parsed = urlparse("http://example.com/session/abc123def456")
handle_get(handler, parsed)
assert handler.status == 200
ct = handler.header("Content-Type") or ""
assert ct.startswith("text/html"), f"expected text/html, got {ct!r}"
# And the body really is the HTML index, not a 404 stub
assert b"<!doctype html>" in bytes(handler.body[:200]).lower()
def test_session_static_path_traversal_blocked():
"""Path-traversal sandbox in _serve_static must still apply after the prefix strip.
/session/static/../../etc/passwd → strips to /static/../../etc/passwd → _serve_static
resolves and rejects via relative_to(static_root) → 404.
"""
from api.routes import handle_get
handler = _FakeHandler()
parsed = urlparse("http://example.com/session/static/../../etc/passwd")
handle_get(handler, parsed)
assert handler.status == 404
def test_session_static_auth_exemption(monkeypatch):
"""/session/static/* must be auth-exempt (same policy as /static/*).
Without this exemption, anonymous browser navigation to /session/<id> would
302-redirect every stylesheet/script to /login, breaking the page even when
the HTML index itself loaded correctly.
"""
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
from api.auth import check_auth
# /session/static/* is public (matches /static/* policy)
handler = _FakeHandler()
assert check_auth(handler, SimpleNamespace(path="/session/static/style.css", query="")) is True
# Confirm the /static/ baseline still works (regression guard)
handler = _FakeHandler()
assert check_auth(handler, SimpleNamespace(path="/static/style.css", query="")) is True
# And confirm a non-static /session/* path still requires auth
handler = _FakeHandler()
assert check_auth(handler, SimpleNamespace(path="/session/abc123", query="")) is False