Files
AareDAQ/tests/unit/daq/test_server_exception_handler.py
2026-07-06 15:39:02 +02:00

353 lines
13 KiB
Python

"""Tests for the collapsed server exception handlers (Phase 2 of the
exception-handling redesign).
Per plan §2, server-side handling is now four handlers + HTTPException +
bare-Exception fallback. Each emits the unified response body from §3:
{"critical", "code", "exception_class", "message", "context"}
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import pytest
from aarecommon.errors.codes import AuthErrorCode
from aarecommon.errors.exception_handler import (
AareAuthError,
AareDBCommunicationError,
AareUserError,
AuthenticationException,
AutomationError,
CriticalTellException,
LoopCenteringFailed,
ManualMountException,
MountingFailed,
SampleException,
SmargonCommunicationError,
TellCommunicationError,
UnmountingFailed,
UserRightsException,
WarningTellException,
)
from fastapi import FastAPI, HTTPException
from starlette.requests import Request
from starlette.responses import JSONResponse
from aare.daq.server_exception_handler import register_exception_handlers
@pytest.fixture
def app():
app = FastAPI()
register_exception_handlers(app)
return app
@pytest.fixture
def mock_request():
return MagicMock(spec=Request)
# Helper: pull JSON body out of a Starlette JSONResponse
def _body(response: JSONResponse) -> dict:
return json.loads(response.body)
# ---------------------------------------------------------------------------
# AutomationError handler -- 503 if critical, 422 otherwise
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_automation_error_not_critical_returns_422(app, mock_request):
# MountingFailed has class default critical=False in Phase 1
exc = MountingFailed("mount failed")
handler = app.exception_handlers[AutomationError]
response = await handler(mock_request, exc)
assert response.status_code == 422
body = _body(response)
assert body["critical"] is False
assert body["code"] == "MOUNTING_FAILED"
assert body["exception_class"] == "MountingFailed"
assert body["message"] == "mount failed"
assert body["context"] == {}
@pytest.mark.asyncio
async def test_automation_error_critical_via_instance_flag_returns_503(app, mock_request):
# Instance-level override: critical=True
exc = MountingFailed("threshold breached", critical=True)
handler = app.exception_handlers[AutomationError]
response = await handler(mock_request, exc)
assert response.status_code == 503
body = _body(response)
assert body["critical"] is True
@pytest.mark.asyncio
async def test_automation_error_context_contains_endpoint_and_operation(app, mock_request):
exc = TellCommunicationError(
"timeout", endpoint="/state", operation="GET", base_url="http://tell:8000"
)
handler = app.exception_handlers[AutomationError]
response = await handler(mock_request, exc)
body = _body(response)
assert body["code"] == "TELL_COMMUNICATION_ERROR"
assert body["context"]["endpoint"] == "/state"
assert body["context"]["operation"] == "GET"
assert body["context"]["base_url"] == "http://tell:8000"
@pytest.mark.asyncio
async def test_automation_error_excludes_critical_and_headers_from_context(app, mock_request):
# critical kwarg should not appear in context (it has its own field)
exc = MountingFailed("x", critical=True)
handler = app.exception_handlers[AutomationError]
response = await handler(mock_request, exc)
body = _body(response)
assert "critical" not in body["context"]
assert "headers" not in body["context"]
# ---------------------------------------------------------------------------
# AareUserError handler -- always 400
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_user_error_returns_400(app, mock_request):
exc = ManualMountException("user must intervene")
handler = app.exception_handlers[AareUserError]
response = await handler(mock_request, exc)
assert response.status_code == 400
body = _body(response)
assert body["critical"] is False
assert body["code"] == "MANUAL_MOUNT_EXCEPTION"
@pytest.mark.asyncio
async def test_sample_exception_returns_400_as_user_error(app, mock_request):
# SampleException parented under AareUserError (see Phase 1 note in plan)
exc = SampleException("Sample SAR0035 not in dewar")
handler = app.exception_handlers[AareUserError]
response = await handler(mock_request, exc)
assert response.status_code == 400
body = _body(response)
assert body["code"] == "SAMPLE_EXCEPTION"
# ---------------------------------------------------------------------------
# AareAuthError handler -- 401 / 403, code from instance.code
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_authentication_exception_returns_401_with_auth_code(app, mock_request):
exc = AuthenticationException("Bad token", code=AuthErrorCode.INVALID_TOKEN)
handler = app.exception_handlers[AareAuthError]
response = await handler(mock_request, exc)
assert response.status_code == 401
body = _body(response)
assert body["critical"] is False
# auth handler surfaces the instance code, not the class-derived one
assert body["code"] == "INVALID_TOKEN"
assert body["exception_class"] == "AuthenticationException"
@pytest.mark.asyncio
async def test_user_rights_exception_returns_403(app, mock_request):
exc = UserRightsException("Not a staff member", code=AuthErrorCode.NOT_STAFF)
handler = app.exception_handlers[AareAuthError]
response = await handler(mock_request, exc)
assert response.status_code == 403
body = _body(response)
assert body["code"] == "NOT_STAFF"
@pytest.mark.asyncio
async def test_authentication_exception_preserves_explicit_status(app, mock_request):
# AuthenticationException can be constructed with a custom status_code
exc = AuthenticationException(
"Forbidden auth path", status_code=403, code=AuthErrorCode.FORBIDDEN
)
handler = app.exception_handlers[AareAuthError]
response = await handler(mock_request, exc)
assert response.status_code == 403
# ---------------------------------------------------------------------------
# HTTPException handler -- pass-through status, new shape
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_http_exception_handler_string_detail(app, mock_request):
exc = HTTPException(status_code=418, detail="I'm a teapot")
handler = app.exception_handlers[HTTPException]
response = await handler(mock_request, exc)
assert response.status_code == 418
body = _body(response)
assert body["code"] == "HTTP_ERROR"
assert body["message"] == "I'm a teapot"
assert body["critical"] is False # 418 < 500
assert body["exception_class"] == "HTTPException"
@pytest.mark.asyncio
async def test_http_exception_handler_dict_detail(app, mock_request):
exc = HTTPException(
status_code=400, detail={"code": "CUSTOM", "message": "Msg", "field": "foo"}
)
handler = app.exception_handlers[HTTPException]
response = await handler(mock_request, exc)
assert response.status_code == 400
body = _body(response)
assert body["code"] == "CUSTOM"
assert body["message"] == "Msg"
assert body["context"] == {"field": "foo"}
@pytest.mark.asyncio
async def test_http_exception_5xx_is_critical(app, mock_request):
exc = HTTPException(status_code=503, detail="downstream gone")
handler = app.exception_handlers[HTTPException]
response = await handler(mock_request, exc)
body = _body(response)
assert body["critical"] is True
# ---------------------------------------------------------------------------
# Bare Exception fallback -- 500, critical=True
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unhandled_exception_is_critical_500(app, mock_request):
exc = ValueError("oops")
handler = app.exception_handlers[Exception]
response = await handler(mock_request, exc)
assert response.status_code == 500
body = _body(response)
assert body["critical"] is True
assert body["code"] == "INTERNAL_ERROR"
assert body["exception_class"] == "ValueError"
assert body["message"] == "oops"
@pytest.mark.asyncio
async def test_unhandled_exception_empty_message_falls_back_to_class_name(app, mock_request):
exc = ValueError()
handler = app.exception_handlers[Exception]
response = await handler(mock_request, exc)
body = _body(response)
assert body["message"] == "ValueError"
# ---------------------------------------------------------------------------
# Handler count -- the design contract is 4 + HTTPException + Exception
# ---------------------------------------------------------------------------
def test_only_four_root_handlers_plus_fallbacks(app):
"""Plan §2: collapse the ~25 per-class handlers down to 4 roots, plus the
HTTPException pass-through and the bare-Exception fallback.
FastAPI pre-registers handlers for RequestValidationError /
WebSocketRequestValidationError -- those are framework-level and not
counted toward our handler budget. We assert (a) our expected handlers
are present and (b) no per-class AareException handlers remain.
"""
from aarecommon.errors.exception_handler import (
AareDBCommunicationError,
LoopCenteringFailed,
MountingFailed,
SmargonCommunicationError,
TellCommunicationError,
)
registered = set(app.exception_handlers.keys())
expected = {AutomationError, AareUserError, AareAuthError, HTTPException, Exception}
assert expected.issubset(registered), f"Missing required handlers: {expected - registered}"
# No leftover per-class handlers from the old design
forbidden = {
MountingFailed,
UnmountingFailed,
TellCommunicationError,
LoopCenteringFailed,
CriticalTellException,
WarningTellException,
SmargonCommunicationError,
AareDBCommunicationError,
}
leftover = forbidden & registered
assert not leftover, f"Per-class handlers must be removed: {leftover}"
# ---------------------------------------------------------------------------
# Response body shape is uniform across handlers
# ---------------------------------------------------------------------------
REQUIRED_BODY_KEYS = {"critical", "code", "exception_class", "message", "context"}
@pytest.mark.asyncio
async def test_automation_error_logs_error_when_critical(app, mock_request, caplog):
exc = MountingFailed("threshold breached", critical=True)
handler = app.exception_handlers[AutomationError]
caplog.clear()
with caplog.at_level("DEBUG"):
await handler(mock_request, exc)
assert any(r.levelname == "ERROR" for r in caplog.records), caplog.records
@pytest.mark.asyncio
async def test_automation_error_logs_warning_when_not_critical(app, mock_request, caplog):
exc = MountingFailed("benign")
handler = app.exception_handlers[AutomationError]
caplog.clear()
with caplog.at_level("DEBUG"):
await handler(mock_request, exc)
assert any(r.levelname == "WARNING" for r in caplog.records), caplog.records
assert not any(r.levelname == "ERROR" for r in caplog.records)
@pytest.mark.asyncio
async def test_user_error_logs_info(app, mock_request, caplog):
exc = ManualMountException("user must intervene")
handler = app.exception_handlers[AareUserError]
caplog.clear()
with caplog.at_level("DEBUG"):
await handler(mock_request, exc)
assert any(r.levelname == "INFO" for r in caplog.records), caplog.records
@pytest.mark.asyncio
async def test_response_body_shape_uniform(app, mock_request):
cases: list[tuple[type, Exception]] = [
(AutomationError, MountingFailed("x")),
(AutomationError, LoopCenteringFailed("y")),
(AutomationError, SmargonCommunicationError("z", operation="GET", endpoint="/e")),
(AutomationError, AareDBCommunicationError("db", critical=True)),
(AareUserError, ManualMountException("m")),
(AareUserError, SampleException("s")),
(AareAuthError, AuthenticationException("a")),
(AareAuthError, UserRightsException("u")),
(Exception, RuntimeError("r")),
(HTTPException, HTTPException(status_code=404, detail="not found")),
]
for handler_key, exc in cases:
handler = app.exception_handlers[handler_key]
response = await handler(mock_request, exc)
body = _body(response)
assert REQUIRED_BODY_KEYS == set(body.keys()), (
f"Body keys mismatch for {type(exc).__name__}: got {set(body.keys())}"
)
assert isinstance(body["critical"], bool)
assert isinstance(body["code"], str) and body["code"]
assert isinstance(body["exception_class"], str) and body["exception_class"]
assert isinstance(body["message"], str) and body["message"]
assert isinstance(body["context"], dict)