"""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 pytest # Same xdist group as the other dashboard-auth tests — they all mutate # web_server.app.state.auth_required at module level. pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state") from fastapi.testclient import TestClient from hermes_cli import web_server from hermes_cli.dashboard_auth import clear_providers, register_provider from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider @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 # --------------------------------------------------------------------------- # 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 # /login redirect must include the prefix or the browser will # follow it to mission-control.tilos.com/login (which the proxy # doesn't route to the dashboard). assert r.headers["location"].startswith("/hermes/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 assert r.headers["location"] == "/login?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 "