d66af4d8ef
- Add Dependabot configuration for pip, npm, GitHub Actions, Docker - Update copilot instructions to require TODO.md and CHANGELOG.md updates - Add unit tests for middleware, security, app factory, and schemas (125 total tests, 57% coverage) - Update TODO.md with current progress - Update CHANGELOG.md with all changes Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/2f15a52f-6812-4586-9d5c-a144226c842b
128 lines
4.7 KiB
Python
128 lines
4.7 KiB
Python
"""
|
|
Unit tests for security middleware.
|
|
"""
|
|
|
|
from starlette.testclient import TestClient
|
|
from starlette.applications import Starlette
|
|
from starlette.responses import PlainTextResponse
|
|
from starlette.routing import Route
|
|
|
|
from app.core.middleware import SecurityHeadersMiddleware, CSRFProtectionMiddleware
|
|
|
|
|
|
def _make_app(middleware_classes):
|
|
"""Helper to build a Starlette app with given middleware."""
|
|
|
|
async def homepage(request):
|
|
return PlainTextResponse("OK")
|
|
|
|
app = Starlette(
|
|
routes=[
|
|
Route("/", homepage, methods=["GET", "HEAD", "POST", "OPTIONS"]),
|
|
Route("/api/v1/auth/login", homepage, methods=["GET", "POST"]),
|
|
]
|
|
)
|
|
for cls in middleware_classes:
|
|
app.add_middleware(cls)
|
|
return app
|
|
|
|
|
|
class TestSecurityHeadersMiddleware:
|
|
"""Test security headers added to all responses"""
|
|
|
|
def setup_method(self):
|
|
app = _make_app([SecurityHeadersMiddleware])
|
|
self.client = TestClient(app)
|
|
|
|
def test_x_frame_options_header(self):
|
|
"""Test X-Frame-Options is set to DENY"""
|
|
response = self.client.get("/")
|
|
assert response.headers["X-Frame-Options"] == "DENY"
|
|
|
|
def test_x_content_type_options_header(self):
|
|
"""Test X-Content-Type-Options is set to nosniff"""
|
|
response = self.client.get("/")
|
|
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
|
|
|
def test_x_xss_protection_header(self):
|
|
"""Test X-XSS-Protection header is set"""
|
|
response = self.client.get("/")
|
|
assert response.headers["X-XSS-Protection"] == "1; mode=block"
|
|
|
|
def test_content_security_policy_header(self):
|
|
"""Test Content-Security-Policy header is present"""
|
|
response = self.client.get("/")
|
|
csp = response.headers["Content-Security-Policy"]
|
|
assert "default-src 'self'" in csp
|
|
assert "script-src" in csp
|
|
|
|
def test_referrer_policy_header(self):
|
|
"""Test Referrer-Policy header"""
|
|
response = self.client.get("/")
|
|
assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
|
|
|
|
def test_permissions_policy_header(self):
|
|
"""Test Permissions-Policy header"""
|
|
response = self.client.get("/")
|
|
policy = response.headers["Permissions-Policy"]
|
|
assert "geolocation=()" in policy
|
|
assert "microphone=()" in policy
|
|
assert "camera=()" in policy
|
|
|
|
def test_no_hsts_for_localhost(self):
|
|
"""Test that HSTS header check depends on hostname"""
|
|
# The HSTS header is only skipped when hostname is localhost or 127.0.0.1.
|
|
# TestClient uses 'testserver' as hostname, which is not in the skip list,
|
|
# so HSTS will be set. Verify the logic works with a direct check.
|
|
response = self.client.get("/")
|
|
# TestClient hostname is 'testserver', not localhost, so HSTS IS set
|
|
assert "Strict-Transport-Security" in response.headers
|
|
|
|
|
|
class TestCSRFProtectionMiddleware:
|
|
"""Test CSRF protection middleware"""
|
|
|
|
def setup_method(self):
|
|
app = _make_app([CSRFProtectionMiddleware])
|
|
self.client = TestClient(app)
|
|
|
|
def test_get_requests_pass_through(self):
|
|
"""Test that GET requests are not blocked"""
|
|
response = self.client.get("/")
|
|
assert response.status_code == 200
|
|
|
|
def test_head_requests_pass_through(self):
|
|
"""Test that HEAD requests are not blocked"""
|
|
response = self.client.head("/")
|
|
assert response.status_code == 200
|
|
|
|
def test_options_requests_pass_through(self):
|
|
"""Test that OPTIONS requests are not blocked"""
|
|
response = self.client.options("/")
|
|
assert response.status_code == 200
|
|
|
|
def test_exempt_paths_pass_through(self):
|
|
"""Test that exempt paths are not CSRF-checked for POST"""
|
|
response = self.client.post("/api/v1/auth/login")
|
|
assert response.status_code == 200
|
|
|
|
def test_post_to_non_exempt_path_passes(self):
|
|
"""Test that POST to non-exempt path also passes (JWT provides CSRF protection)"""
|
|
response = self.client.post("/")
|
|
assert response.status_code == 200
|
|
|
|
def test_generate_csrf_token(self):
|
|
"""Test CSRF token generation produces valid token"""
|
|
token = CSRFProtectionMiddleware._generate_csrf_token()
|
|
assert isinstance(token, str)
|
|
assert len(token) == 43 # token_urlsafe(32) produces 43 chars
|
|
|
|
def test_validate_csrf_token_valid(self):
|
|
"""Test CSRF token validation with valid token"""
|
|
token = CSRFProtectionMiddleware._generate_csrf_token()
|
|
assert CSRFProtectionMiddleware._validate_csrf_token(token) is True
|
|
|
|
def test_validate_csrf_token_invalid(self):
|
|
"""Test CSRF token validation with invalid token"""
|
|
assert CSRFProtectionMiddleware._validate_csrf_token("short") is False
|