"""Path-prefix (X-Forwarded-Prefix) awareness for the dashboard-auth gate.
Mission-control style deployments reverse-proxy the dashboard at a path
prefix (e.g. ``mission-control.tilos.com/hermes/*`` -> local Caddy ->
:9119), injecting ``X-Forwarded-Prefix: /hermes`` on every request.
The dashboard already honours this for the SPA bundle (rewriting asset
URLs and the bootstrap ``__HERMES_BASE_PATH__``). The OAuth gate must
honour it too:
1. The gate's ``Location:`` redirect to /login (in
``_unauth_response``) needs to be ``/hermes/login`` so the browser
follows it through the proxy.
2. The 401 JSON envelope's ``login_url`` needs the same prefix so the
SPA's full-page navigation lands at the proxied login page.
3. ``_redirect_uri`` (the OAuth callback URL handed to the IDP) must
reconstruct the public URL including the prefix, otherwise the IDP
redirects back to ``/auth/callback`` instead of
``/hermes/auth/callback`` and the user gets 404.
4. Cookies must use ``Path=/hermes`` when behind a prefix so they
don't leak to other apps on the same origin AND so they get sent
back to the dashboard on subsequent requests under the prefix.
5. The ``__Host-`` cookie prefix requires ``Path=/`` — when behind an
X-Forwarded-Prefix we use ``__Secure-`` instead (matches every
hardening property except scope, which the explicit ``Path``
covers).
These tests document the wire-level contract so a regression in any of
those rules surfaces before a Mission Control deploy.
"""
from __future__ import annotations
import logging
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth import prefix as prefix_mod
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
HA_INGRESS_DASHBOARD_PREFIX = (
"/api/hassio_ingress/8AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEf"
"/dashboard"
)
@pytest.fixture
def gated_app_proxied():
"""web_server.app configured for gated mode with proxy_headers + a
public Host that simulates the Mission Control reverse proxy.
The ``base_url`` sets ``host:scheme`` defaults so we don't have to
pass them on every request. ``X-Forwarded-Prefix`` is passed
per-request because the TestClient doesn't have a way to default
request headers.
"""
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "mission-control.tilos.com"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(
web_server.app,
base_url="https://mission-control.tilos.com",
)
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def gated_app_direct():
"""web_server.app configured for gated mode WITHOUT a proxy prefix,
for the Fly-direct deploy shape (no path mounting).
"""
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(
web_server.app,
base_url="https://fly-app.fly.dev",
)
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# X-Forwarded-Prefix normalisation
# ---------------------------------------------------------------------------
class TestForwardedPrefixNormalisation:
def test_home_assistant_ingress_prefix_with_subpath_is_accepted(
self, caplog
):
"""Home Assistant Supervisor ingress prefixes are 63 chars before
add-ons append their own mount path. They must survive validation so
the SPA receives the correct __HERMES_BASE_PATH__ and asset prefix."""
prefix_mod._warned_malformed_prefixes.clear()
assert len(HA_INGRESS_DASHBOARD_PREFIX) > 64
with caplog.at_level(logging.WARNING, logger=prefix_mod.__name__):
result = prefix_mod.normalise_prefix(HA_INGRESS_DASHBOARD_PREFIX)
assert result == HA_INGRESS_DASHBOARD_PREFIX
assert not [
r for r in caplog.records
if r.levelno == logging.WARNING
and "X-Forwarded-Prefix" in r.getMessage()
]
def test_overlong_prefix_is_rejected_with_deduplicated_warning(
self, caplog
):
"""Keep a bounded header budget, but make rejected non-empty
prefixes diagnosable instead of silently producing root-relative
dashboard URLs."""
prefix_mod._warned_malformed_prefixes.clear()
too_long = "/" + ("a" * 257)
with caplog.at_level(logging.WARNING, logger=prefix_mod.__name__):
for _ in range(3):
assert prefix_mod.normalise_prefix(too_long) == ""
warnings = [
r for r in caplog.records
if r.levelno == logging.WARNING
and "X-Forwarded-Prefix" in r.getMessage()
]
assert len(warnings) == 1
assert "longer than 256 characters" in warnings[0].getMessage()
# ---------------------------------------------------------------------------
# Gate middleware: Location: header and 401 envelope respect prefix
# ---------------------------------------------------------------------------
class TestGateRedirectsCarryPrefix:
def test_html_redirect_to_login_carries_prefix(self, gated_app_proxied):
r = gated_app_proxied.get(
"/sessions",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
assert r.status_code == 302
# Phase 1 (cloud-auto-discovery): a single-provider unauth HTML load
# auto-initiates the OAuth redirect to /auth/login. That redirect must
# ALSO carry the prefix, or the browser follows it to
# mission-control.tilos.com/auth/login (which the proxy doesn't route
# to the dashboard). The prefix-carrying invariant is what's under
# test; only the target path moved from /login to /auth/login.
assert r.headers["location"].startswith("/hermes/auth/login"), (
f"Location header lost prefix: {r.headers['location']!r}"
)
def test_api_401_envelope_login_url_carries_prefix(self, gated_app_proxied):
r = gated_app_proxied.get(
"/api/sessions",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
assert r.status_code == 401
body = r.json()
# SPA does window.location.assign(body.login_url); this MUST
# include the prefix.
assert body["login_url"].startswith("/hermes/login"), (
f"401 envelope login_url lost prefix: {body['login_url']!r}"
)
def test_no_prefix_header_keeps_unprefixed_paths(self, gated_app_direct):
"""When no X-Forwarded-Prefix is sent, the Location header must
NOT gain a phantom prefix — the Fly-direct deploy shape has no
proxy at all."""
r = gated_app_direct.get("/sessions", follow_redirects=False)
assert r.status_code == 302
# Phase 1: single-provider unauth HTML load auto-initiates OAuth to
# /auth/login (no phantom prefix), carrying the original path as next=.
assert r.headers["location"] == (
"/auth/login?provider=stub&next=%2Fsessions"
)
def test_malformed_prefix_header_is_ignored(self, gated_app_proxied):
"""A hostile proxy injects ``X-Forwarded-Prefix: "},
follow_redirects=False,
)
assert r.status_code == 302
assert "