""" Security configuration, HTTP headers, CORS, and error-handling tests. Control coverage ──────────────── OWASP ASVS v4.0 V9 Communication Security V14 Configuration Verification OWASP API Top 10 API8:2023 Security Misconfiguration API9:2023 Improper Inventory Management """ import pytest from httpx import AsyncClient pytestmark = pytest.mark.asyncio # ── V14.4 HTTP security response headers ──────────────────────────────────── class TestSecurityHeaders: """ASVS V14.4 | API8 — Security response headers harden browser behaviour. These headers are typically applied by a middleware class or a reverse-proxy (e.g. nginx). """ async def test_x_content_type_options_nosniff(self, client: AsyncClient): """ASVS 14.4.3 | API8 — X-Content-Type-Options: nosniff must be present.""" resp = await client.get("/health") assert resp.headers.get("x-content-type-options") == "nosniff" async def test_x_frame_options_present(self, client: AsyncClient): """ASVS 14.4.5 | API8 — X-Frame-Options: DENY must be present.""" resp = await client.get("/health") assert resp.headers.get("x-frame-options") is not None async def test_content_security_policy_present(self, client: AsyncClient): """ASVS 14.4.6 | API8 — A Content-Security-Policy header must be present.""" resp = await client.get("/health") assert "content-security-policy" in resp.headers async def test_strict_transport_security_present(self, client: AsyncClient): """ASVS 9.2.2 | API8 — Strict-Transport-Security must be present.""" resp = await client.get("/health") assert "strict-transport-security" in resp.headers async def test_referrer_policy_present(self, client: AsyncClient): """ASVS 14.4.4 | API8 — Referrer-Policy must be present.""" resp = await client.get("/health") assert "referrer-policy" in resp.headers # ── V14.3 Error handling ───────────────────────────────────────────────────── class TestErrorHandling: """ASVS V14.3 | API8 — Error responses contain no internal implementation details.""" async def test_404_does_not_expose_internals(self, client: AsyncClient): """ASVS 14.3.2 — 404 for an unknown route contains no stack trace or file paths.""" resp = await client.get("/api/v1/this-endpoint-does-not-exist-xyz") assert resp.status_code == 404 body = resp.text assert "Traceback" not in body assert "site-packages" not in body assert "File /" not in body async def test_422_validation_error_returns_clean_json( self, client: AsyncClient, admin_token: str ): """ASVS 14.3.2 | API8 — Validation failures return Pydantic's structured JSON, no stack trace.""" resp = await client.post( "/api/v1/pages", json={"title": 99, "slug": [], "body": None}, headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 422 data = resp.json() assert "detail" in data assert "Traceback" not in resp.text assert "site-packages" not in resp.text async def test_401_response_includes_www_authenticate(self, client: AsyncClient): """ASVS 3.5.1 | API2 — 401 from the auth layer includes WWW-Authenticate: Bearer.""" resp = await client.post( "/api/v1/auth/login", json={"email": "nobody@example.com", "password": "wrong"}, ) assert resp.status_code == 401 # WWW-Authenticate is required by RFC 7235 for 401 responses assert "www-authenticate" in resp.headers async def test_malformed_json_returns_422_not_500( self, client: AsyncClient, admin_token: str ): """ASVS 14.3.2 — Syntactically invalid JSON body returns 422, not 500.""" resp = await client.post( "/api/v1/pages", content=b"{{not valid json at all{{", headers={ "Authorization": f"Bearer {admin_token}", "Content-Type": "application/json", }, ) assert resp.status_code == 422 assert resp.status_code != 500 async def test_unexpected_content_type_handled( self, client: AsyncClient, admin_token: str ): """ASVS 14.3.2 — Sending plain text to a JSON endpoint returns 422, not 500.""" resp = await client.post( "/api/v1/pages", content=b"title=Test&slug=test&body=body", headers={ "Authorization": f"Bearer {admin_token}", "Content-Type": "text/plain", }, ) assert resp.status_code in (415, 422) assert resp.status_code != 500 # ── V14.5 CORS policy ──────────────────────────────────────────────────────── class TestCORSPolicy: """ASVS V14.5 | API8 — Cross-Origin Resource Sharing is restricted to declared origins.""" async def test_allowed_origin_receives_acao_header(self, client: AsyncClient): """ASVS 14.5.2 — Preflight from an allowed origin gets the correct ACAO header.""" resp = await client.options( "/api/v1/pages", headers={ "Origin": "http://localhost:5173", "Access-Control-Request-Method": "GET", }, ) acao = resp.headers.get("access-control-allow-origin", "") assert acao == "http://localhost:5173" async def test_disallowed_origin_does_not_receive_acao_header( self, client: AsyncClient ): """ASVS 14.5.2 | API8 — Preflight from an unknown origin is not granted cross-origin access. The ACAO header must not be echoed back for arbitrary origins, and must not be the wildcard '*', since credentials are enabled. """ resp = await client.options( "/api/v1/pages", headers={ "Origin": "https://evil.example.com", "Access-Control-Request-Method": "POST", }, ) acao = resp.headers.get("access-control-allow-origin", "") assert acao != "https://evil.example.com" assert acao != "*" async def test_production_origin_receives_acao_header(self, client: AsyncClient): """ASVS 14.5.2 — The production domain is in the CORS allowlist.""" resp = await client.options( "/api/v1/pages", headers={ "Origin": "https://www.goodwalk.co.nz", "Access-Control-Request-Method": "GET", }, ) acao = resp.headers.get("access-control-allow-origin", "") assert acao == "https://www.goodwalk.co.nz" # ── API9 API inventory and documentation exposure ──────────────────────────── class TestAPIInventory: """ASVS V14 | API9:2023 — The API surface is intentional and known.""" async def test_health_endpoint_returns_ok(self, client: AsyncClient): """Health check endpoint is reachable and returns structured JSON.""" resp = await client.get("/health") assert resp.status_code == 200 assert resp.json() == {"status": "ok"} async def test_openapi_schema_is_accessible(self, client: AsyncClient): """API9:2023 — OpenAPI schema is reachable (intentional; document as known exposure). In production this endpoint should be removed or IP-restricted. This test records the current state: it is publicly accessible. The schema must not contain connection strings or private server details. """ resp = await client.get("/openapi.json") assert resp.status_code == 200 schema = resp.json() assert "paths" in schema schema_str = resp.text.lower() # Connection string or internal host details must not appear assert "postgresql" not in schema_str assert "asyncpg" not in schema_str assert "localhost:5432" not in schema_str async def test_swagger_ui_not_publicly_accessible_in_production( self, client: AsyncClient ): """API9:2023 — Interactive API documentation should not be public in production.""" resp = await client.get("/docs") assert resp.status_code in (403, 404)