- 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>
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""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)
|