"""Tests for :class:`app.middleware.SecurityHeadersMiddleware`. The middleware is wired into the real application by :func:`app.main.create_app`, so these tests exercise it end-to-end via the module-level ``app`` instance — no bespoke app / harness. Coverage: - Every expected response header is present on the homepage. - The CSP header carries a ``nonce-`` directive whose value changes across requests (so a replayed page can't re-authorize an attacker-controlled script). - HSTS is **absent** in development — otherwise a browser would refuse to load ``http://127.0.0.1:8000`` after a single successful test run. """ from __future__ import annotations import re import pytest from fastapi.testclient import TestClient from app.main import app @pytest.fixture(scope="module") def client() -> TestClient: """Module-scoped TestClient so each test reuses the running app.""" return TestClient(app) # CSP nonces are emitted as ``'nonce-'``; capture the payload. _NONCE_RE = re.compile(r"'nonce-([A-Za-z0-9_\-]+)'") def _extract_nonce(csp_header: str) -> str: """Pull the ``nonce-`` token out of a CSP header string.""" match = _NONCE_RE.search(csp_header) assert match, f"CSP header is missing a nonce directive: {csp_header!r}" return match.group(1) def test_csp_header_present_and_carries_nonce(client: TestClient) -> None: """The homepage returns a CSP header with a nonce- directive.""" response = client.get("/") assert response.status_code == 200 csp = response.headers.get("Content-Security-Policy") assert csp is not None, "CSP header missing on /" # Sanity: the directives we care about are all present. assert "default-src 'self'" in csp assert "frame-ancestors 'none'" in csp assert "form-action 'self'" in csp assert "https://js.hcaptcha.com" in csp # hCaptcha allowlist intact. nonce = _extract_nonce(csp) assert len(nonce) >= 16, f"nonce looks too short: {nonce!r}" def test_csp_nonce_changes_across_requests(client: TestClient) -> None: """Two requests must see two different nonces. A reused nonce would let an attacker who captured one response authorize a script in a later response — hence the per-request fresh mint in :class:`SecurityHeadersMiddleware.dispatch`. """ first = client.get("/") second = client.get("/") assert first.status_code == 200 assert second.status_code == 200 nonce_a = _extract_nonce(first.headers["Content-Security-Policy"]) nonce_b = _extract_nonce(second.headers["Content-Security-Policy"]) assert nonce_a != nonce_b, "CSP nonce must be fresh per response" def test_hsts_absent_in_development(client: TestClient) -> None: """HSTS is production-only; the dev TestClient must not see it.""" response = client.get("/") assert response.status_code == 200 assert "Strict-Transport-Security" not in response.headers def test_other_security_headers_present(client: TestClient) -> None: """Spot-check every non-CSP security header we commit to.""" response = client.get("/") assert response.status_code == 200 expected = { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", "Cross-Origin-Opener-Policy": "same-origin", } for name, value in expected.items(): assert response.headers.get(name) == value, ( f"{name}: expected {value!r}, " f"got {response.headers.get(name)!r}" ) # Permissions-Policy is multi-valued; just assert the key disables # we care about are present rather than pinning the full string. perm = response.headers.get("Permissions-Policy", "") for directive in ("camera=()", "geolocation=()", "microphone=()"): assert directive in perm, ( f"Permissions-Policy missing {directive!r}; got {perm!r}" ) def test_nonce_appears_in_rendered_template(client: TestClient) -> None: """The per-request CSP nonce must be reachable from Jinja templates. The public base template stamps the nonce onto the inline nav-toggle