diff --git a/src/ahriman/core/log/filtered_access_logger.py b/src/ahriman/core/log/filtered_access_logger.py new file mode 100644 index 00000000..2dfdd60c --- /dev/null +++ b/src/ahriman/core/log/filtered_access_logger.py @@ -0,0 +1,61 @@ +# +# Copyright (c) 2021-2022 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import re + +from aiohttp.abc import BaseRequest, StreamResponse +from aiohttp.web_log import AccessLogger + + +class FilteredAccessLogger(AccessLogger): + """ + access logger implementation with log filter enabled + + Attributes: + LOG_PATH_REGEX(re.Pattern): (class attribute) regex for logs uri + """ + + # official packages have only ``[A-Za-z0-9_.+-]`` regex + LOG_PATH_REGEX = re.compile(r"^/api/v1/packages/[A-Za-z0-9_.+%-]+/logs$") + + @staticmethod + def is_logs_post(request: BaseRequest) -> bool: + """ + check if request looks lie logs posting + + Args: + request(BaseRequest): http reqeust descriptor + + Returns: + bool: True in case if request looks like logs positing and False otherwise + """ + return request.method == "POST" and FilteredAccessLogger.LOG_PATH_REGEX.match(request.path) is not None + + def log(self, request: BaseRequest, response: StreamResponse, time: float) -> None: + """ + access log with enabled filter by request path + + Args: + request(BaseRequest): http reqeust descriptor + response(StreamResponse): streaming response object + time(float): + """ + if self.is_logs_post(request): + return + AccessLogger.log(self, request, response, time) diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index d6140810..a99272f5 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -27,6 +27,7 @@ from ahriman.core.auth import Auth from ahriman.core.configuration import Configuration from ahriman.core.database import SQLite from ahriman.core.exceptions import InitializeError +from ahriman.core.log.filtered_access_logger import FilteredAccessLogger from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher from ahriman.web.middlewares.exception_handler import exception_handler @@ -79,7 +80,7 @@ def run_server(application: web.Application) -> None: port = configuration.getint("web", "port") web.run_app(application, host=host, port=port, handle_signals=False, - access_log=logging.getLogger("http")) + access_log=logging.getLogger("http"), access_log_class=FilteredAccessLogger) def setup_service(architecture: str, configuration: Configuration, spawner: Spawn) -> web.Application: diff --git a/tests/ahriman/core/log/conftest.py b/tests/ahriman/core/log/conftest.py new file mode 100644 index 00000000..caf2b3b0 --- /dev/null +++ b/tests/ahriman/core/log/conftest.py @@ -0,0 +1,16 @@ +import logging +import pytest + +from ahriman.core.log.filtered_access_logger import FilteredAccessLogger + + +@pytest.fixture +def filtered_access_logger() -> FilteredAccessLogger: + """ + fixture for custom access logger + + Returns: + FilteredAccessLogger: custom access logger test instance + """ + logger = logging.getLogger() + return FilteredAccessLogger(logger) diff --git a/tests/ahriman/core/log/test_filtered_access_logger.py b/tests/ahriman/core/log/test_filtered_access_logger.py new file mode 100644 index 00000000..2bee57be --- /dev/null +++ b/tests/ahriman/core/log/test_filtered_access_logger.py @@ -0,0 +1,71 @@ +from pytest_mock import MockerFixture +from unittest.mock import MagicMock + +from ahriman.core.log.filtered_access_logger import FilteredAccessLogger + + +def test_is_logs_post() -> None: + """ + must correctly define if request belongs to logs posting + """ + request = MagicMock() + + request.method = "POST" + request.path = "/api/v1/packages/ahriman/logs" + assert FilteredAccessLogger.is_logs_post(request) + + request.method = "POST" + request.path = "/api/v1/packages/linux-headers/logs" + assert FilteredAccessLogger.is_logs_post(request) + + request.method = "POST" + request.path = "/api/v1/packages/memtest86+/logs" + assert FilteredAccessLogger.is_logs_post(request) + + request.method = "POST" + request.path = "/api/v1/packages/memtest86%2B/logs" + assert FilteredAccessLogger.is_logs_post(request) + + request.method = "POST" + request.path = "/api/v1/packages/python2.7/logs" + assert FilteredAccessLogger.is_logs_post(request) + + request.method = "GET" + request.path = "/api/v1/packages/ahriman/logs" + assert not FilteredAccessLogger.is_logs_post(request) + + request.method = "POST" + request.path = "/api/v1/packages/ahriman" + assert not FilteredAccessLogger.is_logs_post(request) + + request.method = "POST" + request.path = "/api/v1/packages/ahriman/logs/random/path/after" + assert not FilteredAccessLogger.is_logs_post(request) + + +def test_log(filtered_access_logger: FilteredAccessLogger, mocker: MockerFixture) -> None: + """ + must emit log record + """ + request_mock = MagicMock() + response_mock = MagicMock() + is_log_path_mock = mocker.patch("ahriman.core.log.filtered_access_logger.FilteredAccessLogger.is_logs_post", + return_value=False) + log_mock = mocker.patch("aiohttp.web_log.AccessLogger.log") + + filtered_access_logger.log(request_mock, response_mock, 0.001) + is_log_path_mock.assert_called_once_with(request_mock) + log_mock.assert_called_once_with(filtered_access_logger, request_mock, response_mock, 0.001) + + +def test_log_filter_logs(filtered_access_logger: FilteredAccessLogger, mocker: MockerFixture) -> None: + """ + must skip log record in case if it is from logs posting + """ + request_mock = MagicMock() + response_mock = MagicMock() + mocker.patch("ahriman.core.log.filtered_access_logger.FilteredAccessLogger.is_logs_post", return_value=True) + log_mock = mocker.patch("aiohttp.web_log.AccessLogger.log") + + filtered_access_logger.log(request_mock, response_mock, 0.001) + log_mock.assert_not_called() diff --git a/tests/ahriman/web/test_web.py b/tests/ahriman/web/test_web.py index 4e8c75fc..695456db 100644 --- a/tests/ahriman/web/test_web.py +++ b/tests/ahriman/web/test_web.py @@ -4,6 +4,7 @@ from aiohttp import web from pytest_mock import MockerFixture from ahriman.core.exceptions import InitializeError +from ahriman.core.log.filtered_access_logger import FilteredAccessLogger from ahriman.core.status.watcher import Watcher from ahriman.web.web import on_shutdown, on_startup, run_server @@ -48,8 +49,10 @@ def test_run(application: web.Application, mocker: MockerFixture) -> None: run_application_mock = mocker.patch("aiohttp.web.run_app") run_server(application) - run_application_mock.assert_called_once_with(application, host="127.0.0.1", port=port, - handle_signals=False, access_log=pytest.helpers.anyvar(int)) + run_application_mock.assert_called_once_with( + application, host="127.0.0.1", port=port, handle_signals=False, + access_log=pytest.helpers.anyvar(int), access_log_class=FilteredAccessLogger + ) def test_run_with_auth(application_with_auth: web.Application, mocker: MockerFixture) -> None: @@ -61,8 +64,10 @@ def test_run_with_auth(application_with_auth: web.Application, mocker: MockerFix run_application_mock = mocker.patch("aiohttp.web.run_app") run_server(application_with_auth) - run_application_mock.assert_called_once_with(application_with_auth, host="127.0.0.1", port=port, - handle_signals=False, access_log=pytest.helpers.anyvar(int)) + run_application_mock.assert_called_once_with( + application_with_auth, host="127.0.0.1", port=port, handle_signals=False, + access_log=pytest.helpers.anyvar(int), access_log_class=FilteredAccessLogger + ) def test_run_with_debug(application_with_debug: web.Application, mocker: MockerFixture) -> None: @@ -74,5 +79,7 @@ def test_run_with_debug(application_with_debug: web.Application, mocker: MockerF run_application_mock = mocker.patch("aiohttp.web.run_app") run_server(application_with_debug) - run_application_mock.assert_called_once_with(application_with_debug, host="127.0.0.1", port=port, - handle_signals=False, access_log=pytest.helpers.anyvar(int)) + run_application_mock.assert_called_once_with( + application_with_debug, host="127.0.0.1", port=port, handle_signals=False, + access_log=pytest.helpers.anyvar(int), access_log_class=FilteredAccessLogger + )