"""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 "