- Introduced `pyproject.toml` to centralize backend tool configurations (e.g., Ruff, mypy, coverage, pytest). - Replaced Black, isort, and Flake8 with Ruff for linting, formatting, and import sorting. - Updated `requirements.txt` to include Ruff and remove replaced tools. - Added `Makefile` to streamline development workflows with commands for linting, formatting, type-checking, testing, and cleanup.
169 lines
6.6 KiB
Python
Executable File
169 lines
6.6 KiB
Python
Executable File
# tests/api/test_security_headers.py
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.main import app
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client():
|
|
"""Create a FastAPI test client for the main app (module-scoped for speed)."""
|
|
# Mock get_db to avoid database connection issues
|
|
with patch("app.core.database.get_db") as mock_get_db:
|
|
|
|
async def mock_session_generator():
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.execute = AsyncMock(return_value=None)
|
|
mock_session.close = AsyncMock(return_value=None)
|
|
yield mock_session
|
|
|
|
mock_get_db.side_effect = lambda: mock_session_generator()
|
|
yield TestClient(app)
|
|
|
|
|
|
class TestSecurityHeaders:
|
|
"""Tests for security headers middleware"""
|
|
|
|
def test_all_security_headers(self, client):
|
|
"""Test all security headers in a single request for speed"""
|
|
response = client.get("/health")
|
|
|
|
# Test X-Frame-Options
|
|
assert "X-Frame-Options" in response.headers
|
|
assert response.headers["X-Frame-Options"] == "DENY"
|
|
|
|
# Test X-Content-Type-Options
|
|
assert "X-Content-Type-Options" in response.headers
|
|
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
|
|
|
# Test X-XSS-Protection
|
|
assert "X-XSS-Protection" in response.headers
|
|
assert response.headers["X-XSS-Protection"] == "1; mode=block"
|
|
|
|
# Test Content-Security-Policy
|
|
assert "Content-Security-Policy" in response.headers
|
|
assert "default-src 'self'" in response.headers["Content-Security-Policy"]
|
|
assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"]
|
|
|
|
# Test Permissions-Policy
|
|
assert "Permissions-Policy" in response.headers
|
|
assert "geolocation=()" in response.headers["Permissions-Policy"]
|
|
assert "microphone=()" in response.headers["Permissions-Policy"]
|
|
assert "camera=()" in response.headers["Permissions-Policy"]
|
|
|
|
# Test Referrer-Policy
|
|
assert "Referrer-Policy" in response.headers
|
|
assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
|
|
|
|
def test_hsts_not_in_development(self, client):
|
|
"""Test that Strict-Transport-Security header is not set in development"""
|
|
from app.core.config import settings
|
|
|
|
# In development, HSTS should not be present
|
|
if settings.ENVIRONMENT == "development":
|
|
response = client.get("/health")
|
|
assert "Strict-Transport-Security" not in response.headers
|
|
|
|
def test_security_headers_on_404(self, client):
|
|
"""Test that security headers are present even on 404 responses"""
|
|
response = client.get("/nonexistent-endpoint")
|
|
assert response.status_code == 404
|
|
assert "X-Frame-Options" in response.headers
|
|
assert "X-Content-Type-Options" in response.headers
|
|
assert "X-XSS-Protection" in response.headers
|
|
|
|
def test_hsts_in_production(self):
|
|
"""Test that HSTS header is set in production (covers line 95)"""
|
|
with patch("app.core.config.settings.ENVIRONMENT", "production"):
|
|
with patch("app.core.database.get_db") as mock_get_db:
|
|
|
|
async def mock_session_generator():
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.execute = AsyncMock(return_value=None)
|
|
mock_session.close = AsyncMock(return_value=None)
|
|
yield mock_session
|
|
|
|
mock_get_db.side_effect = lambda: mock_session_generator()
|
|
|
|
# Need to reimport app to pick up the new settings
|
|
from importlib import reload
|
|
|
|
import app.main
|
|
|
|
reload(app.main)
|
|
test_client = TestClient(app.main.app)
|
|
|
|
response = test_client.get("/health")
|
|
assert "Strict-Transport-Security" in response.headers
|
|
assert (
|
|
"max-age=31536000" in response.headers["Strict-Transport-Security"]
|
|
)
|
|
|
|
def test_csp_strict_mode(self):
|
|
"""Test CSP strict mode (covers line 121)"""
|
|
with patch("app.core.config.settings.CSP_MODE", "strict"):
|
|
with patch("app.core.database.get_db") as mock_get_db:
|
|
|
|
async def mock_session_generator():
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.execute = AsyncMock(return_value=None)
|
|
mock_session.close = AsyncMock(return_value=None)
|
|
yield mock_session
|
|
|
|
mock_get_db.side_effect = lambda: mock_session_generator()
|
|
|
|
from importlib import reload
|
|
|
|
import app.main
|
|
|
|
reload(app.main)
|
|
test_client = TestClient(app.main.app)
|
|
|
|
response = test_client.get("/health")
|
|
csp = response.headers.get("Content-Security-Policy", "")
|
|
# Strict mode should only allow 'self'
|
|
assert "script-src 'self'" in csp
|
|
assert "style-src 'self'" in csp
|
|
assert "cdn.jsdelivr.net" not in csp # No external CDNs in strict mode
|
|
|
|
def test_csp_docs_endpoint(self, client):
|
|
"""Test CSP on /docs endpoint allows Swagger resources (covers line 110)"""
|
|
response = client.get("/docs")
|
|
csp = response.headers.get("Content-Security-Policy", "")
|
|
# Docs endpoint should allow Swagger UI resources
|
|
assert "cdn.jsdelivr.net" in csp
|
|
assert "fastapi.tiangolo.com" in csp
|
|
|
|
|
|
class TestRootEndpoint:
|
|
"""Tests for the root endpoint"""
|
|
|
|
def test_root_endpoint(self):
|
|
"""Test root endpoint returns HTML (covers line 174)"""
|
|
with patch("app.core.database.get_db") as mock_get_db:
|
|
|
|
async def mock_session_generator():
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.execute = AsyncMock(return_value=None)
|
|
mock_session.close = AsyncMock(return_value=None)
|
|
yield mock_session
|
|
|
|
mock_get_db.side_effect = lambda: mock_session_generator()
|
|
test_client = TestClient(app)
|
|
|
|
response = test_client.get("/")
|
|
assert response.status_code == 200
|
|
assert "text/html" in response.headers["content-type"]
|
|
assert "Welcome to app API" in response.text
|
|
assert "/docs" in response.text
|