353 lines
13 KiB
Python
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)
|