"""Tests for checklist section 3 — Network & Transport. Covers: - Security headers (CSP, X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy) - CSP frame-ancestors + upgrade-insecure-requests - CORS policy: explicit methods/headers, no wildcard for credentialed requests """ from __future__ import annotations import pytest from httpx import AsyncClient # ── Security headers ──────────────────────────────────────────────────────────── async def test_x_content_type_options_nosniff(client: AsyncClient) -> None: """X-Content-Type-Options: nosniff must be present on every response.""" resp = await client.get("/") assert resp.headers.get("X-Content-Type-Options") == "nosniff" async def test_x_frame_options_deny(client: AsyncClient) -> None: """X-Frame-Options: DENY must be present on every response.""" resp = await client.get("/") assert resp.headers.get("X-Frame-Options") == "DENY" async def test_csp_header_present(client: AsyncClient) -> None: """Content-Security-Policy header must be present on every response.""" resp = await client.get("/") csp = resp.headers.get("Content-Security-Policy", "") assert csp, "Content-Security-Policy header is missing" async def test_csp_frame_ancestors_none(client: AsyncClient) -> None: """CSP must include frame-ancestors 'none' to prevent clickjacking.""" resp = await client.get("/") csp = resp.headers.get("Content-Security-Policy", "") assert "frame-ancestors 'none'" in csp async def test_csp_no_unsafe_inline_scripts(client: AsyncClient) -> None: """CSP script-src must not include 'unsafe-inline' (XSS vector).""" resp = await client.get("/") csp = resp.headers.get("Content-Security-Policy", "") # script-src directive must not contain 'unsafe-inline' script_src_part = "" for directive in csp.split(";"): if directive.strip().startswith("script-src"): script_src_part = directive break assert "'unsafe-inline'" not in script_src_part, ( f"script-src contains 'unsafe-inline': {script_src_part!r}" ) async def test_csp_upgrade_insecure_requests(client: AsyncClient) -> None: """CSP must include upgrade-insecure-requests to block mixed-content.""" resp = await client.get("/") csp = resp.headers.get("Content-Security-Policy", "") assert "upgrade-insecure-requests" in csp async def test_referrer_policy_set(client: AsyncClient) -> None: """Referrer-Policy header must be present.""" resp = await client.get("/") assert resp.headers.get("Referrer-Policy"), "Referrer-Policy header is missing" async def test_security_headers_on_api_endpoint(client: AsyncClient) -> None: """Security headers must be present on API responses, not just HTML.""" resp = await client.get("/api/repos") assert resp.headers.get("X-Content-Type-Options") == "nosniff" assert resp.headers.get("X-Frame-Options") == "DENY" assert resp.headers.get("Content-Security-Policy") # ── CORS ──────────────────────────────────────────────────────────────────────── async def test_cors_preflight_allows_explicit_methods(client: AsyncClient) -> None: """CORS preflight must allow GET, POST, PATCH, DELETE — not PUT or arbitrary methods.""" resp = await client.options( "/api/repos", headers={ "Origin": "https://localhost:1337", "Access-Control-Request-Method": "DELETE", "Access-Control-Request-Headers": "Authorization", }, ) # 200 or 204 from OPTIONS preflight assert resp.status_code in (200, 204, 400) # The allow-methods header (if present) must not contain PUT allow_methods = resp.headers.get("Access-Control-Allow-Methods", "") if allow_methods: assert "PUT" not in allow_methods.upper().split(", "), ( f"PUT should not be in CORS allowed methods: {allow_methods}" ) async def test_cors_does_not_echo_wildcard_origin_with_credentials( client: AsyncClient, ) -> None: """When cors_origins is empty (default in tests), no ACAO header is echoed back. If cors_origins contained '*', allow_credentials=True would be a browser security violation. The config validator warns and the middleware falls back to rejecting such requests — verifying no wildcard leaks here. """ resp = await client.get( "/api/repos", headers={"Origin": "http://evil.example.com"}, ) acao = resp.headers.get("Access-Control-Allow-Origin", "") # Should not be the wildcard '*' (browser would reject credentialed req anyway, # but we ensure the header is not present at all for unlisted origins). assert acao != "*", "CORS must not allow all origins unconditionally" def test_cors_config_explicit_methods() -> None: """Unit test: CORSMiddleware is not configured with allow_methods=['*'].""" from musehub.main import app for middleware in app.user_middleware: cls = getattr(middleware, "cls", None) kwargs = getattr(middleware, "kwargs", {}) if cls is not None and "cors" in getattr(cls, "__name__", "").lower(): methods = kwargs.get("allow_methods", []) assert methods != ["*"], ( "allow_methods=['*'] is too broad — use an explicit list" ) # Ensure common methods are present for m in ("GET", "POST", "PATCH", "DELETE"): assert m in methods, f"{m} missing from CORS allow_methods" return # CORSMiddleware not registered — that's also fine (no CORS needed) def test_cors_config_explicit_headers() -> None: """Unit test: CORSMiddleware is not configured with allow_headers=['*'].""" from musehub.main import app for middleware in app.user_middleware: cls = getattr(middleware, "cls", None) kwargs = getattr(middleware, "kwargs", {}) if cls is not None and "cors" in getattr(cls, "__name__", "").lower(): headers = kwargs.get("allow_headers", []) assert headers != ["*"], ( "allow_headers=['*'] is too broad — use an explicit list" ) assert "Authorization" in headers assert "Content-Type" in headers return