Files
TieMeasureFlow/src/backend/tests/test_security.py
T
Adriano e91ef739af fix(rate-limit): secchio login esteso a credenziali supervisore e setup (#8)
Il secchio "login" del rate limit valeva solo per /api/auth/login. Un
capoturno può ora essere attaccato a forza bruta anche via
/api/measurements/{id}/authorise e /api/production-runs/{id}/pause|
resume|close, senza mai toccare il bucket più permissivo "general".
_is_login_bucket_request confronta il path con un'espressione regolare,
non con ==, perché l'id in mezzo varia per richiesta.

Include anche la parte di #9 che riguarda questo stesso file: i path
sotto /api/setup/ entrano nello stesso secchio, indipendentemente dal
metodo (GET /api/setup/status accetta ora una password opzionale).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:34:42 +02:00

211 lines
7.6 KiB
Python

"""Security integration tests for FastAPI server.
Covers CORS, rate limiting, security headers, API key auth,
path traversal protection, filename sanitization, and OPTIONS preflight.
"""
import time
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from src.backend.tests.conftest import _create_user, auth_headers
class TestCORSHeaders:
"""CORS header tests."""
async def test_cors_headers_present(self, client: AsyncClient):
"""Response includes CORS headers for allowed origin."""
resp = await client.get(
"/api/health",
headers={"Origin": "http://localhost:5000"},
)
assert resp.status_code == 200
assert "access-control-allow-origin" in resp.headers
class TestSecurityHeaders:
"""Security header tests."""
async def test_security_headers_present(self, client: AsyncClient):
"""Response includes all standard security headers."""
resp = await client.get("/api/health")
assert resp.status_code == 200
assert resp.headers.get("x-content-type-options") == "nosniff"
assert resp.headers.get("x-frame-options") == "DENY"
assert resp.headers.get("x-xss-protection") == "1; mode=block"
assert "strict-origin" in resp.headers.get("referrer-policy", "")
assert "default-src" in resp.headers.get("content-security-policy", "")
class TestAPIKeyAuth:
"""API key authentication tests."""
async def test_protected_endpoint_requires_api_key(self, client: AsyncClient):
"""Protected endpoint returns 401 without API key."""
resp = await client.get("/api/users")
assert resp.status_code == 401
detail = resp.json().get("detail", "")
assert "API key" in detail or "Missing" in detail
async def test_invalid_api_key_returns_401(self, client: AsyncClient):
"""Invalid API key returns 401."""
resp = await client.get(
"/api/users",
headers={"X-API-Key": "totally-invalid-key-12345"},
)
assert resp.status_code == 401
detail = resp.json().get("detail", "")
assert "Invalid" in detail or "inactive" in detail
class TestRateLimiting:
"""Rate limiting tests."""
async def test_login_rate_limit_returns_429(self, client: AsyncClient, db_session):
"""Exceeding login rate limit returns 429."""
# Default rate_limit_login = 5 per minute
# Send 6 requests to exceed the limit
for i in range(6):
resp = await client.post(
"/api/auth/login",
json={"username": f"user{i}", "password": "pass"},
)
# The 6th request (or beyond) should be rate limited
# Send one more to be sure
resp = await client.post(
"/api/auth/login",
json={"username": "overflow", "password": "pass"},
)
assert resp.status_code == 429
assert "Too many" in resp.json().get("detail", "")
async def test_rate_limit_retry_after_header(self, client: AsyncClient):
"""429 response includes Retry-After header."""
# Exhaust login rate limit
for i in range(7):
resp = await client.post(
"/api/auth/login",
json={"username": f"retry{i}", "password": "pass"},
)
# Final request should be 429 with Retry-After
resp = await client.post(
"/api/auth/login",
json={"username": "retrycheck", "password": "pass"},
)
assert resp.status_code == 429
assert "retry-after" in resp.headers
retry_val = int(resp.headers["retry-after"])
assert retry_val >= 1
async def test_login_rate_limit_survives_rotated_x_forwarded_for(
self, client: AsyncClient
):
"""A client rotating the spoofable (first) hop of XFF cannot dodge the
login rate limit: with one trusted proxy (the default), the real
client IP is the last hop, which stays constant."""
for i in range(5):
resp = await client.post(
"/api/auth/login",
json={"username": f"rotated{i}", "password": "pass"},
headers={
"X-Forwarded-For": f"{i}.{i}.{i}.{i}, 198.51.100.42",
},
)
assert resp.status_code == 401
resp = await client.post(
"/api/auth/login",
json={"username": "rotated-overflow", "password": "pass"},
headers={"X-Forwarded-For": "9.9.9.9, 198.51.100.42"},
)
assert resp.status_code == 429
async def test_production_run_pause_shares_login_bucket(self, client: AsyncClient):
"""Repeated wrong-password POSTs to a supervisor-gated action are rate
limited under the login bucket, not the (much higher) general one."""
for i in range(5):
resp = await client.post(
f"/api/production-runs/{i + 1}/pause",
json={
"supervisor_username": "capoturno",
"supervisor_password": "wrong",
"note": None,
},
)
# Unauthenticated (no X-API-Key) or run not found either way -
# what matters here is that it is not yet rate limited.
assert resp.status_code != 429
resp = await client.post(
"/api/production-runs/999/pause",
json={
"supervisor_username": "capoturno",
"supervisor_password": "wrong",
"note": None,
},
)
assert resp.status_code == 429
class TestPathTraversal:
"""Path traversal protection in file endpoints."""
async def test_path_traversal_blocked(self, client: AsyncClient, db_session):
"""Path traversal in file endpoint is blocked (403 or 404)."""
user = await _create_user(
db_session,
username="fileuser",
roles=["Maker", "MeasurementTec"],
)
resp = await client.get(
"/api/files/../../etc/passwd",
headers=auth_headers(user),
)
# Should be 403 (access denied) or 404 (not found), never 200
assert resp.status_code in (403, 404)
class TestFilenameSanitization:
"""Filename sanitization tests."""
async def test_dotfile_rejected(self, client: AsyncClient, db_session):
"""Filenames starting with '.' are rejected."""
from src.backend.api.routers.files import sanitize_filename
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
sanitize_filename(".htaccess")
assert exc_info.value.status_code == 400
assert "." in exc_info.value.detail
async def test_empty_filename_rejected(self, client: AsyncClient, db_session):
"""Empty filenames are rejected."""
from src.backend.api.routers.files import sanitize_filename
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
sanitize_filename("///")
assert exc_info.value.status_code == 400
class TestOptionsPreflight:
"""OPTIONS preflight request tests."""
async def test_options_preflight(self, client: AsyncClient):
"""OPTIONS request returns CORS preflight headers."""
resp = await client.options(
"/api/health",
headers={
"Origin": "http://localhost:5000",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "X-API-Key",
},
)
assert resp.status_code == 200
assert "access-control-allow-methods" in resp.headers