130 lines
4.6 KiB
Python
130 lines
4.6 KiB
Python
"""Tests for the generic Exception handler at decnet/web/api.py.
|
|
|
|
Mitigation target: threat model F1/I — "Production error handler suppresses
|
|
tracebacks and internal details." Verifies that uncaught exceptions return
|
|
an opaque 500 with a correlation id in prod, and include debug detail only
|
|
when DECNET_DEVELOPER is on.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from typing import AsyncGenerator
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
|
from decnet.web.api import app
|
|
from decnet.web.dependencies import repo
|
|
|
|
|
|
@pytest.fixture
|
|
async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
"""Override the shared client fixture to NOT re-raise app exceptions.
|
|
|
|
The default `httpx.ASGITransport` re-raises any uncaught exception
|
|
from the app — which defeats the whole point of testing our
|
|
generic exception handler. With `raise_app_exceptions=False`, the
|
|
transport instead returns the HTTP response our handler built.
|
|
"""
|
|
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
yield ac
|
|
|
|
|
|
async def _admin_headers(client: httpx.AsyncClient) -> dict[str, str]:
|
|
resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD},
|
|
)
|
|
token = resp.json()["access_token"]
|
|
# Clear must_change_password so the token passes mutation-gated endpoints.
|
|
await client.post(
|
|
"/api/v1/auth/change-password",
|
|
json={"old_password": DECNET_ADMIN_PASSWORD, "new_password": DECNET_ADMIN_PASSWORD},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
resp2 = await client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD},
|
|
)
|
|
return {"Authorization": f"Bearer {resp2.json()['access_token']}"}
|
|
|
|
|
|
def _raise_boom(*_a, **_kw):
|
|
raise RuntimeError("boom")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_unhandled_exception_prod_shape_is_opaque(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Prod mode (DECNET_DEVELOPER=False): 500 with opaque body + error_id.
|
|
Must NOT include traceback or exception_type."""
|
|
import decnet.web.api as _api
|
|
monkeypatch.setattr(_api, "DECNET_DEVELOPER", False)
|
|
|
|
headers = await _admin_headers(client)
|
|
monkeypatch.setattr(repo, "get_attacker_by_uuid", _raise_boom)
|
|
|
|
r = await client.get("/api/v1/attackers/any-uuid", headers=headers)
|
|
|
|
assert r.status_code == 500
|
|
body = r.json()
|
|
assert body.get("detail") == "Internal Server Error"
|
|
assert "error_id" in body
|
|
assert re.fullmatch(r"[0-9a-f]{32}", body["error_id"]), body["error_id"]
|
|
assert "traceback" not in body
|
|
assert "exception_type" not in body
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_unhandled_exception_dev_shape_includes_traceback(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Dev mode (DECNET_DEVELOPER=True): body includes exception_type and
|
|
traceback so failures are debuggable without tailing server logs."""
|
|
import decnet.web.api as _api
|
|
monkeypatch.setattr(_api, "DECNET_DEVELOPER", True)
|
|
|
|
headers = await _admin_headers(client)
|
|
monkeypatch.setattr(repo, "get_attacker_by_uuid", _raise_boom)
|
|
|
|
r = await client.get("/api/v1/attackers/any-uuid", headers=headers)
|
|
|
|
assert r.status_code == 500
|
|
body = r.json()
|
|
assert body["detail"] == "Internal Server Error"
|
|
assert "error_id" in body
|
|
assert body["exception_type"] == "RuntimeError"
|
|
assert "RuntimeError" in body["traceback"]
|
|
assert "boom" in body["traceback"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_unhandled_exception_logs_error_id(
|
|
client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""The same error_id returned to the client must appear in server logs,
|
|
so operators can correlate a user's 500 report with the full traceback."""
|
|
import decnet.web.api as _api
|
|
monkeypatch.setattr(_api, "DECNET_DEVELOPER", False)
|
|
|
|
headers = await _admin_headers(client)
|
|
monkeypatch.setattr(repo, "get_attacker_by_uuid", _raise_boom)
|
|
|
|
with caplog.at_level(logging.ERROR, logger="api"):
|
|
r = await client.get("/api/v1/attackers/any-uuid", headers=headers)
|
|
|
|
assert r.status_code == 500
|
|
error_id = r.json()["error_id"]
|
|
assert any(error_id in rec.getMessage() for rec in caplog.records), (
|
|
f"error_id {error_id} not found in captured logs: "
|
|
f"{[rec.getMessage() for rec in caplog.records]}"
|
|
)
|