diff --git a/package/share/ahriman/templates/error.jinja2 b/package/share/ahriman/templates/error.jinja2 new file mode 100644 index 00000000..a7e627d7 --- /dev/null +++ b/package/share/ahriman/templates/error.jinja2 @@ -0,0 +1,31 @@ + + + + Error + + + + + + {% include "utils/style.jinja2" %} + + + + +
+
+
+
+ {{ code }} +
{{ reason }}
+ home +
+
+
+
+ + {% include "utils/bootstrap-scripts.jinja2" %} + + + + \ No newline at end of file diff --git a/setup.py b/setup.py index 8f6abcd5..74c84fa4 100644 --- a/setup.py +++ b/setup.py @@ -66,6 +66,7 @@ setup( ("share/ahriman/templates", [ "package/share/ahriman/templates/build-status.jinja2", "package/share/ahriman/templates/email-index.jinja2", + "package/share/ahriman/templates/error.jinja2", "package/share/ahriman/templates/repo-index.jinja2", "package/share/ahriman/templates/shell", "package/share/ahriman/templates/telegram-index.jinja2", diff --git a/src/ahriman/web/middlewares/exception_handler.py b/src/ahriman/web/middlewares/exception_handler.py index fbd4065c..ed5daec2 100644 --- a/src/ahriman/web/middlewares/exception_handler.py +++ b/src/ahriman/web/middlewares/exception_handler.py @@ -17,10 +17,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +import aiohttp_jinja2 import logging from aiohttp.web import middleware, Request -from aiohttp.web_exceptions import HTTPClientError, HTTPException, HTTPServerError +from aiohttp.web_exceptions import HTTPClientError, HTTPException, HTTPServerError, HTTPUnauthorized from aiohttp.web_response import json_response, StreamResponse from ahriman.web.middlewares import HandlerType, MiddlewareType @@ -43,6 +44,11 @@ def exception_handler(logger: logging.Logger) -> MiddlewareType: async def handle(request: Request, handler: HandlerType) -> StreamResponse: try: return await handler(request) + except HTTPUnauthorized as e: + if is_templated_unauthorized(request): + context = {"code": e.status_code, "reason": e.reason} + return aiohttp_jinja2.render_template("error.jinja2", request, context, status=e.status_code) + return json_response(data={"error": e.reason}, status=e.status_code) except HTTPClientError as e: return json_response(data={"error": e.reason}, status=e.status_code) except HTTPServerError as e: @@ -55,3 +61,17 @@ def exception_handler(logger: logging.Logger) -> MiddlewareType: return json_response(data={"error": str(e)}, status=500) return handle + + +def is_templated_unauthorized(request: Request) -> bool: + """ + check if the request is eligible for rendering html template + + Args: + request(Request): source request to check + + Returns: + bool: True in case if response should be rendered as html and False otherwise + """ + return request.path in ("/api/v1/login", "/api/v1/logout") \ + and "application/json" not in request.headers.getall("accept", []) diff --git a/tests/ahriman/core/test_util.py b/tests/ahriman/core/test_util.py index 5e976d8d..d8c83ddd 100644 --- a/tests/ahriman/core/test_util.py +++ b/tests/ahriman/core/test_util.py @@ -335,6 +335,7 @@ def test_walk(resource_path_root: Path) -> None: resource_path_root / "web" / "templates" / "utils" / "style.jinja2", resource_path_root / "web" / "templates" / "build-status.jinja2", resource_path_root / "web" / "templates" / "email-index.jinja2", + resource_path_root / "web" / "templates" / "error.jinja2", resource_path_root / "web" / "templates" / "repo-index.jinja2", resource_path_root / "web" / "templates" / "shell", resource_path_root / "web" / "templates" / "telegram-index.jinja2", diff --git a/tests/ahriman/web/middlewares/test_exception_handler.py b/tests/ahriman/web/middlewares/test_exception_handler.py index 712c755a..f62af933 100644 --- a/tests/ahriman/web/middlewares/test_exception_handler.py +++ b/tests/ahriman/web/middlewares/test_exception_handler.py @@ -2,12 +2,12 @@ import json import logging import pytest -from aiohttp.web_exceptions import HTTPBadRequest, HTTPInternalServerError, HTTPNoContent +from aiohttp.web_exceptions import HTTPBadRequest, HTTPInternalServerError, HTTPNoContent, HTTPUnauthorized from pytest_mock import MockerFixture from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock -from ahriman.web.middlewares.exception_handler import exception_handler +from ahriman.web.middlewares.exception_handler import exception_handler, is_templated_unauthorized def _extract_body(response: Any) -> Any: @@ -23,6 +23,37 @@ def _extract_body(response: Any) -> Any: return json.loads(getattr(response, "body")) +def test_is_templated_unauthorized() -> None: + """ + must correct check if response should be rendered as template + """ + response_mock = MagicMock() + + response_mock.path = "/api/v1/login" + response_mock.headers.getall.return_value = ["*/*"] + assert is_templated_unauthorized(response_mock) + + response_mock.path = "/api/v1/login" + response_mock.headers.getall.return_value = ["application/json"] + assert not is_templated_unauthorized(response_mock) + + response_mock.path = "/api/v1/logout" + response_mock.headers.getall.return_value = ["*/*"] + assert is_templated_unauthorized(response_mock) + + response_mock.path = "/api/v1/logout" + response_mock.headers.getall.return_value = ["application/json"] + assert not is_templated_unauthorized(response_mock) + + response_mock.path = "/api/v1/status" + response_mock.headers.getall.return_value = ["*/*"] + assert not is_templated_unauthorized(response_mock) + + response_mock.path = "/api/v1/status" + response_mock.headers.getall.return_value = ["application/json"] + assert not is_templated_unauthorized(response_mock) + + async def test_exception_handler(mocker: MockerFixture) -> None: """ must pass success response @@ -50,6 +81,36 @@ async def test_exception_handler_success(mocker: MockerFixture) -> None: logging_mock.assert_not_called() +async def test_exception_handler_unauthorized(mocker: MockerFixture) -> None: + """ + must handle unauthorized exception as json response + """ + request = pytest.helpers.request("", "", "") + request_handler = AsyncMock(side_effect=HTTPUnauthorized()) + mocker.patch("ahriman.web.middlewares.exception_handler.is_templated_unauthorized", return_value=False) + render_mock = mocker.patch("aiohttp_jinja2.render_template") + + handler = exception_handler(logging.getLogger()) + response = await handler(request, request_handler) + assert _extract_body(response) == {"error": HTTPUnauthorized().reason} + render_mock.assert_not_called() + + +async def test_exception_handler_unauthorized_templated(mocker: MockerFixture) -> None: + """ + must handle unauthorized exception as json response + """ + request = pytest.helpers.request("", "", "") + request_handler = AsyncMock(side_effect=HTTPUnauthorized()) + mocker.patch("ahriman.web.middlewares.exception_handler.is_templated_unauthorized", return_value=True) + render_mock = mocker.patch("aiohttp_jinja2.render_template") + + handler = exception_handler(logging.getLogger()) + await handler(request, request_handler) + context = {"code": 401, "reason": "Unauthorized"} + render_mock.assert_called_once_with("error.jinja2", request, context, status=HTTPUnauthorized.status_code) + + async def test_exception_handler_client_error(mocker: MockerFixture) -> None: """ must handle client exception