"""End-to-end behavioural tests for the dashboard auth gate. Uses ``StubAuthProvider`` so the OAuth round trip can complete in-process without any external IDP. Exercises: * `/api/status` flips from public (loopback) to gated (auth_required) * `/` redirects to /login when no cookie present * `/api/auth/providers` is the public bootstrap endpoint * `/login` renders HTML listing all providers * /assets/* still passes through unauthenticated * Full /auth/login → /auth/callback → / round trip with the stub * Invalid / missing cookies return 401 (api) or 302 (html) * Zero-providers + gate-on fails closed """ from __future__ import annotations import json 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.cookies import SESSION_AT_COOKIE from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider @pytest.fixture def gated_app(): """Configure web_server.app for gated mode + register the stub provider.""" 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 # Use https base_url so cookies pick up Secure flag and host_header # matches the bound interface. 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 # --------------------------------------------------------------------------- # Allowlist (public) routes # --------------------------------------------------------------------------- @pytest.mark.parametrize( "next_value", [ "", "javascript:alert(1)", "../../etc/passwd", "canary\r\nSet-Cookie: injected=1", ], ) def test_empty_provider_login_page_is_safe_through_real_route( gated_app, next_value ): clear_providers() response = gated_app.get("/login", params={"next": next_value}) assert response.status_code == 200 assert "text/html" in response.headers["content-type"] assert "no-store" in response.headers["cache-control"] assert "Sign-in unavailable" in response.text assert "username/password provider" in response.text assert "OAuth provider" in response.text assert "--insecure" not in response.text assert next_value not in response.text def test_gated_status_is_public(gated_app): """``/api/status`` MUST be public under the OAuth gate. Regression guard for the wildcard-subdomain rollout: NAS (``fly-provider.ts`` ``getInstanceRuntimeStatus``) hits ``/api/status`` without a cookie as its sole liveness probe. A 401 here surfaces every healthy agent as STARTING/down in the portal UI. The endpoint returns only version + gateway/auth-gate metadata (no user data, no session content), so it stays in the shared ``PUBLIC_API_PATHS`` allowlist under both the legacy ``_SESSION_TOKEN`` gate and the OAuth gate. The body also reports the gate's shape (``auth_required``, ``auth_providers``) so the SPA's StatusPage and external monitors can distinguish loopback / gated / no-providers without a separate round trip. """ r = gated_app.get("/api/status") assert r.status_code == 200, ( f"Expected 200, got {r.status_code}: {r.text}" ) body = r.json() assert body["auth_required"] is True assert "version" in body assert "gateway_state" in body @pytest.mark.parametrize("path", [ "/api/health", "/api/config/defaults", "/api/config/schema", "/api/model/info", "/api/dashboard/themes", "/api/dashboard/plugins", ]) def test_other_public_api_paths_are_public_under_gate(gated_app, path): """The remaining ``PUBLIC_API_PATHS`` entries must also bypass the gate. They're documented as non-sensitive read-only endpoints that the SPA pre-loads before login (themes, config schema, model metadata). A 401 / 302-to-login here would block the dashboard shell from rendering pre-auth. Accept any non-auth-failure status: 200 when the route succeeds, or any route-specific error (e.g. 400 / 404 / 500 from a missing dependency) — but NEVER 401, and NEVER a 302 to ``/login``. """ r = gated_app.get(path, follow_redirects=False) assert r.status_code != 401, ( f"{path} returned 401 under the OAuth gate — should be public" ) if r.status_code == 302: location = r.headers.get("location", "") assert "/login" not in location, ( f"{path} redirected to {location} — should be public, " "not bounced to /login" ) def test_plugin_assets_pass_gate_while_api_stays_gated(gated_app): """Plugin JS/CSS bundles are mounted at ``/dashboard-plugins/*`` and loaded by the SPA via ``