feat: add Django project scaffold, configuration and container
Environment-variable configuration that fails fast, database-only health check, JSON request logs with a request id, security headers with a strict CSP, and a multi-stage non-root Docker image with compose file. Ticket 1.1. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
24de5878ed
commit
f8b0584687
@@ -0,0 +1,118 @@
|
||||
"""Typed environment configuration that reports every problem at once (AD-13)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
||||
MIN_SECRET_KEY_LENGTH = 32
|
||||
_TRUE = {"1", "true", "yes", "on"}
|
||||
_FALSE = {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised with one message that lists every invalid or missing variable.
|
||||
|
||||
Messages name variables and the rule they break, never their values.
|
||||
"""
|
||||
|
||||
def __init__(self, problems: list[str]) -> None:
|
||||
self.problems = problems
|
||||
super().__init__(self.render(problems))
|
||||
|
||||
@staticmethod
|
||||
def render(problems: list[str]) -> str:
|
||||
lines = ["Invalid configuration. Fix these environment variables:"]
|
||||
lines.extend(f" - {p}" for p in problems)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
secret_key: str
|
||||
allowed_hosts: list[str]
|
||||
data_dir: Path
|
||||
debug: bool
|
||||
trusted_proxies: list[str]
|
||||
log_level: str
|
||||
default_timezone: str
|
||||
|
||||
|
||||
def load_config(environ: Mapping[str, str]) -> Config:
|
||||
problems: list[str] = []
|
||||
|
||||
def raw(name: str) -> str | None:
|
||||
value = environ.get(name)
|
||||
if value is None or value.strip() == "":
|
||||
return None
|
||||
return value.strip()
|
||||
|
||||
secret_key = raw("SECRET_KEY")
|
||||
if secret_key is None:
|
||||
problems.append("SECRET_KEY: required")
|
||||
elif len(secret_key) < MIN_SECRET_KEY_LENGTH:
|
||||
problems.append(f"SECRET_KEY: must be at least {MIN_SECRET_KEY_LENGTH} characters")
|
||||
|
||||
hosts_raw = raw("ALLOWED_HOSTS")
|
||||
allowed_hosts: list[str] = []
|
||||
if hosts_raw is None:
|
||||
problems.append("ALLOWED_HOSTS: required, comma-separated host names")
|
||||
else:
|
||||
allowed_hosts = [h.strip() for h in hosts_raw.split(",") if h.strip()]
|
||||
if not allowed_hosts:
|
||||
problems.append("ALLOWED_HOSTS: must contain at least one host name")
|
||||
|
||||
data_dir = Path(raw("DATA_DIR") or "/data")
|
||||
if not data_dir.is_dir():
|
||||
problems.append("DATA_DIR: must be an existing directory")
|
||||
else:
|
||||
data_dir = data_dir.resolve()
|
||||
|
||||
debug = False
|
||||
debug_raw = raw("DEBUG")
|
||||
if debug_raw is not None:
|
||||
if debug_raw.lower() in _TRUE:
|
||||
debug = True
|
||||
elif debug_raw.lower() not in _FALSE:
|
||||
problems.append("DEBUG: must be true or false")
|
||||
|
||||
proxies: list[str] = []
|
||||
proxies_raw = raw("TRUSTED_PROXIES")
|
||||
if proxies_raw is not None:
|
||||
for item in proxies_raw.split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
ipaddress.ip_network(item, strict=False)
|
||||
except ValueError:
|
||||
problems.append("TRUSTED_PROXIES: must be comma-separated IP addresses or networks")
|
||||
break
|
||||
proxies.append(item)
|
||||
|
||||
log_level = (raw("LOG_LEVEL") or "INFO").upper()
|
||||
if log_level not in LOG_LEVELS:
|
||||
problems.append(f"LOG_LEVEL: must be one of {', '.join(LOG_LEVELS)}")
|
||||
|
||||
timezone = raw("DEFAULT_TIMEZONE") or "Europe/Berlin"
|
||||
try:
|
||||
ZoneInfo(timezone)
|
||||
except (ZoneInfoNotFoundError, ValueError, OSError):
|
||||
problems.append("DEFAULT_TIMEZONE: must be a valid IANA time zone name")
|
||||
|
||||
if problems:
|
||||
raise ConfigError(problems)
|
||||
|
||||
return Config(
|
||||
secret_key=secret_key or "",
|
||||
allowed_hosts=allowed_hosts,
|
||||
data_dir=data_dir,
|
||||
debug=debug,
|
||||
trusted_proxies=proxies,
|
||||
log_level=log_level,
|
||||
default_timezone=timezone,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Liveness check. Database only; never AD, SMTP or the share (AD-18)."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import connection
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@require_GET
|
||||
@never_cache
|
||||
def healthz(request):
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
except Exception as exc: # noqa: BLE001 - any failure means unavailable
|
||||
logger.error("health check failed", extra={"error_type": type(exc).__name__})
|
||||
return JsonResponse({"status": "unavailable"}, status=503)
|
||||
return JsonResponse({"status": "ok"})
|
||||
@@ -0,0 +1,49 @@
|
||||
"""JSON logs on stdout with a request id (AD-18)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
from datetime import UTC, datetime
|
||||
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
|
||||
# Attributes set by logging itself; everything else passed via `extra` is emitted.
|
||||
_STANDARD = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {"message", "asctime"}
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, object] = {
|
||||
"ts": datetime.fromtimestamp(record.created, UTC).isoformat(timespec="milliseconds"),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"request_id": getattr(record, "request_id", None) or request_id_var.get(),
|
||||
}
|
||||
for key, value in record.__dict__.items():
|
||||
if key in _STANDARD or key in ("request_id", "request"):
|
||||
continue
|
||||
if value is None or isinstance(value, str | int | float | bool):
|
||||
payload[key] = value
|
||||
if record.exc_info:
|
||||
payload["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, default=str)
|
||||
|
||||
|
||||
def build_logging(level: str) -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {"json": {"()": "portal.config.logging.JsonFormatter"}},
|
||||
"handlers": {
|
||||
"stdout": {
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": "ext://sys.stdout",
|
||||
"formatter": "json",
|
||||
}
|
||||
},
|
||||
"loggers": {"django.request": {"level": "ERROR"}},
|
||||
"root": {"handlers": ["stdout"], "level": level},
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Request id, request logging and security headers (AD-16, AD-18)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from .logging import request_id_var
|
||||
|
||||
logger = logging.getLogger("portal.request")
|
||||
|
||||
_VALID_REQUEST_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
||||
|
||||
CSP = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
)
|
||||
PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=()"
|
||||
|
||||
|
||||
class RequestIdMiddleware:
|
||||
"""Assign a request id, set the response header and write one log line."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
incoming = request.META.get("HTTP_X_REQUEST_ID", "")
|
||||
request_id = incoming if _VALID_REQUEST_ID.match(incoming) else uuid.uuid4().hex
|
||||
request.request_id = request_id
|
||||
token = request_id_var.set(request_id)
|
||||
started = time.monotonic()
|
||||
status = 500
|
||||
try:
|
||||
response = self.get_response(request)
|
||||
status = response.status_code
|
||||
response["X-Request-ID"] = request_id
|
||||
return response
|
||||
finally:
|
||||
# Path only: query strings and bodies are never logged.
|
||||
logger.info(
|
||||
"request",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.path,
|
||||
"status": status,
|
||||
"duration_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
},
|
||||
)
|
||||
request_id_var.reset(token)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Add the Content-Security-Policy and related headers to every response."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
response.headers.setdefault("Content-Security-Policy", CSP)
|
||||
response.headers.setdefault("Permissions-Policy", PERMISSIONS_POLICY)
|
||||
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||
return response
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Django settings. All deployment values come from environment variables (AD-13)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .env import ConfigError, load_config
|
||||
from .logging import build_logging
|
||||
|
||||
try:
|
||||
_config = load_config(os.environ)
|
||||
except ConfigError as exc:
|
||||
# One clear message, no traceback, no values.
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
SECRET_KEY = _config.secret_key
|
||||
DEBUG = _config.debug
|
||||
ALLOWED_HOSTS = _config.allowed_hosts
|
||||
# Reserved for the client-IP rule in a later entry; currently has no effect.
|
||||
TRUSTED_PROXIES = _config.trusted_proxies
|
||||
DATA_DIR = _config.data_dir
|
||||
DISPLAY_TIME_ZONE = _config.default_timezone
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.staticfiles",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"portal.config.middleware.RequestIdMiddleware",
|
||||
"portal.config.middleware.SecurityHeadersMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "portal.config.urls"
|
||||
WSGI_APPLICATION = "portal.config.wsgi.application"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "portal" / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.template.context_processors.i18n",
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": DATA_DIR / "db.sqlite3",
|
||||
"OPTIONS": {
|
||||
"transaction_mode": "IMMEDIATE",
|
||||
"timeout": 20,
|
||||
"init_command": "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;",
|
||||
},
|
||||
}
|
||||
}
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# Time and language (AD-15): store UTC, translate every string.
|
||||
USE_TZ = True
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
LANGUAGE_CODE = "en"
|
||||
LANGUAGES = [("en", "English"), ("de", "Deutsch")]
|
||||
LOCALE_PATHS = [BASE_DIR / "portal" / "locale"]
|
||||
|
||||
# Static files served by whitenoise; assets are built into the image.
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STATICFILES_DIRS = [BASE_DIR / "portal" / "static"]
|
||||
STORAGES = {
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
|
||||
}
|
||||
|
||||
# Sessions in the database; cookies secure, HttpOnly, SameSite=Lax (AD-6).
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.db"
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_HTTPONLY = True
|
||||
CSRF_COOKIE_SAMESITE = "Lax"
|
||||
|
||||
# TLS ends at the reverse proxy.
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_REFERRER_POLICY = "same-origin"
|
||||
SECURE_HSTS_SECONDS = 0 if DEBUG else 31536000
|
||||
X_FRAME_OPTIONS = "DENY"
|
||||
|
||||
LOGGING = build_logging(_config.log_level)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from .health import healthz
|
||||
|
||||
urlpatterns = [
|
||||
path("healthz", healthz, name="healthz"),
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portal.config.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,10 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_env(tmp_path):
|
||||
return {
|
||||
"SECRET_KEY": "x" * 48,
|
||||
"ALLOWED_HOSTS": "testserver",
|
||||
"DATA_DIR": str(tmp_path),
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from portal.config.env import ConfigError, load_config
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_valid_environment_uses_safe_defaults(valid_env):
|
||||
config = load_config(valid_env)
|
||||
assert config.debug is False
|
||||
assert config.log_level == "INFO"
|
||||
assert config.default_timezone == "Europe/Berlin"
|
||||
assert config.trusted_proxies == []
|
||||
assert config.allowed_hosts == ["testserver"]
|
||||
|
||||
|
||||
def test_every_problem_is_listed_and_no_value_leaks(valid_env):
|
||||
secret = "short-secret"
|
||||
env = {
|
||||
**valid_env,
|
||||
"SECRET_KEY": secret,
|
||||
"LOG_LEVEL": "loud",
|
||||
"DEBUG": "maybe",
|
||||
"TRUSTED_PROXIES": "not-an-ip",
|
||||
"DEFAULT_TIMEZONE": "Nowhere/Land",
|
||||
}
|
||||
del env["ALLOWED_HOSTS"]
|
||||
with pytest.raises(ConfigError) as info:
|
||||
load_config(env)
|
||||
message = str(info.value)
|
||||
for name in (
|
||||
"SECRET_KEY",
|
||||
"ALLOWED_HOSTS",
|
||||
"LOG_LEVEL",
|
||||
"DEBUG",
|
||||
"TRUSTED_PROXIES",
|
||||
"DEFAULT_TIMEZONE",
|
||||
):
|
||||
assert name in message
|
||||
assert secret not in message
|
||||
|
||||
|
||||
def test_missing_data_dir_is_reported(valid_env, tmp_path):
|
||||
with pytest.raises(ConfigError, match="DATA_DIR"):
|
||||
load_config({**valid_env, "DATA_DIR": str(tmp_path / "missing")})
|
||||
|
||||
|
||||
def test_process_exits_nonzero_naming_both_variables(tmp_path):
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("SECRET_KEY", "LOG_LEVEL")}
|
||||
env.update(
|
||||
ALLOWED_HOSTS="testserver",
|
||||
DATA_DIR=str(tmp_path),
|
||||
LOG_LEVEL="loud",
|
||||
DJANGO_SETTINGS_MODULE="portal.config.settings",
|
||||
)
|
||||
result = subprocess.run( # noqa: S603
|
||||
[sys.executable, "manage.py", "check"],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "SECRET_KEY" in result.stderr
|
||||
assert "LOG_LEVEL" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
@@ -0,0 +1,39 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_security_headers_present(client):
|
||||
response = client.get("/healthz")
|
||||
csp = response["Content-Security-Policy"]
|
||||
assert "script-src 'self'" in csp
|
||||
assert "default-src 'self'" in csp
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
assert "unsafe-inline" not in csp
|
||||
assert "http" not in csp
|
||||
assert response["X-Content-Type-Options"] == "nosniff"
|
||||
assert response["X-Frame-Options"] == "DENY"
|
||||
assert response["Referrer-Policy"] == "same-origin"
|
||||
assert response["X-Request-ID"]
|
||||
|
||||
|
||||
def test_headers_also_on_error_responses(client):
|
||||
response = client.get("/missing")
|
||||
assert response.status_code == 404
|
||||
assert "Content-Security-Policy" in response
|
||||
assert response["X-Request-ID"]
|
||||
|
||||
|
||||
def test_valid_incoming_request_id_is_kept(client):
|
||||
response = client.get("/missing", headers={"X-Request-ID": "abc12345-req"})
|
||||
assert response["X-Request-ID"] == "abc12345-req"
|
||||
|
||||
|
||||
def test_invalid_incoming_request_id_is_replaced(client):
|
||||
response = client.get("/missing", headers={"X-Request-ID": "bad id\twith junk"})
|
||||
assert response["X-Request-ID"] != "bad id\twith junk"
|
||||
|
||||
|
||||
def test_csp_on_bad_host_response(client):
|
||||
response = client.get("/healthz", HTTP_HOST="unlisted.example")
|
||||
assert response.status_code == 400
|
||||
assert "script-src 'self'" in response["Content-Security-Policy"]
|
||||
@@ -0,0 +1,20 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.db.utils import OperationalError
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_health_ok(client):
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_health_down_when_database_unreachable(client, caplog):
|
||||
with mock.patch("portal.config.health.connection") as connection:
|
||||
connection.cursor.side_effect = OperationalError("unable to open database file")
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"status": "unavailable"}
|
||||
assert "unable to open" not in caplog.text
|
||||
@@ -0,0 +1,38 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from portal.config.logging import JsonFormatter
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_request_log_has_id_and_no_query(client, caplog):
|
||||
with caplog.at_level(logging.INFO, logger="portal.request"):
|
||||
response = client.get("/healthz?token=supersecret")
|
||||
records = [r for r in caplog.records if r.name == "portal.request"]
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record.request_id == response["X-Request-ID"]
|
||||
assert (record.method, record.path, record.status) == ("GET", "/healthz", 200)
|
||||
assert "supersecret" not in caplog.text
|
||||
|
||||
|
||||
def test_json_formatter_emits_json_with_extras():
|
||||
record = logging.LogRecord("x", logging.INFO, __file__, 1, "hello %s", ("world",), None)
|
||||
record.request_id = "rid-1"
|
||||
record.status = 200
|
||||
data = json.loads(JsonFormatter().format(record))
|
||||
assert data["message"] == "hello world"
|
||||
assert data["request_id"] == "rid-1"
|
||||
assert data["status"] == 200
|
||||
assert data["level"] == "INFO"
|
||||
|
||||
|
||||
def test_404_with_query_writes_one_line_without_query(client, caplog):
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
client.get("/missing?token=supersecret")
|
||||
assert len(caplog.records) == 1
|
||||
output = JsonFormatter().format(caplog.records[0])
|
||||
assert "supersecret" not in output
|
||||
assert json.loads(output)["path"] == "/missing"
|
||||
Reference in New Issue
Block a user