"""Regression guards for the print/PDF Content-Security-Policy. The in-app print dialog loads a generated PDF as a same-origin ``blob:`` URL into an iframe and calls ``contentWindow.print()``. If the CSP omits ``frame-src`` / ``child-src`` for ``blob:`` the directive falls back to ``default-src 'self'``, which silently blocks the frame and breaks printing for every user (regardless of role). These tests pin the policy on both layers that emit it: * the FastAPI security middleware (covers every API response), and * the production nginx config (the source of the *document* CSP that actually governs ``frame-src`` in the browser). """ from __future__ import annotations import re from pathlib import Path import pytest from fastapi.testclient import TestClient from app.core.config import settings from app.core.security import issue_token from app.main import app REPO_ROOT = Path(__file__).resolve().parents[2] NGINX_CONFIGS = [REPO_ROOT / "deploy" / "nginx" / "clients.lean-101.conf"] # Directives the print flow depends on. blob: must be framable, and that must not # come at the cost of dropping the same-origin baseline. REQUIRED_FRAME_SOURCES = {"'self'", "blob:"} def _parse_csp(header: str) -> dict[str, set[str]]: """Parse a CSP header string into ``{directive: {sources}}``.""" directives: dict[str, set[str]] = {} for part in header.split(";"): tokens = part.split() if not tokens: continue directives[tokens[0].lower()] = set(tokens[1:]) return directives def _assert_blob_framing(header: str) -> None: csp = _parse_csp(header) # frame-src must exist and allow self + blob (no falling back to default-src). assert "frame-src" in csp, f"frame-src missing from CSP: {header!r}" assert REQUIRED_FRAME_SOURCES <= csp["frame-src"], ( f"frame-src must allow {REQUIRED_FRAME_SOURCES}, got {csp['frame-src']}" ) # child-src is the Safari fallback for frame-src; keep it aligned. assert "child-src" in csp, f"child-src missing from CSP: {header!r}" assert "blob:" in csp["child-src"], f"child-src must allow blob:, got {csp['child-src']}" # We only widened framing: the same-origin default must stay intact. assert csp.get("default-src") == {"'self'"}, f"default-src weakened: {csp.get('default-src')}" # --- Backend middleware policy ------------------------------------------------ @pytest.fixture() def client() -> TestClient: with TestClient(app) as test_client: yield test_client def test_backend_csp_allows_blob_frames(client: TestClient) -> None: response = client.get("/health") assert "content-security-policy" in response.headers _assert_blob_framing(response.headers["content-security-policy"]) def test_backend_csp_present_for_all_users(client: TestClient) -> None: """The policy is identical for anonymous, authenticated, and rejected (401) requests, so printing can never depend on who is signed in.""" admin_token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"}) responses = [ client.get("/health"), # anonymous client.get("/api/access/me"), # the endpoint that 401s for warehouse users client.get("/api/access/me", headers={"Authorization": f"Bearer {admin_token}"}), ] policies = set() for response in responses: header = response.headers.get("content-security-policy") assert header is not None, f"CSP missing on {response.request.url} ({response.status_code})" _assert_blob_framing(header) policies.add(header) assert len(policies) == 1, "CSP must not vary by authentication state" # --- Production document policy (nginx) --------------------------------------- def test_nginx_csp_allows_blob_frames() -> None: """Every CSP the production nginx emits must allow blob framing. This guards the *document* policy, which is what the browser enforces for the print iframe.""" csp_line = re.compile(r'Content-Security-Policy\s+"([^"]+)"', re.IGNORECASE) for config_path in NGINX_CONFIGS: assert config_path.exists(), f"missing nginx config: {config_path}" text = config_path.read_text(encoding="utf-8") policies = csp_line.findall(text) assert policies, f"no Content-Security-Policy header found in {config_path}" for policy in policies: _assert_blob_framing(policy)