Add ruff config to exclude alembic E402, SQLAlchemy F821, and pre-existing E501 line-length issues. Auto-fix 69 unused imports and 2 f-strings without placeholders. Manually fix 8 unused variables. Apply ruff format to 127 files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
233 lines
9.1 KiB
Python
233 lines
9.1 KiB
Python
"""Unit tests for security hardening.
|
|
|
|
Tests cover:
|
|
- Production startup validation (insecure defaults rejection)
|
|
- Security headers middleware (per-environment header behavior)
|
|
|
|
These are pure function/middleware tests -- no database or async required
|
|
for startup validation, async only for middleware tests.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.config import KNOWN_INSECURE_DEFAULTS, validate_production_settings
|
|
|
|
|
|
class TestStartupValidation:
|
|
"""Tests for validate_production_settings()."""
|
|
|
|
def _make_settings(self, **kwargs):
|
|
"""Create a mock settings object with given field values."""
|
|
defaults = {
|
|
"ENVIRONMENT": "dev",
|
|
"JWT_SECRET_KEY": "change-this-in-production-use-a-long-random-string",
|
|
"CREDENTIAL_ENCRYPTION_KEY": "LLLjnfBZTSycvL2U07HDSxUeTtLxb9cZzryQl0R9E4w=",
|
|
}
|
|
defaults.update(kwargs)
|
|
return SimpleNamespace(**defaults)
|
|
|
|
def test_production_rejects_insecure_jwt_secret(self):
|
|
"""Production with default JWT secret must exit."""
|
|
settings = self._make_settings(
|
|
ENVIRONMENT="production",
|
|
JWT_SECRET_KEY=KNOWN_INSECURE_DEFAULTS["JWT_SECRET_KEY"][0],
|
|
)
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
validate_production_settings(settings)
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_production_rejects_insecure_encryption_key(self):
|
|
"""Production with default encryption key must exit."""
|
|
settings = self._make_settings(
|
|
ENVIRONMENT="production",
|
|
JWT_SECRET_KEY="a-real-secure-jwt-secret-that-is-long-enough",
|
|
CREDENTIAL_ENCRYPTION_KEY=KNOWN_INSECURE_DEFAULTS["CREDENTIAL_ENCRYPTION_KEY"][0],
|
|
)
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
validate_production_settings(settings)
|
|
assert exc_info.value.code == 1
|
|
|
|
def test_dev_allows_insecure_defaults(self):
|
|
"""Dev environment allows insecure defaults without error."""
|
|
settings = self._make_settings(
|
|
ENVIRONMENT="dev",
|
|
JWT_SECRET_KEY=KNOWN_INSECURE_DEFAULTS["JWT_SECRET_KEY"][0],
|
|
CREDENTIAL_ENCRYPTION_KEY=KNOWN_INSECURE_DEFAULTS["CREDENTIAL_ENCRYPTION_KEY"][0],
|
|
)
|
|
# Should NOT raise
|
|
validate_production_settings(settings)
|
|
|
|
def test_production_allows_secure_values(self):
|
|
"""Production with non-default secrets should pass."""
|
|
settings = self._make_settings(
|
|
ENVIRONMENT="production",
|
|
JWT_SECRET_KEY="a-real-secure-jwt-secret-that-is-long-enough-for-production",
|
|
CREDENTIAL_ENCRYPTION_KEY="dGhpcyBpcyBhIHNlY3VyZSBrZXkgdGhhdCBpcw==",
|
|
)
|
|
# Should NOT raise
|
|
validate_production_settings(settings)
|
|
|
|
|
|
class TestSecurityHeadersMiddleware:
|
|
"""Tests for SecurityHeadersMiddleware."""
|
|
|
|
@pytest.fixture
|
|
def prod_app(self):
|
|
"""Create a minimal FastAPI app with security middleware in production mode."""
|
|
from fastapi import FastAPI
|
|
from app.middleware.security_headers import SecurityHeadersMiddleware
|
|
|
|
app = FastAPI()
|
|
app.add_middleware(SecurityHeadersMiddleware, environment="production")
|
|
|
|
@app.get("/test")
|
|
async def test_endpoint():
|
|
return {"status": "ok"}
|
|
|
|
return app
|
|
|
|
@pytest.fixture
|
|
def dev_app(self):
|
|
"""Create a minimal FastAPI app with security middleware in dev mode."""
|
|
from fastapi import FastAPI
|
|
from app.middleware.security_headers import SecurityHeadersMiddleware
|
|
|
|
app = FastAPI()
|
|
app.add_middleware(SecurityHeadersMiddleware, environment="dev")
|
|
|
|
@app.get("/test")
|
|
async def test_endpoint():
|
|
return {"status": "ok"}
|
|
|
|
return app
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_production_includes_hsts(self, prod_app):
|
|
"""Production responses must include HSTS header."""
|
|
import httpx
|
|
|
|
transport = httpx.ASGITransport(app=prod_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/test")
|
|
|
|
assert response.status_code == 200
|
|
assert (
|
|
response.headers["strict-transport-security"] == "max-age=31536000; includeSubDomains"
|
|
)
|
|
assert response.headers["x-content-type-options"] == "nosniff"
|
|
assert response.headers["x-frame-options"] == "DENY"
|
|
assert response.headers["cache-control"] == "no-store"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dev_excludes_hsts(self, dev_app):
|
|
"""Dev responses must NOT include HSTS (breaks plain HTTP)."""
|
|
import httpx
|
|
|
|
transport = httpx.ASGITransport(app=dev_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/test")
|
|
|
|
assert response.status_code == 200
|
|
assert "strict-transport-security" not in response.headers
|
|
assert response.headers["x-content-type-options"] == "nosniff"
|
|
assert response.headers["x-frame-options"] == "DENY"
|
|
assert response.headers["cache-control"] == "no-store"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_csp_header_present_production(self, prod_app):
|
|
"""Production responses must include CSP header."""
|
|
import httpx
|
|
|
|
transport = httpx.ASGITransport(app=prod_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/test")
|
|
|
|
assert "content-security-policy" in response.headers
|
|
csp = response.headers["content-security-policy"]
|
|
assert "default-src 'self'" in csp
|
|
assert "script-src" in csp
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_csp_header_present_dev(self, dev_app):
|
|
"""Dev responses must include CSP header."""
|
|
import httpx
|
|
|
|
transport = httpx.ASGITransport(app=dev_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/test")
|
|
|
|
assert "content-security-policy" in response.headers
|
|
csp = response.headers["content-security-policy"]
|
|
assert "default-src 'self'" in csp
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_csp_production_blocks_inline_scripts(self, prod_app):
|
|
"""Production CSP must block inline scripts (no unsafe-inline in script-src)."""
|
|
import httpx
|
|
|
|
transport = httpx.ASGITransport(app=prod_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/test")
|
|
|
|
csp = response.headers["content-security-policy"]
|
|
# Extract the script-src directive value
|
|
script_src = [d for d in csp.split(";") if "script-src" in d][0]
|
|
assert "'unsafe-inline'" not in script_src
|
|
assert "'unsafe-eval'" not in script_src
|
|
assert "'self'" in script_src
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_csp_dev_allows_unsafe_inline(self, dev_app):
|
|
"""Dev CSP must allow unsafe-inline and unsafe-eval for Vite HMR."""
|
|
import httpx
|
|
|
|
transport = httpx.ASGITransport(app=dev_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/test")
|
|
|
|
csp = response.headers["content-security-policy"]
|
|
script_src = [d for d in csp.split(";") if "script-src" in d][0]
|
|
assert "'unsafe-inline'" in script_src
|
|
assert "'unsafe-eval'" in script_src
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_csp_production_allows_inline_styles(self, prod_app):
|
|
"""Production CSP must allow unsafe-inline for styles (Tailwind, Framer Motion, Radix)."""
|
|
import httpx
|
|
|
|
transport = httpx.ASGITransport(app=prod_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/test")
|
|
|
|
csp = response.headers["content-security-policy"]
|
|
style_src = [d for d in csp.split(";") if "style-src" in d][0]
|
|
assert "'unsafe-inline'" in style_src
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_csp_allows_websocket_connections(self, prod_app):
|
|
"""CSP must allow wss: and ws: for SSE/WebSocket connections."""
|
|
import httpx
|
|
|
|
transport = httpx.ASGITransport(app=prod_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/test")
|
|
|
|
csp = response.headers["content-security-policy"]
|
|
connect_src = [d for d in csp.split(";") if "connect-src" in d][0]
|
|
assert "wss:" in connect_src
|
|
assert "ws:" in connect_src
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_csp_frame_ancestors_none(self, prod_app):
|
|
"""CSP must include frame-ancestors 'none' (anti-clickjacking)."""
|
|
import httpx
|
|
|
|
transport = httpx.ASGITransport(app=prod_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/test")
|
|
|
|
csp = response.headers["content-security-policy"]
|
|
assert "frame-ancestors 'none'" in csp
|