Corp/backend/tests/test_logging.py

619 lines
20 KiB
Python

from __future__ import annotations
import json
import logging
import ast
import re
from pathlib import Path
from types import SimpleNamespace
import pytest
from django.conf import settings
from django.http import HttpResponse
from django.test import RequestFactory
from django.test.utils import override_settings
from api.auth.authentication import SessionAwareJWTAuthentication
from eusrr_backend.auth_backends import EmailOrPhoneBackend
from eusrr_backend.celery import (
inject_task_log_context,
log_task_completed,
log_task_started,
)
from eusrr_backend.channels_jwt import JWTAuthMiddleware
from eusrr_backend.logging_utils import (
ConsoleFormatter,
ContextFilter,
JsonFormatter,
WebSocketLoggingMiddleware,
bind_log_context,
get_log_context,
reset_log_context,
)
from eusrr_backend.middleware import RequestLoggingMiddleware
BACKEND_ROOT = Path(__file__).resolve().parents[1]
def _record(message="operation completed", **extra):
record = logging.LogRecord(
"test.logging",
logging.INFO,
__file__,
10,
message,
(),
None,
)
for key, value in extra.items():
setattr(record, key, value)
return record
def test_json_formatter_emits_context_and_structured_fields():
token = bind_log_context(request_id="req-123", user_id=42)
try:
output = JsonFormatter().format(
_record(
event="document.created",
document_id=7,
duration_ms=12.5,
)
)
finally:
reset_log_context(token)
payload = json.loads(output)
assert payload["event"] == "document.created"
assert payload["request_id"] == "req-123"
assert payload["user_id"] == 42
assert payload["document_id"] == 7
assert payload["duration_ms"] == 12.5
assert payload["source"].endswith(":10")
def test_logging_settings_use_one_context_aware_console_pipeline():
config = settings.LOGGING
assert config["root"]["handlers"] == ["console"]
assert config["handlers"]["console"]["filters"] == ["context"]
assert config["handlers"]["console"]["formatter"] in {"console", "json"}
assert "eusrr_backend.auth_backends" not in config["loggers"]
assert settings.CELERY_WORKER_HIJACK_ROOT_LOGGER is False
assert isinstance(settings.LOG_TRUST_X_FORWARDED_FOR, bool)
assert config["loggers"]["daphne"]["level"] == "WARNING"
assert config["loggers"]["daphne"]["propagate"] is False
assert config["loggers"]["django.channels.server"]["level"] == "WARNING"
assert config["loggers"]["django.channels.server"]["propagate"] is False
assert config["loggers"]["httpcore"]["level"] == "WARNING"
assert config["loggers"]["httpcore"]["propagate"] is False
assert config["loggers"]["httpx"]["level"] == "WARNING"
assert config["loggers"]["httpx"]["propagate"] is False
def test_context_filter_assigns_stable_event_to_framework_logs():
record = _record("transport warning")
record.name = "daphne.ws_protocol"
record.levelno = logging.WARNING
record.levelname = "WARNING"
assert ContextFilter().filter(record) is True
assert record.event == "framework.daphne_ws_protocol.warning"
def test_formatter_removes_terminal_ansi_sequences():
record = _record("\x1b[mHTTP GET /health/ 200\x1b[0m", event="server.access")
output = ConsoleFormatter().format(record)
assert "\\u001b" not in output
assert "\x1b" not in output
assert "HTTP GET /health/ 200" in output
def test_console_formatter_groups_http_context_and_event_data():
output = ConsoleFormatter().format(
_record(
"HTTP request completed",
event="http.request.completed",
http_method="GET",
path="/api/v1/tasks/boards/1/",
status_code=200,
duration_ms=27.5,
request_id="req-123",
user_id=42,
client_ip="127.0.0.1",
cache_hit=True,
)
)
assert "INFO [HTTP] http.request.completed @test.logging" in output
assert '[GET "/api/v1/tasks/boards/1/" → 200 27.5ms]' in output
assert '[req="req-123" user=42 ip="127.0.0.1"]' in output
assert "[cache_hit=true]" in output
assert '"HTTP request completed"' in output
assert "level=" not in output
assert "logger=" not in output
assert "event=" not in output
assert "status_code=" not in output
assert "duration_ms=" not in output
assert "message=" not in output
assert "\x1b" not in output
assert output.index("[GET ") < output.index("[req=")
assert output.index("[req=") < output.index("[cache_hit=")
def test_console_formatter_does_not_repeat_websocket_correlation_id():
connection_id = "connection-123"
output = ConsoleFormatter().format(
_record(
"WebSocket connection accepted",
event="websocket.connection.accepted",
connection_id=connection_id,
request_id=connection_id,
path="/ws/",
user_id=42,
)
)
assert output.count(connection_id) == 1
assert '[conn="connection-123" path="/ws/"]' in output
assert "[user=42]" in output
@pytest.mark.parametrize("formatter", [ConsoleFormatter(), JsonFormatter()])
def test_formatters_redact_secrets_and_escape_newlines(formatter):
output = formatter.format(
_record(
"token=top-secret Authorization: Bearer jwt-value\nsecond line",
event="security.test",
password="plain-password",
cookie="session=abc",
)
)
assert "top-secret" not in output
assert "jwt-value" not in output
assert "plain-password" not in output
assert "session=abc" not in output
assert "\nsecond line" not in output
assert "\\nsecond line" in output
@pytest.mark.parametrize("formatter", [ConsoleFormatter(), JsonFormatter()])
def test_formatters_redact_personal_directory_data_and_bound_values(formatter):
output = formatter.format(
_record(
"employee=CN=Private Person,OU=Users,DC=example,DC=test "
"phone=+7 (999) 123-45-67 " + "x" * 3000,
event="security.directory_data",
)
)
assert "Private Person" not in output
assert "999" not in output
assert "[REDACTED_DN]" in output
assert "[REDACTED_PHONE]" in output
assert "[TRUNCATED]" in output
def test_formatter_serializes_exception_without_losing_event():
try:
raise ValueError("broken")
except ValueError:
record = _record(event="operation.failed")
record.exc_info = __import__("sys").exc_info()
payload = json.loads(JsonFormatter().format(record))
assert payload["event"] == "operation.failed"
assert "ValueError" in payload["exception"]
def test_formatter_sanitizes_django_request_extra():
request = RequestFactory().get(
"/api/v1/documents/?token=query-secret&search=private"
)
record = _record(event="django.request.failed")
record.request = request
payload = json.loads(JsonFormatter().format(record))
assert payload["request"] == {
"method": "GET",
"path": "/api/v1/documents/",
}
assert "query-secret" not in json.dumps(payload)
assert "private" not in json.dumps(payload)
def test_http_middleware_accepts_safe_request_id_and_cleans_context(monkeypatch):
contexts = []
log_calls = []
def get_response(request):
contexts.append(get_log_context())
return HttpResponse("ok", status=200)
monkeypatch.setattr(
"eusrr_backend.middleware.logger.info",
lambda message, **kwargs: log_calls.append((message, kwargs)),
)
request = RequestFactory().get(
"/api/v1/documents/?token=must-not-leak",
HTTP_X_REQUEST_ID="trace-123",
REMOTE_ADDR="127.0.0.7",
)
request.user = SimpleNamespace(is_authenticated=True, pk=42)
response = RequestLoggingMiddleware(get_response)(request)
assert response["X-Request-ID"] == "trace-123"
assert contexts == [
{
"request_id": "trace-123",
"user_id": 42,
"http_method": "GET",
"path": "/api/v1/documents/",
"client_ip": "127.0.0.7",
}
]
assert get_log_context() == {}
assert log_calls[0][1]["extra"]["event"] == "http.request.completed"
assert log_calls[0][1]["extra"]["status_code"] == 200
def test_http_middleware_replaces_unsafe_request_id():
request = RequestFactory().get(
"/health/", HTTP_X_REQUEST_ID="bad id\nAuthorization: secret"
)
request.user = SimpleNamespace(is_authenticated=False)
response = RequestLoggingMiddleware(lambda request: HttpResponse("ok"))(request)
assert response["X-Request-ID"] != request.headers["X-Request-ID"]
assert len(response["X-Request-ID"]) == 32
assert get_log_context() == {}
@override_settings(LOG_TRUST_X_FORWARDED_FOR=True)
def test_http_middleware_uses_valid_trusted_proxy_ip():
contexts = []
request = RequestFactory().get(
"/health/",
HTTP_X_FORWARDED_FOR="203.0.113.10, 10.0.0.5",
REMOTE_ADDR="10.0.0.5",
)
request.user = SimpleNamespace(is_authenticated=False)
RequestLoggingMiddleware(
lambda request: contexts.append(get_log_context()) or HttpResponse("ok")
)(request)
assert contexts[0]["client_ip"] == "203.0.113.10"
assert get_log_context() == {}
@override_settings(LOG_TRUST_X_FORWARDED_FOR=True)
def test_http_middleware_rejects_invalid_forwarded_ip():
contexts = []
request = RequestFactory().get(
"/health/",
HTTP_X_FORWARDED_FOR="attacker-controlled-value",
REMOTE_ADDR="192.0.2.20",
)
request.user = SimpleNamespace(is_authenticated=False)
RequestLoggingMiddleware(
lambda request: contexts.append(get_log_context()) or HttpResponse("ok")
)(request)
assert contexts[0]["client_ip"] == "192.0.2.20"
def test_http_jwt_authentication_binds_user_before_view_logging(monkeypatch):
user = SimpleNamespace(pk=73)
validated_token = object()
monkeypatch.setattr(
"rest_framework_simplejwt.authentication.JWTAuthentication.authenticate",
lambda authentication, request: (user, validated_token),
)
monkeypatch.setattr(
SessionAwareJWTAuthentication,
"validate_session",
lambda authentication, token, user, request=None: None,
)
token = bind_log_context(client_ip="203.0.113.11")
try:
result = SessionAwareJWTAuthentication().authenticate(SimpleNamespace())
assert result == (user, validated_token)
assert get_log_context() == {
"client_ip": "203.0.113.11",
"user_id": 73,
}
finally:
reset_log_context(token)
@pytest.mark.asyncio
async def test_websocket_middleware_logs_lifecycle_without_query_string(caplog):
sent = []
async def app(scope, receive, send):
assert get_log_context()["path"] == "/ws/notifications/"
assert "query_string" not in get_log_context()
await send({"type": "websocket.accept"})
await receive()
messages = iter([{"type": "websocket.disconnect", "code": 1000}])
async def receive():
return next(messages)
async def send(message):
sent.append(message)
scope = {
"type": "websocket",
"path": "/ws/notifications/",
"query_string": b"token=must-not-leak",
"headers": [(b"x-request-id", b"ws-123")],
"client": ("127.0.0.8", 10000),
}
with caplog.at_level(logging.INFO, logger="eusrr.websocket"):
await WebSocketLoggingMiddleware(app)(scope, receive, send)
assert scope["request_id"] == "ws-123"
assert [record.event for record in caplog.records] == [
"websocket.connection.accepted",
"websocket.connection.closed",
]
assert caplog.records[-1].close_code == 1000
assert get_log_context() == {}
@pytest.mark.asyncio
@override_settings(LOG_TRUST_X_FORWARDED_FOR=True)
async def test_websocket_middleware_uses_valid_trusted_proxy_ip():
async def app(scope, receive, send):
assert get_log_context()["client_ip"] == "2001:db8::7"
scope = {
"type": "websocket",
"path": "/ws/notifications/",
"headers": [(b"x-forwarded-for", b"2001:db8::7, 10.0.0.5")],
"client": ("10.0.0.5", 10000),
}
await WebSocketLoggingMiddleware(app)(scope, None, None)
assert get_log_context() == {}
@pytest.mark.asyncio
async def test_websocket_session_authentication_binds_user_context():
async def app(scope, receive, send):
assert get_log_context()["user_id"] == 91
token = bind_log_context(client_ip="203.0.113.12")
try:
await JWTAuthMiddleware(app)(
{
"type": "websocket",
"headers": [],
"query_string": b"",
"user": SimpleNamespace(is_authenticated=True, pk=91),
},
None,
None,
)
finally:
reset_log_context(token)
def test_celery_lifecycle_has_task_context_and_duration(caplog):
sender = SimpleNamespace(name="documents.tasks.index")
with caplog.at_level(logging.INFO, logger="eusrr.celery"):
log_task_started(sender=sender, task_id="task-123")
log_task_completed(
sender=sender,
task_id="task-123",
state="SUCCESS",
)
assert [record.event for record in caplog.records] == [
"celery.task.started",
"celery.task.completed",
]
assert caplog.records[0].task_id == "task-123"
assert caplog.records[-1].task_name == "documents.tasks.index"
assert caplog.records[-1].duration_ms >= 0
assert get_log_context() == {}
def test_celery_propagates_safe_request_context_through_headers():
headers = {}
token = bind_log_context(
request_id="request-123",
user_id=42,
client_ip="203.0.113.15",
path="/must-not-propagate/",
)
try:
inject_task_log_context(headers=headers)
finally:
reset_log_context(token)
assert headers == {
"eusrr_log_context": {
"request_id": "request-123",
"user_id": 42,
"client_ip": "203.0.113.15",
}
}
sender = SimpleNamespace(
name="notifications.tasks.send",
request=SimpleNamespace(headers=headers),
)
log_task_started(sender=sender, task_id="task-context-1")
assert get_log_context() == {
"request_id": "request-123",
"user_id": 42,
"client_ip": "203.0.113.15",
"task_id": "task-context-1",
"task_name": "notifications.tasks.send",
}
log_task_completed(sender=sender, task_id="task-context-1", state="SUCCESS")
assert get_log_context() == {}
def test_celery_derives_user_id_from_named_positional_task_argument():
def run(user_id, frequency="daily"):
return None
sender = SimpleNamespace(
name="notifications.send_digest",
request=SimpleNamespace(headers={}),
run=run,
)
log_task_started(
sender=sender,
task_id="task-context-2",
args=(84,),
kwargs={"frequency": "weekly"},
)
assert get_log_context()["user_id"] == 84
log_task_completed(sender=sender, task_id="task-context-2", state="SUCCESS")
assert get_log_context() == {}
@pytest.mark.django_db
def test_failed_authentication_does_not_log_identifier_password_or_hash(
caplog, user_factory
):
user = user_factory(email="private-person@example.test")
password_hash = user.password
with caplog.at_level(logging.WARNING, logger="eusrr_backend.auth_backends"):
result = EmailOrPhoneBackend().authenticate(
None,
username=user.email,
password="plain-secret-password",
)
assert result is None
output = JsonFormatter().format(caplog.records[-1])
assert caplog.records[-1].event == "authentication.local.denied"
assert caplog.records[-1].reason == "invalid_password"
assert "private-person@example.test" not in output
assert "plain-secret-password" not in output
assert password_hash not in output
def test_production_python_does_not_use_print_for_runtime_logging():
excluded_parts = {".venv", "docs", "migrations", "scripts", "tests"}
violations = []
for path in BACKEND_ROOT.rglob("*.py"):
if any(part in excluded_parts for part in path.parts):
continue
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "print"
):
violations.append(f"{path.relative_to(BACKEND_ROOT)}:{node.lineno}")
assert violations == []
def test_structured_log_extras_do_not_overwrite_log_record_fields():
excluded_parts = {".venv", "docs", "migrations", "scripts", "tests"}
reserved = set(logging.makeLogRecord({}).__dict__) | {"message", "asctime"}
violations = []
for path in BACKEND_ROOT.rglob("*.py"):
if any(part in excluded_parts for part in path.parts):
continue
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if not (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr
in {"debug", "info", "warning", "error", "exception", "critical", "log"}
):
continue
for keyword in node.keywords:
if keyword.arg != "extra" or not isinstance(keyword.value, ast.Dict):
continue
for key in keyword.value.keys:
if isinstance(key, ast.Constant) and key.value in reserved:
violations.append(
f"{path.relative_to(BACKEND_ROOT)}:{node.lineno}:{key.value}"
)
assert violations == []
def test_production_logger_calls_have_explicit_well_formed_events():
excluded_parts = {".venv", "docs", "migrations", "scripts", "tests"}
levels = {"debug", "info", "warning", "error", "exception", "critical", "log"}
event_pattern = re.compile(r"[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+")
violations = []
logger_call_count = 0
for path in BACKEND_ROOT.rglob("*.py"):
if any(part in excluded_parts for part in path.parts):
continue
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if not (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr in levels
):
continue
receiver = node.func.value
receiver_name = (
receiver.id
if isinstance(receiver, ast.Name)
else receiver.attr
if isinstance(receiver, ast.Attribute)
else ""
)
if receiver_name != "logger" and not receiver_name.endswith("_logger"):
continue
logger_call_count += 1
event_value = None
for keyword in node.keywords:
if keyword.arg != "extra" or not isinstance(keyword.value, ast.Dict):
continue
for key, value in zip(keyword.value.keys, keyword.value.values):
if isinstance(key, ast.Constant) and key.value == "event":
event_value = value
break
if event_value is None:
violations.append(
f"{path.relative_to(BACKEND_ROOT)}:{node.lineno}:missing"
)
elif isinstance(event_value, ast.Constant):
if not isinstance(
event_value.value, str
) or not event_pattern.fullmatch(event_value.value):
violations.append(
f"{path.relative_to(BACKEND_ROOT)}:{node.lineno}:invalid"
)
elif not (isinstance(event_value, ast.Name) and event_value.id == "event"):
violations.append(
f"{path.relative_to(BACKEND_ROOT)}:{node.lineno}:dynamic"
)
assert logger_call_count >= 400
assert violations == []