123 lines
5.0 KiB
Python
123 lines
5.0 KiB
Python
|
|
"""Tests for observability & trace_id correlation (B.14).
|
||
|
|
|
||
|
|
Covers:
|
||
|
|
- trace_id is generated per request and present in response header
|
||
|
|
- trace_id is set in structlog contextvars
|
||
|
|
- Sensitive fields are not logged (sanitize_dict)
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import structlog
|
||
|
|
|
||
|
|
from app.core.monitoring import sanitize_dict, _sanitize_sensitive_fields, _SENSITIVE_FIELDS
|
||
|
|
|
||
|
|
|
||
|
|
class TestTraceIdPropagation:
|
||
|
|
"""Tests for trace_id generation and propagation."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_trace_id_in_response_header(self, client):
|
||
|
|
"""Every response should have X-Trace-Id header."""
|
||
|
|
response = await client.get("/api/v1/health")
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert "x-trace-id" in response.headers
|
||
|
|
trace_id = response.headers["x-trace-id"]
|
||
|
|
assert len(trace_id) == 8
|
||
|
|
# Should be hex characters
|
||
|
|
int(trace_id, 16) # Raises ValueError if not hex
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_trace_id_unique_per_request(self, client):
|
||
|
|
"""Each request should get a unique trace_id."""
|
||
|
|
r1 = await client.get("/api/v1/health")
|
||
|
|
r2 = await client.get("/api/v1/health")
|
||
|
|
t1 = r1.headers.get("x-trace-id")
|
||
|
|
t2 = r2.headers.get("x-trace-id")
|
||
|
|
assert t1 is not None
|
||
|
|
assert t2 is not None
|
||
|
|
assert t1 != t2
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_trace_id_in_structlog_contextvars(self, client):
|
||
|
|
"""trace_id should be set in structlog contextvars during request processing."""
|
||
|
|
# The middleware sets trace_id in contextvars during request handling.
|
||
|
|
# We verify this by checking that the response has the header (which
|
||
|
|
# is set from the same trace_id variable that was bound to contextvars).
|
||
|
|
response = await client.get("/api/v1/health")
|
||
|
|
assert "x-trace-id" in response.headers
|
||
|
|
# After request completes, contextvars should be cleared
|
||
|
|
ctx = structlog.contextvars.get_contextvars()
|
||
|
|
assert "trace_id" not in ctx or ctx.get("trace_id") is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_trace_id_consistent_header_and_body(self, client):
|
||
|
|
"""trace_id in error response body should match X-Trace-Id header."""
|
||
|
|
response = await client.get("/api/v1/contacts/00000000-0000-0000-0000-000000000000")
|
||
|
|
if response.status_code >= 400:
|
||
|
|
body = response.json()
|
||
|
|
header_trace = response.headers.get("x-trace-id")
|
||
|
|
body_trace = body.get("trace_id")
|
||
|
|
# Both should be present and match
|
||
|
|
if header_trace and body_trace:
|
||
|
|
assert header_trace == body_trace
|
||
|
|
|
||
|
|
|
||
|
|
class TestSensitiveFieldSanitization:
|
||
|
|
"""Tests for sensitive field redaction in logs."""
|
||
|
|
|
||
|
|
def test_sanitize_dict_redacts_password(self):
|
||
|
|
data = {"username": "admin", "password": "secret123"}
|
||
|
|
result = sanitize_dict(data)
|
||
|
|
assert result["username"] == "admin"
|
||
|
|
assert result["password"] == "***REDACTED***"
|
||
|
|
|
||
|
|
def test_sanitize_dict_redacts_api_key(self):
|
||
|
|
data = {"api_key": "sk-abc123", "model": "gpt-4"}
|
||
|
|
result = sanitize_dict(data)
|
||
|
|
assert result["api_key"] == "***REDACTED***"
|
||
|
|
assert result["model"] == "gpt-4"
|
||
|
|
|
||
|
|
def test_sanitize_dict_redacts_token(self):
|
||
|
|
data = {"token": "bearer xyz", "user_id": "123"}
|
||
|
|
result = sanitize_dict(data)
|
||
|
|
assert result["token"] == "***REDACTED***"
|
||
|
|
assert result["user_id"] == "123"
|
||
|
|
|
||
|
|
def test_sanitize_dict_redacts_nested(self):
|
||
|
|
data = {"config": {"password": "secret", "host": "localhost"}}
|
||
|
|
result = sanitize_dict(data)
|
||
|
|
assert result["config"]["password"] == "***REDACTED***"
|
||
|
|
assert result["config"]["host"] == "localhost"
|
||
|
|
|
||
|
|
def test_sanitize_dict_extra_sensitive(self):
|
||
|
|
data = {"custom_secret": "value", "name": "test"}
|
||
|
|
result = sanitize_dict(data, extra_sensitive={"custom_secret"})
|
||
|
|
assert result["custom_secret"] == "***REDACTED***"
|
||
|
|
assert result["name"] == "test"
|
||
|
|
|
||
|
|
def test_sanitize_dict_case_insensitive(self):
|
||
|
|
data = {"Password": "secret", "API_KEY": "key123"}
|
||
|
|
result = sanitize_dict(data)
|
||
|
|
assert result["Password"] == "***REDACTED***"
|
||
|
|
assert result["API_KEY"] == "***REDACTED***"
|
||
|
|
|
||
|
|
def test_sanitize_processor_redacts(self):
|
||
|
|
"""The structlog processor should redact sensitive fields."""
|
||
|
|
event_dict = {"password": "secret", "event": "login", "user": "admin"}
|
||
|
|
result = _sanitize_sensitive_fields(None, "info", event_dict)
|
||
|
|
assert result["password"] == "***REDACTED***"
|
||
|
|
assert result["event"] == "login"
|
||
|
|
assert result["user"] == "admin"
|
||
|
|
|
||
|
|
def test_sensitive_fields_include_common_names(self):
|
||
|
|
"""Common sensitive field names should be in the redaction set."""
|
||
|
|
expected = {"password", "api_key", "token", "secret", "authorization"}
|
||
|
|
assert expected.issubset(_SENSITIVE_FIELDS)
|
||
|
|
|
||
|
|
def test_sanitize_dict_preserves_non_sensitive(self):
|
||
|
|
data = {"method": "GET", "path": "/api/v1/health", "status": 200}
|
||
|
|
result = sanitize_dict(data)
|
||
|
|
assert result == data
|