Implement centralized exception handling and validation

- Add custom exception classes for structured error handling
- Implement global exception handlers in FastAPI application
- Add comprehensive request/response validation
- Create exception contract tests for validation
- Update backend development documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-27 18:52:12 +02:00
parent 2e221f6852
commit afc1e44e99
5 changed files with 500 additions and 98 deletions

View File

@@ -0,0 +1,308 @@
"""Test exception contract: categories map to HTTP status codes.
This test suite verifies that domain exceptions inherit from the correct
base categories and that main.py registers handlers that map those categories
to the expected HTTP status codes.
The exception taxonomy ensures:
1. Routers don't need to know about individual exception types.
2. Exception mapping is consistent and centralized in main.py.
3. Existing clients see predictable HTTP status codes.
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.exceptions import (
ActionAlreadyExistsError,
ActionNameError,
ActionNotFoundError,
ActionReadonlyError,
BadRequestError,
ConfigDirError,
ConfigFileExistsError,
ConfigFileNameError,
ConfigFileNotFoundError,
ConfigFileWriteError,
ConfigOperationError,
ConfigValidationError,
ConfigWriteError,
ConflictError,
Fail2BanConnectionError,
Fail2BanProtocolError,
FilterAlreadyExistsError,
FilterInvalidRegexError,
FilterNameError,
FilterNotFoundError,
FilterReadonlyError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundError,
JailNotFoundInConfigError,
JailOperationError,
NotFoundError,
OperationError,
ServerOperationError,
ServiceUnavailableError,
)
from app.main import create_app
# ---------------------------------------------------------------------------
# Exception Taxonomy Tests
# ---------------------------------------------------------------------------
class TestExceptionTaxonomy:
"""Verify all domain exceptions inherit from the correct category."""
def test_jail_not_found_error_inherits_from_not_found_error(self) -> None:
exc = JailNotFoundError("test")
assert isinstance(exc, NotFoundError)
def test_jail_not_found_in_config_error_inherits_from_not_found_error(self) -> None:
exc = JailNotFoundInConfigError("test")
assert isinstance(exc, NotFoundError)
def test_filter_not_found_error_inherits_from_not_found_error(self) -> None:
exc = FilterNotFoundError("test")
assert isinstance(exc, NotFoundError)
def test_action_not_found_error_inherits_from_not_found_error(self) -> None:
exc = ActionNotFoundError("test")
assert isinstance(exc, NotFoundError)
def test_config_file_not_found_error_inherits_from_not_found_error(self) -> None:
exc = ConfigFileNotFoundError("test.conf")
assert isinstance(exc, NotFoundError)
def test_config_validation_error_inherits_from_bad_request_error(self) -> None:
exc = ConfigValidationError()
assert isinstance(exc, BadRequestError)
def test_config_operation_error_inherits_from_bad_request_error(self) -> None:
exc = ConfigOperationError()
assert isinstance(exc, BadRequestError)
def test_config_file_name_error_inherits_from_bad_request_error(self) -> None:
exc = ConfigFileNameError()
assert isinstance(exc, BadRequestError)
def test_server_operation_error_inherits_from_bad_request_error(self) -> None:
exc = ServerOperationError()
assert isinstance(exc, BadRequestError)
def test_jail_name_error_inherits_from_bad_request_error(self) -> None:
exc = JailNameError()
assert isinstance(exc, BadRequestError)
def test_filter_name_error_inherits_from_bad_request_error(self) -> None:
exc = FilterNameError()
assert isinstance(exc, BadRequestError)
def test_action_name_error_inherits_from_bad_request_error(self) -> None:
exc = ActionNameError()
assert isinstance(exc, BadRequestError)
def test_filter_invalid_regex_error_inherits_from_bad_request_error(self) -> None:
exc = FilterInvalidRegexError("[invalid", "error")
assert isinstance(exc, BadRequestError)
def test_jail_operation_error_inherits_from_conflict_error(self) -> None:
exc = JailOperationError()
assert isinstance(exc, ConflictError)
def test_jail_already_active_error_inherits_from_conflict_error(self) -> None:
exc = JailAlreadyActiveError("test")
assert isinstance(exc, ConflictError)
def test_jail_already_inactive_error_inherits_from_conflict_error(self) -> None:
exc = JailAlreadyInactiveError("test")
assert isinstance(exc, ConflictError)
def test_filter_already_exists_error_inherits_from_conflict_error(self) -> None:
exc = FilterAlreadyExistsError("test")
assert isinstance(exc, ConflictError)
def test_filter_readonly_error_inherits_from_conflict_error(self) -> None:
exc = FilterReadonlyError("test")
assert isinstance(exc, ConflictError)
def test_action_already_exists_error_inherits_from_conflict_error(self) -> None:
exc = ActionAlreadyExistsError("test")
assert isinstance(exc, ConflictError)
def test_action_readonly_error_inherits_from_conflict_error(self) -> None:
exc = ActionReadonlyError("test")
assert isinstance(exc, ConflictError)
def test_config_file_exists_error_inherits_from_conflict_error(self) -> None:
exc = ConfigFileExistsError("test.conf")
assert isinstance(exc, ConflictError)
def test_config_write_error_inherits_from_operation_error(self) -> None:
exc = ConfigWriteError("test")
assert isinstance(exc, OperationError)
def test_config_file_write_error_inherits_from_operation_error(self) -> None:
exc = ConfigFileWriteError()
assert isinstance(exc, OperationError)
def test_fail2ban_connection_error_inherits_from_service_unavailable_error(
self,
) -> None:
exc = Fail2BanConnectionError("test", "/tmp/socket")
assert isinstance(exc, ServiceUnavailableError)
def test_fail2ban_protocol_error_inherits_from_service_unavailable_error(
self,
) -> None:
exc = Fail2BanProtocolError()
assert isinstance(exc, ServiceUnavailableError)
def test_config_dir_error_inherits_from_service_unavailable_error(self) -> None:
exc = ConfigDirError()
assert isinstance(exc, ServiceUnavailableError)
# ---------------------------------------------------------------------------
# HTTP Status Code Mapping Tests
# ---------------------------------------------------------------------------
class TestExceptionHandlerMapping:
"""Verify exception handlers map to the correct HTTP status codes."""
@pytest.mark.asyncio
async def test_not_found_error_returns_404(self, test_settings: Settings) -> None:
"""NotFoundError handlers return HTTP 404."""
app = create_app(test_settings)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
@app.get("/test-404")
async def test_404() -> None:
raise JailNotFoundError("test_jail")
response = await client.get("/test-404")
assert response.status_code == 404
data = response.json()
assert "detail" in data
assert "Jail not found" in data["detail"]
@pytest.mark.asyncio
async def test_bad_request_error_returns_400(self, test_settings: Settings) -> None:
"""BadRequestError handlers return HTTP 400."""
app = create_app(test_settings)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
@app.get("/test-400")
async def test_400() -> None:
raise ConfigValidationError()
response = await client.get("/test-400")
assert response.status_code == 400
@pytest.mark.asyncio
async def test_conflict_error_returns_409(self, test_settings: Settings) -> None:
"""ConflictError handlers return HTTP 409."""
app = create_app(test_settings)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
@app.get("/test-409")
async def test_409() -> None:
raise JailAlreadyActiveError("test_jail")
response = await client.get("/test-409")
assert response.status_code == 409
data = response.json()
assert "detail" in data
assert "already active" in data["detail"]
@pytest.mark.asyncio
async def test_operation_error_returns_500(self, test_settings: Settings) -> None:
"""OperationError handlers return HTTP 500."""
app = create_app(test_settings)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
@app.get("/test-500")
async def test_500() -> None:
raise ConfigWriteError("test error")
response = await client.get("/test-500")
assert response.status_code == 500
data = response.json()
assert "detail" in data
@pytest.mark.asyncio
async def test_service_unavailable_error_returns_503(self, test_settings: Settings) -> None:
"""ServiceUnavailableError handlers return HTTP 503."""
app = create_app(test_settings)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
@app.get("/test-503")
async def test_503() -> None:
raise ConfigDirError()
response = await client.get("/test-503")
assert response.status_code == 503
data = response.json()
assert "detail" in data
@pytest.mark.asyncio
async def test_fail2ban_connection_error_returns_502(self, test_settings: Settings) -> None:
"""Fail2BanConnectionError is specially mapped to HTTP 502 (not 503)."""
app = create_app(test_settings)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
@app.get("/test-502")
async def test_502() -> None:
raise Fail2BanConnectionError("test", "/tmp/socket")
response = await client.get("/test-502")
assert response.status_code == 502
data = response.json()
assert "detail" in data
# ---------------------------------------------------------------------------
# Exception Detail Preservation Tests
# ---------------------------------------------------------------------------
class TestExceptionDetails:
"""Verify exception details are preserved in responses."""
def test_jail_not_found_error_preserves_name(self) -> None:
exc = JailNotFoundError("my_jail")
assert exc.name == "my_jail"
assert "my_jail" in str(exc)
def test_jail_already_active_error_preserves_name(self) -> None:
exc = JailAlreadyActiveError("my_jail")
assert exc.name == "my_jail"
assert "my_jail" in str(exc)
def test_config_file_not_found_error_preserves_filename(self) -> None:
exc = ConfigFileNotFoundError("test.conf")
assert exc.filename == "test.conf"
assert "test.conf" in str(exc)
def test_config_write_error_preserves_message(self) -> None:
exc = ConfigWriteError("Permission denied")
assert exc.message == "Permission denied"
assert "Permission denied" in str(exc)
def test_fail2ban_connection_error_preserves_socket_path(self) -> None:
exc = Fail2BanConnectionError(
"Connection refused", "/var/run/fail2ban/fail2ban.sock"
)
assert exc.socket_path == "/var/run/fail2ban/fail2ban.sock"
def test_filter_invalid_regex_error_preserves_pattern_and_error(self) -> None:
exc = FilterInvalidRegexError("[invalid", "unterminated character set")
assert exc.pattern == "[invalid"
assert exc.error == "unterminated character set"
assert "[invalid" in str(exc)