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