gabriel / musehub public
test_network_transport.py python
150 lines 6.4 KB
Raw
sha256:7d6dd8f4a89e2d1fef2d84f6e65feaff51385d382f466766b7f690a22ec18e32 fix: fall back to DB ancestry check when mpack-only fast-fo… Sonnet 4.6 patch 6 days ago
1 """Tests for checklist section 3 — Network & Transport.
2
3 Covers:
4 - Security headers (CSP, X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy)
5 - CSP frame-ancestors + upgrade-insecure-requests
6 - CORS policy: explicit methods/headers, no wildcard for credentialed requests
7 """
8 from __future__ import annotations
9
10 import pytest
11 from httpx import AsyncClient
12
13
14 # ── Security headers ────────────────────────────────────────────────────────────
15
16 async def test_x_content_type_options_nosniff(client: AsyncClient) -> None:
17 """X-Content-Type-Options: nosniff must be present on every response."""
18 resp = await client.get("/")
19 assert resp.headers.get("X-Content-Type-Options") == "nosniff"
20
21
22 async def test_x_frame_options_deny(client: AsyncClient) -> None:
23 """X-Frame-Options: DENY must be present on every response."""
24 resp = await client.get("/")
25 assert resp.headers.get("X-Frame-Options") == "DENY"
26
27
28 async def test_csp_header_present(client: AsyncClient) -> None:
29 """Content-Security-Policy header must be present on every response."""
30 resp = await client.get("/")
31 csp = resp.headers.get("Content-Security-Policy", "")
32 assert csp, "Content-Security-Policy header is missing"
33
34
35 async def test_csp_frame_ancestors_none(client: AsyncClient) -> None:
36 """CSP must include frame-ancestors 'none' to prevent clickjacking."""
37 resp = await client.get("/")
38 csp = resp.headers.get("Content-Security-Policy", "")
39 assert "frame-ancestors 'none'" in csp
40
41
42 async def test_csp_no_unsafe_inline_scripts(client: AsyncClient) -> None:
43 """CSP script-src must not include 'unsafe-inline' (XSS vector)."""
44 resp = await client.get("/")
45 csp = resp.headers.get("Content-Security-Policy", "")
46 # script-src directive must not contain 'unsafe-inline'
47 script_src_part = ""
48 for directive in csp.split(";"):
49 if directive.strip().startswith("script-src"):
50 script_src_part = directive
51 break
52 assert "'unsafe-inline'" not in script_src_part, (
53 f"script-src contains 'unsafe-inline': {script_src_part!r}"
54 )
55
56
57 async def test_csp_upgrade_insecure_requests(client: AsyncClient) -> None:
58 """CSP must include upgrade-insecure-requests to block mixed-content."""
59 resp = await client.get("/")
60 csp = resp.headers.get("Content-Security-Policy", "")
61 assert "upgrade-insecure-requests" in csp
62
63
64 async def test_referrer_policy_set(client: AsyncClient) -> None:
65 """Referrer-Policy header must be present."""
66 resp = await client.get("/")
67 assert resp.headers.get("Referrer-Policy"), "Referrer-Policy header is missing"
68
69
70 async def test_security_headers_on_api_endpoint(client: AsyncClient) -> None:
71 """Security headers must be present on API responses, not just HTML."""
72 resp = await client.get("/api/repos")
73 assert resp.headers.get("X-Content-Type-Options") == "nosniff"
74 assert resp.headers.get("X-Frame-Options") == "DENY"
75 assert resp.headers.get("Content-Security-Policy")
76
77
78 # ── CORS ────────────────────────────────────────────────────────────────────────
79
80 async def test_cors_preflight_allows_explicit_methods(client: AsyncClient) -> None:
81 """CORS preflight must allow GET, POST, PATCH, DELETE — not PUT or arbitrary methods."""
82 resp = await client.options(
83 "/api/repos",
84 headers={
85 "Origin": "https://localhost:1337",
86 "Access-Control-Request-Method": "DELETE",
87 "Access-Control-Request-Headers": "Authorization",
88 },
89 )
90 # 200 or 204 from OPTIONS preflight
91 assert resp.status_code in (200, 204, 400)
92 # The allow-methods header (if present) must not contain PUT
93 allow_methods = resp.headers.get("Access-Control-Allow-Methods", "")
94 if allow_methods:
95 assert "PUT" not in allow_methods.upper().split(", "), (
96 f"PUT should not be in CORS allowed methods: {allow_methods}"
97 )
98
99
100 async def test_cors_does_not_echo_wildcard_origin_with_credentials(
101 client: AsyncClient,
102 ) -> None:
103 """When cors_origins is empty (default in tests), no ACAO header is echoed back.
104
105 If cors_origins contained '*', allow_credentials=True would be a browser
106 security violation. The config validator warns and the middleware falls back
107 to rejecting such requests — verifying no wildcard leaks here.
108 """
109 resp = await client.get(
110 "/api/repos",
111 headers={"Origin": "http://evil.example.com"},
112 )
113 acao = resp.headers.get("Access-Control-Allow-Origin", "")
114 # Should not be the wildcard '*' (browser would reject credentialed req anyway,
115 # but we ensure the header is not present at all for unlisted origins).
116 assert acao != "*", "CORS must not allow all origins unconditionally"
117
118
119 def test_cors_config_explicit_methods() -> None:
120 """Unit test: CORSMiddleware is not configured with allow_methods=['*']."""
121 from musehub.main import app
122 for middleware in app.user_middleware:
123 cls = getattr(middleware, "cls", None)
124 kwargs = getattr(middleware, "kwargs", {})
125 if cls is not None and "cors" in getattr(cls, "__name__", "").lower():
126 methods = kwargs.get("allow_methods", [])
127 assert methods != ["*"], (
128 "allow_methods=['*'] is too broad — use an explicit list"
129 )
130 # Ensure common methods are present
131 for m in ("GET", "POST", "PATCH", "DELETE"):
132 assert m in methods, f"{m} missing from CORS allow_methods"
133 return
134 # CORSMiddleware not registered — that's also fine (no CORS needed)
135
136
137 def test_cors_config_explicit_headers() -> None:
138 """Unit test: CORSMiddleware is not configured with allow_headers=['*']."""
139 from musehub.main import app
140 for middleware in app.user_middleware:
141 cls = getattr(middleware, "cls", None)
142 kwargs = getattr(middleware, "kwargs", {})
143 if cls is not None and "cors" in getattr(cls, "__name__", "").lower():
144 headers = kwargs.get("allow_headers", [])
145 assert headers != ["*"], (
146 "allow_headers=['*'] is too broad — use an explicit list"
147 )
148 assert "Authorization" in headers
149 assert "Content-Type" in headers
150 return
File History 1 commit
sha256:7d6dd8f4a89e2d1fef2d84f6e65feaff51385d382f466766b7f690a22ec18e32 fix: fall back to DB ancestry check when mpack-only fast-fo… Sonnet 4.6 patch 6 days ago