Co-Study4Grid / expert_backend /tests /test_api_errors.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
7.55 kB
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# SPDX-License-Identifier: MPL-2.0
"""Unit + integration tests for the unified API error contract (D2, 2026-07).
Guards `expert_backend/services/api_errors.py`:
- every HTTPException renders as ``{detail, code}`` (code additive);
- request-validation errors keep their rich detail + gain ``VALIDATION``;
- **uncaught exceptions never leak ``str(exc)``** (the QW6 / security fix):
a generic ``500`` + ``code=INTERNAL``, with the real message only logged;
- the frontend's branch discriminator ``ACTION_RESULT_UNAVAILABLE`` reaches
the client on the post-reload action-variant-diagram 400.
"""
from __future__ import annotations
import asyncio
import json
import pytest
from fastapi import HTTPException
from fastapi.exceptions import RequestValidationError
from starlette.requests import Request
from expert_backend.services.api_errors import (
AppHTTPException,
CODE_ACTION_RESULT_UNAVAILABLE,
CODE_BAD_REQUEST,
CODE_INTERNAL,
CODE_NOT_FOUND,
CODE_STUDY_BUSY,
CODE_VALIDATION,
_code_for,
_http_exception_handler,
_unhandled_exception_handler,
_validation_exception_handler,
install_error_handlers,
)
def _fake_request() -> Request:
return Request(
{
"type": "http",
"method": "POST",
"path": "/api/thing",
"headers": [],
"query_string": b"",
}
)
def _run(coro):
return asyncio.run(coro)
def _body(response) -> dict:
return json.loads(bytes(response.body))
# ---------------------------------------------------------------------
# AppHTTPException + _code_for
# ---------------------------------------------------------------------
def test_app_http_exception_carries_code_and_is_http_exception():
exc = AppHTTPException(status_code=409, detail="busy", code=CODE_STUDY_BUSY)
assert isinstance(exc, HTTPException)
assert exc.status_code == 409
assert exc.detail == "busy"
assert exc.code == CODE_STUDY_BUSY
def test_code_for_prefers_explicit_code():
exc = AppHTTPException(status_code=400, detail="x", code=CODE_ACTION_RESULT_UNAVAILABLE)
assert _code_for(exc) == CODE_ACTION_RESULT_UNAVAILABLE
@pytest.mark.parametrize(
"status, expected",
[
(400, CODE_BAD_REQUEST),
(404, CODE_NOT_FOUND),
(409, CODE_STUDY_BUSY),
(422, CODE_VALIDATION),
(500, CODE_INTERNAL),
(418, CODE_BAD_REQUEST), # unmapped status → conservative default
],
)
def test_code_for_default_mapping_by_status(status, expected):
assert _code_for(HTTPException(status_code=status, detail="d")) == expected
# ---------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------
def test_http_handler_renders_detail_and_code():
exc = HTTPException(status_code=404, detail="Not found")
resp = _run(_http_exception_handler(_fake_request(), exc))
assert resp.status_code == 404
assert _body(resp) == {"detail": "Not found", "code": CODE_NOT_FOUND}
def test_http_handler_uses_explicit_app_code():
exc = AppHTTPException(status_code=400, detail="no obs", code=CODE_ACTION_RESULT_UNAVAILABLE)
resp = _run(_http_exception_handler(_fake_request(), exc))
assert _body(resp) == {"detail": "no obs", "code": CODE_ACTION_RESULT_UNAVAILABLE}
def test_http_handler_reraises_non_http_exception():
# Dispatch invariant: only registered for HTTPException.
with pytest.raises(ValueError):
_run(_http_exception_handler(_fake_request(), ValueError("boom")))
def test_validation_handler_keeps_errors_and_tags_validation():
exc = RequestValidationError(
[{"loc": ("body", "x"), "msg": "field required", "type": "missing"}]
)
resp = _run(_validation_exception_handler(_fake_request(), exc))
body = _body(resp)
assert resp.status_code == 422
assert body["code"] == CODE_VALIDATION
assert isinstance(body["detail"], list)
assert body["detail"][0]["msg"] == "field required"
def test_unhandled_handler_returns_generic_500_and_does_not_leak():
secret = "/home/operator/secret/path/grid.xiidm does not exist"
resp = _run(_unhandled_exception_handler(_fake_request(), RuntimeError(secret)))
body = _body(resp)
assert resp.status_code == 500
assert body == {"detail": "Internal server error.", "code": CODE_INTERNAL}
# The security guarantee: the exception's message NEVER reaches the client.
assert secret not in json.dumps(body)
# ---------------------------------------------------------------------
# Wiring on the real app
# ---------------------------------------------------------------------
def test_install_error_handlers_registers_all_three():
from fastapi import FastAPI
app = FastAPI()
install_error_handlers(app)
assert HTTPException in app.exception_handlers
assert RequestValidationError in app.exception_handlers
assert Exception in app.exception_handlers
def test_main_app_has_the_handlers_installed():
from expert_backend.main import app
assert Exception in app.exception_handlers
assert HTTPException in app.exception_handlers
# ---------------------------------------------------------------------
# Integration: the frontend-branch discriminator reaches the client
# ---------------------------------------------------------------------
def test_action_variant_diagram_400_carries_action_result_unavailable_code():
from unittest.mock import patch
fastapi_testclient = pytest.importorskip("fastapi.testclient")
from expert_backend.services.diagram_mixin import ActionResultUnavailableError
with patch("expert_backend.main.recommender_service") as mock_rs:
mock_rs.get_action_variant_diagram.side_effect = ActionResultUnavailableError(
"No analysis result available. Run analysis first."
)
from expert_backend.main import app
client = fastapi_testclient.TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/api/action-variant-diagram",
json={"action_id": "a1", "mode": "network"},
)
assert resp.status_code == 400
body = resp.json()
assert body["code"] == CODE_ACTION_RESULT_UNAVAILABLE
assert "detail" in body
def test_response_validation_failure_becomes_generic_500_not_a_leak():
"""A response that doesn't match its `response_model` raises OUTSIDE the
endpoint's try/except (during serialization) — proving the global
Exception handler fires AND still returns the generic envelope."""
from unittest.mock import patch
fastapi_testclient = pytest.importorskip("fastapi.testclient")
with patch("expert_backend.main.recommender_service") as mock_rs:
# active_model must be a str for RecommenderModelResponse; returning a
# dict makes response serialization fail with a ResponseValidationError.
mock_rs.get_active_model_name.return_value = {"not": "a string"}
mock_rs.get_compute_overflow_graph.return_value = True
from expert_backend.main import app
client = fastapi_testclient.TestClient(app, raise_server_exceptions=False)
resp = client.post("/api/recommender-model", json={"model": "random"})
assert resp.status_code == 500
assert resp.json() == {"detail": "Internal server error.", "code": CODE_INTERNAL}