From 5f79cbc34bbcd8063438872f90c4db6699f4d29c Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Mon, 26 Aug 2024 22:13:18 +0300 Subject: [PATCH] feat: implement audit log tables and methods (#129) --- .../core/database/migrations/m014_auditlog.py | 38 +++++ .../core/database/operations/__init__.py | 1 + .../operations/dependencies_operations.py | 2 +- .../database/operations/event_operations.py | 104 ++++++++++++ src/ahriman/core/database/sqlite.py | 3 +- src/ahriman/core/status/client.py | 32 ++++ src/ahriman/core/status/local_client.py | 26 +++ src/ahriman/core/status/watcher.py | 5 + src/ahriman/core/status/web_client.py | 61 ++++++- src/ahriman/models/event.py | 89 ++++++++++ src/ahriman/web/schemas/__init__.py | 2 + src/ahriman/web/schemas/event_schema.py | 47 ++++++ .../web/schemas/event_search_schema.py | 38 +++++ src/ahriman/web/views/v1/auditlog/__init__.py | 19 +++ src/ahriman/web/views/v1/auditlog/events.py | 104 ++++++++++++ .../database/migrations/test_m014_auditlog.py | 8 + .../operations/test_event_operations.py | 40 +++++ tests/ahriman/core/status/test_client.py | 17 ++ .../ahriman/core/status/test_local_client.py | 21 +++ tests/ahriman/core/status/test_web_client.py | 158 ++++++++++++++++-- tests/ahriman/models/test_event.py | 17 ++ .../ahriman/web/schemas/test_event_schema.py | 1 + .../web/schemas/test_event_search_schema.py | 1 + .../auditlog/test_view_v1_auditlog_events.py | 104 ++++++++++++ 24 files changed, 922 insertions(+), 16 deletions(-) create mode 100644 src/ahriman/core/database/migrations/m014_auditlog.py create mode 100644 src/ahriman/core/database/operations/event_operations.py create mode 100644 src/ahriman/models/event.py create mode 100644 src/ahriman/web/schemas/event_schema.py create mode 100644 src/ahriman/web/schemas/event_search_schema.py create mode 100644 src/ahriman/web/views/v1/auditlog/__init__.py create mode 100644 src/ahriman/web/views/v1/auditlog/events.py create mode 100644 tests/ahriman/core/database/migrations/test_m014_auditlog.py create mode 100644 tests/ahriman/core/database/operations/test_event_operations.py create mode 100644 tests/ahriman/models/test_event.py create mode 100644 tests/ahriman/web/schemas/test_event_schema.py create mode 100644 tests/ahriman/web/schemas/test_event_search_schema.py create mode 100644 tests/ahriman/web/views/v1/auditlog/test_view_v1_auditlog_events.py diff --git a/src/ahriman/core/database/migrations/m014_auditlog.py b/src/ahriman/core/database/migrations/m014_auditlog.py new file mode 100644 index 00000000..94463573 --- /dev/null +++ b/src/ahriman/core/database/migrations/m014_auditlog.py @@ -0,0 +1,38 @@ +# +# Copyright (c) 2021-2024 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 . +# +__all__ = ["steps"] + + +steps = [ + """ + create table auditlog ( + created integer not null, + repository text not null, + event text not null, + object_id text not null, + message text, + data json + ) + """, + """ + create index auditlog_created_repository_event_object_id + on auditlog (created, repository, event, object_id) + """, +] diff --git a/src/ahriman/core/database/operations/__init__.py b/src/ahriman/core/database/operations/__init__.py index d89ef710..acabbf6d 100644 --- a/src/ahriman/core/database/operations/__init__.py +++ b/src/ahriman/core/database/operations/__init__.py @@ -21,6 +21,7 @@ from ahriman.core.database.operations.auth_operations import AuthOperations from ahriman.core.database.operations.build_operations import BuildOperations from ahriman.core.database.operations.changes_operations import ChangesOperations from ahriman.core.database.operations.dependencies_operations import DependenciesOperations +from ahriman.core.database.operations.event_operations import EventOperations from ahriman.core.database.operations.logs_operations import LogsOperations from ahriman.core.database.operations.package_operations import PackageOperations from ahriman.core.database.operations.patch_operations import PatchOperations diff --git a/src/ahriman/core/database/operations/dependencies_operations.py b/src/ahriman/core/database/operations/dependencies_operations.py index 1f2a7780..c485eca9 100644 --- a/src/ahriman/core/database/operations/dependencies_operations.py +++ b/src/ahriman/core/database/operations/dependencies_operations.py @@ -39,7 +39,7 @@ class DependenciesOperations(Operations): repository_id(RepositoryId, optional): repository unique identifier override (Default value = None) Returns: - Dependencies: changes for the package base if available + Dependencies: dependencies for the package base if available """ repository_id = repository_id or self._repository_id diff --git a/src/ahriman/core/database/operations/event_operations.py b/src/ahriman/core/database/operations/event_operations.py new file mode 100644 index 00000000..8d51899d --- /dev/null +++ b/src/ahriman/core/database/operations/event_operations.py @@ -0,0 +1,104 @@ +# +# Copyright (c) 2021-2024 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 . +# +from sqlite3 import Connection + +from ahriman.core.database.operations.operations import Operations +from ahriman.models.event import Event, EventType +from ahriman.models.repository_id import RepositoryId + + +class EventOperations(Operations): + """ + operations for audit log table + """ + + def event_get(self, event: str | EventType | None = None, object_id: str | None = None, + limit: int = -1, offset: int = 0, repository_id: RepositoryId | None = None) -> list[Event]: + """ + get list of events with filters applied + + Args: + event(str | EventType | None, optional): filter by event type (Default value = None) + object_id(str | None, optional): filter by event object (Default value = None) + limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1) + offset(int, optional): records offset (Default value = 0) + repository_id(RepositoryId, optional): repository unique identifier override (Default value = None) + + Returns: + list[Event]: list of audit log events + """ + repository_id = repository_id or self._repository_id + + def run(connection: Connection) -> list[Event]: + return [ + Event( + event=row["event"], + object_id=row["object_id"], + message=row["message"], + data=row["data"], + created=row["created"], + ) for row in connection.execute( + """ + select created, event, object_id, message, data from auditlog + where (:event is null or event = :event) + and (:object_id is null or object_id = :object_id) + and repository = :repository + order by created limit :limit offset :offset + """, + { + "event": event, + "object_id": object_id, + "repository": repository_id.id, + "limit": limit, + "offset": offset, + } + ) + ] + + return self.with_connection(run) + + def event_insert(self, event: Event, repository_id: RepositoryId | None = None) -> None: + """ + insert audit log event + + Args: + event(Event): event to insert + repository_id(RepositoryId, optional): repository unique identifier override (Default value = None) + """ + repository_id = repository_id or self._repository_id + + def run(connection: Connection) -> None: + connection.execute( + """ + insert into auditlog + (created, repository, event, object_id, message, data) + values + (:created, :repository, :event, :object_id, :message, :data) + """, + { + "created": event.created, + "repository": repository_id.id, + "event": event.event, + "object_id": event.object_id, + "message": event.message, + "data": event.data, + }) + + return self.with_connection(run, commit=True) diff --git a/src/ahriman/core/database/sqlite.py b/src/ahriman/core/database/sqlite.py index e6d26d7e..c659a10a 100644 --- a/src/ahriman/core/database/sqlite.py +++ b/src/ahriman/core/database/sqlite.py @@ -26,7 +26,7 @@ from typing import Self from ahriman.core.configuration import Configuration from ahriman.core.database.migrations import Migrations from ahriman.core.database.operations import AuthOperations, BuildOperations, ChangesOperations, \ - DependenciesOperations, LogsOperations, PackageOperations, PatchOperations + DependenciesOperations, EventOperations, LogsOperations, PackageOperations, PatchOperations # pylint: disable=too-many-ancestors @@ -35,6 +35,7 @@ class SQLite( BuildOperations, ChangesOperations, DependenciesOperations, + EventOperations, LogsOperations, PackageOperations, PatchOperations): diff --git a/src/ahriman/core/status/client.py b/src/ahriman/core/status/client.py index c8a3398e..67a6401d 100644 --- a/src/ahriman/core/status/client.py +++ b/src/ahriman/core/status/client.py @@ -25,6 +25,7 @@ from ahriman.core.database import SQLite from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.changes import Changes from ahriman.models.dependencies import Dependencies +from ahriman.models.event import Event, EventType from ahriman.models.internal_status import InternalStatus from ahriman.models.log_record_id import LogRecordId from ahriman.models.package import Package @@ -79,6 +80,37 @@ class Client: return make_local_client() + def event_add(self, event: Event) -> None: + """ + create new event + + Args: + event(Event): audit log event + + Raises: + NotImplementedError: not implemented method + """ + raise NotImplementedError + + def event_get(self, event: str | EventType | None, object_id: str | None, + limit: int = -1, offset: int = 0) -> list[Event]: + """ + retrieve list of events + + Args: + event(str | EventType | None): filter by event type + object_id(str | None): filter by event object + limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1) + offset(int, optional): records offset (Default value = 0) + + Returns: + list[Event]: list of audit log events + + Raises: + NotImplementedError: not implemented method + """ + raise NotImplementedError + def package_changes_get(self, package_base: str) -> Changes: """ get package changes diff --git a/src/ahriman/core/status/local_client.py b/src/ahriman/core/status/local_client.py index e0900805..3229f0e2 100644 --- a/src/ahriman/core/status/local_client.py +++ b/src/ahriman/core/status/local_client.py @@ -22,6 +22,7 @@ from ahriman.core.status import Client from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.changes import Changes from ahriman.models.dependencies import Dependencies +from ahriman.models.event import Event, EventType from ahriman.models.log_record_id import LogRecordId from ahriman.models.package import Package from ahriman.models.pkgbuild_patch import PkgbuildPatch @@ -48,6 +49,31 @@ class LocalClient(Client): self.database = database self.repository_id = repository_id + def event_add(self, event: Event) -> None: + """ + create new event + + Args: + event(Event): audit log event + """ + self.database.event_insert(event, self.repository_id) + + def event_get(self, event: str | EventType | None, object_id: str | None, + limit: int = -1, offset: int = 0) -> list[Event]: + """ + retrieve list of events + + Args: + event(str | EventType | None): filter by event type + object_id(str | None): filter by event object + limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1) + offset(int, optional): records offset (Default value = 0) + + Returns: + list[Event]: list of audit log events + """ + return self.database.event_get(event, object_id, limit, offset, self.repository_id) + def package_changes_get(self, package_base: str) -> Changes: """ get package changes diff --git a/src/ahriman/core/status/watcher.py b/src/ahriman/core/status/watcher.py index a3d704ff..e3389a21 100644 --- a/src/ahriman/core/status/watcher.py +++ b/src/ahriman/core/status/watcher.py @@ -27,6 +27,7 @@ from ahriman.core.status import Client from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.changes import Changes from ahriman.models.dependencies import Dependencies +from ahriman.models.event import Event, EventType from ahriman.models.log_record_id import LogRecordId from ahriman.models.package import Package from ahriman.models.pkgbuild_patch import PkgbuildPatch @@ -68,6 +69,10 @@ class Watcher(LazyLogging): with self._lock: return list(self._known.values()) + event_add: Callable[[Event], None] + + event_get: Callable[[str | EventType | None, str | None, int, int], list[Event]] + def load(self) -> None: """ load packages from local database diff --git a/src/ahriman/core/status/web_client.py b/src/ahriman/core/status/web_client.py index 24b4ccd3..d80b47ae 100644 --- a/src/ahriman/core/status/web_client.py +++ b/src/ahriman/core/status/web_client.py @@ -27,6 +27,7 @@ from ahriman.core.status import Client from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.changes import Changes from ahriman.models.dependencies import Dependencies +from ahriman.models.event import Event, EventType from ahriman.models.internal_status import InternalStatus from ahriman.models.log_record_id import LogRecordId from ahriman.models.package import Package @@ -109,6 +110,15 @@ class WebClient(Client, SyncAhrimanClient): """ return f"{self.address}/api/v1/packages/{urlencode(package_base)}/dependencies" + def _events_url(self) -> str: + """ + get url for the events api + + Returns: + str: full url for web service for events + """ + return f"{self.address}/api/v1/events" + def _logs_url(self, package_base: str) -> str: """ get url for the logs api @@ -157,6 +167,44 @@ class WebClient(Client, SyncAhrimanClient): """ return f"{self.address}/api/v1/status" + def event_add(self, event: Event) -> None: + """ + create new event + + Args: + event(Event): audit log event + """ + with contextlib.suppress(Exception): + self.make_request("POST", self._events_url(), params=self.repository_id.query(), json=event.view()) + + def event_get(self, event: str | EventType | None, object_id: str | None, + limit: int = -1, offset: int = 0) -> list[Event]: + """ + retrieve list of events + + Args: + event(str | EventType | None): filter by event type + object_id(str | None): filter by event object + limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1) + offset(int, optional): records offset (Default value = 0) + + Returns: + list[Event]: list of audit log events + """ + query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))] + if event is not None: + query.append(("event", str(event))) + if object_id is not None: + query.append(("object_id", object_id)) + + with contextlib.suppress(Exception): + response = self.make_request("GET", self._events_url(), params=query) + response_json = response.json() + + return [Event.from_json(event) for event in response_json] + + return [] + def package_changes_get(self, package_base: str) -> Changes: """ get package changes @@ -274,8 +322,9 @@ class WebClient(Client, SyncAhrimanClient): Returns: list[tuple[float, str]]: package logs """ + query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))] + with contextlib.suppress(Exception): - query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))] response = self.make_request("GET", self._logs_url(package_base), params=query) response_json = response.json() @@ -291,10 +340,11 @@ class WebClient(Client, SyncAhrimanClient): package_base(str): package base version(str | None): package version to remove logs. If None set, all logs will be removed """ + query = self.repository_id.query() + if version is not None: + query += [("version", version)] + with contextlib.suppress(Exception): - query = self.repository_id.query() - if version is not None: - query += [("version", version)] self.make_request("DELETE", self._logs_url(package_base), params=query) def package_patches_get(self, package_base: str, variable: str | None) -> list[PkgbuildPatch]: @@ -361,6 +411,7 @@ class WebClient(Client, SyncAhrimanClient): NotImplementedError: not implemented method """ payload = {"status": status.value} + with contextlib.suppress(Exception): self.make_request("POST", self._package_url(package_base), params=self.repository_id.query(), json=payload) @@ -380,6 +431,7 @@ class WebClient(Client, SyncAhrimanClient): "status": status.value, "package": package.view(), } + with contextlib.suppress(Exception): self.make_request("POST", self._package_url(package.base), params=self.repository_id.query(), json=payload) @@ -407,5 +459,6 @@ class WebClient(Client, SyncAhrimanClient): status(BuildStatusEnum): current ahriman status """ payload = {"status": status.value} + with contextlib.suppress(Exception): self.make_request("POST", self._status_url(), params=self.repository_id.query(), json=payload) diff --git a/src/ahriman/models/event.py b/src/ahriman/models/event.py new file mode 100644 index 00000000..7b5e970b --- /dev/null +++ b/src/ahriman/models/event.py @@ -0,0 +1,89 @@ +# +# Copyright (c) 2021-2024 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 . +# +from dataclasses import dataclass, field, fields +from enum import StrEnum +from typing import Any, Self + +from ahriman.core.utils import dataclass_view, filter_json, utcnow + + +class EventType(StrEnum): + """ + predefined event types + + Attributes: + PackageRemoved(EventType): (class attribute) package has been removed + PackageUpdated(EventType): (class attribute) package has been updated + """ + + PackageRemoved = "package-removed" + PackageUpdated = "package-updated" + + +@dataclass(frozen=True) +class Event: + """ + audit log event + + Attributes: + created(int): event timestamp + data(dict[str, Any]): event metadata + event(str | EventType): event type + message(str | None): event message if available + object_id(str): object identifier + """ + + event: str | EventType + object_id: str + message: str | None = None + data: dict[str, Any] = field(default_factory=dict) + created: int = field(default_factory=lambda: int(utcnow().timestamp())) + + def __post_init__(self) -> None: + """ + replace null data to empty dictionary + """ + if self.event in EventType: + object.__setattr__(self, "event", EventType(self.event)) + object.__setattr__(self, "data", self.data or {}) + + @classmethod + def from_json(cls, dump: dict[str, Any]) -> Self: + """ + construct event from the json dump + + Args: + dump(dict[str, Any]): json dump body + + Returns: + Self: dependencies object + """ + # filter to only known fields + known_fields = [pair.name for pair in fields(cls)] + return cls(**filter_json(dump, known_fields)) + + def view(self) -> dict[str, Any]: + """ + generate json event view + + Returns: + dict[str, Any]: json-friendly dictionary + """ + return dataclass_view(self) diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py index aac967f8..8a2e8108 100644 --- a/src/ahriman/web/schemas/__init__.py +++ b/src/ahriman/web/schemas/__init__.py @@ -24,6 +24,8 @@ from ahriman.web.schemas.changes_schema import ChangesSchema from ahriman.web.schemas.counters_schema import CountersSchema from ahriman.web.schemas.dependencies_schema import DependenciesSchema from ahriman.web.schemas.error_schema import ErrorSchema +from ahriman.web.schemas.event_schema import EventSchema +from ahriman.web.schemas.event_search_schema import EventSearchSchema from ahriman.web.schemas.file_schema import FileSchema from ahriman.web.schemas.info_schema import InfoSchema from ahriman.web.schemas.internal_status_schema import InternalStatusSchema diff --git a/src/ahriman/web/schemas/event_schema.py b/src/ahriman/web/schemas/event_schema.py new file mode 100644 index 00000000..d9647d0c --- /dev/null +++ b/src/ahriman/web/schemas/event_schema.py @@ -0,0 +1,47 @@ +# +# Copyright (c) 2021-2024 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 . +# +from marshmallow import Schema, fields + +from ahriman.models.event import EventType + + +class EventSchema(Schema): + """ + request/response event schema + """ + + created = fields.Integer(required=True, metadata={ + "description": "Event creation timestamp", + "example": 1680537091, + }) + event = fields.String(required=True, metadata={ + "description": "Event type", + "example": EventType.PackageUpdated, + }) + object_id = fields.String(required=True, metadata={ + "description": "Event object identifier", + "example": "ahriman", + }) + message = fields.String(metadata={ + "description": "Event message if available", + }) + data = fields.Dict(keys=fields.String(), metadata={ + "description": "Event metadata if available", + }) diff --git a/src/ahriman/web/schemas/event_search_schema.py b/src/ahriman/web/schemas/event_search_schema.py new file mode 100644 index 00000000..3fd65c30 --- /dev/null +++ b/src/ahriman/web/schemas/event_search_schema.py @@ -0,0 +1,38 @@ +# +# Copyright (c) 2021-2024 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 . +# +from marshmallow import fields + +from ahriman.models.event import EventType +from ahriman.web.schemas.pagination_schema import PaginationSchema + + +class EventSearchSchema(PaginationSchema): + """ + request event search schema + """ + + event = fields.String(metadata={ + "description": "Event type", + "example": EventType.PackageUpdated, + }) + object_id = fields.String(metadata={ + "description": "Event object identifier", + "example": "ahriman", + }) diff --git a/src/ahriman/web/views/v1/auditlog/__init__.py b/src/ahriman/web/views/v1/auditlog/__init__.py new file mode 100644 index 00000000..78e01321 --- /dev/null +++ b/src/ahriman/web/views/v1/auditlog/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright (c) 2021-2024 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 . +# diff --git a/src/ahriman/web/views/v1/auditlog/events.py b/src/ahriman/web/views/v1/auditlog/events.py new file mode 100644 index 00000000..21847aa5 --- /dev/null +++ b/src/ahriman/web/views/v1/auditlog/events.py @@ -0,0 +1,104 @@ +# +# Copyright (c) 2021-2024 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 aiohttp_apispec # type: ignore[import-untyped] + +from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response + +from ahriman.models.event import Event +from ahriman.models.user_access import UserAccess +from ahriman.web.schemas import AuthSchema, ErrorSchema, EventSchema, EventSearchSchema +from ahriman.web.views.base import BaseView + + +class EventsView(BaseView): + """ + audit log view + + Attributes: + GET_PERMISSION(UserAccess): (class attribute) get permissions of self + POST_PERMISSION(UserAccess): (class attribute) post permissions of self + """ + + GET_PERMISSION = POST_PERMISSION = UserAccess.Full + ROUTES = ["/api/v1/events"] + + @aiohttp_apispec.docs( + tags=["Audit log"], + summary="Get events", + description="Retrieve events from audit log", + responses={ + 200: {"description": "Success response", "schema": EventSchema(many=True)}, + 400: {"description": "Bad data is supplied", "schema": ErrorSchema}, + 401: {"description": "Authorization required", "schema": ErrorSchema}, + 403: {"description": "Access is forbidden", "schema": ErrorSchema}, + 500: {"description": "Internal server error", "schema": ErrorSchema}, + }, + security=[{"token": [GET_PERMISSION]}], + ) + @aiohttp_apispec.cookies_schema(AuthSchema) + @aiohttp_apispec.querystring_schema(EventSearchSchema) + async def get(self) -> Response: + """ + get events list + + Returns: + Response: 200 with workers list on success + """ + limit, offset = self.page() + event = self.request.query.get("event") or None + object_id = self.request.query.get("object_id") or None + + events = self.service().event_get(event, object_id, limit, offset) + response = [event.view() for event in events] + + return json_response(response) + + @aiohttp_apispec.docs( + tags=["Audit log"], + summary="Create event", + description="Add new event to the audit log", + responses={ + 204: {"description": "Success response"}, + 400: {"description": "Bad data is supplied", "schema": ErrorSchema}, + 401: {"description": "Authorization required", "schema": ErrorSchema}, + 403: {"description": "Access is forbidden", "schema": ErrorSchema}, + 500: {"description": "Internal server error", "schema": ErrorSchema}, + }, + security=[{"token": [POST_PERMISSION]}], + ) + @aiohttp_apispec.cookies_schema(AuthSchema) + @aiohttp_apispec.json_schema(EventSchema) + async def post(self) -> None: + """ + add new audit log event + + Raises: + HTTPBadRequest: if bad data is supplied + HTTPNoContent: in case of success response + """ + try: + data = await self.request.json() + event = Event.from_json(data) + except Exception as ex: + raise HTTPBadRequest(reason=str(ex)) + + self.service().event_add(event) + + raise HTTPNoContent diff --git a/tests/ahriman/core/database/migrations/test_m014_auditlog.py b/tests/ahriman/core/database/migrations/test_m014_auditlog.py new file mode 100644 index 00000000..63bca2da --- /dev/null +++ b/tests/ahriman/core/database/migrations/test_m014_auditlog.py @@ -0,0 +1,8 @@ +from ahriman.core.database.migrations.m014_auditlog import steps + + +def test_migration_auditlog() -> None: + """ + migration must not be empty + """ + assert steps diff --git a/tests/ahriman/core/database/operations/test_event_operations.py b/tests/ahriman/core/database/operations/test_event_operations.py new file mode 100644 index 00000000..4a66d392 --- /dev/null +++ b/tests/ahriman/core/database/operations/test_event_operations.py @@ -0,0 +1,40 @@ +from ahriman.core.database import SQLite +from ahriman.models.event import Event, EventType +from ahriman.models.package import Package +from ahriman.models.repository_id import RepositoryId + + +def test_event_insert_get(database: SQLite, package_ahriman: Package) -> None: + """ + must insert and get event + """ + event = Event(EventType.PackageUpdated, package_ahriman.base, "Updated", {"key": "value"}) + database.event_insert(event) + assert database.event_get() == [event] + + event2 = Event("event", "object") + database.event_insert(event2, RepositoryId("i686", database._repository_id.name)) + assert database.event_get() == [event] + assert database.event_get(repository_id=RepositoryId("i686", database._repository_id.name)) == [event2] + + +def test_event_insert_get_filter(database: SQLite) -> None: + """ + must insert and get events with filter + """ + database.event_insert(Event("event 1", "object 1", created=1)) + database.event_insert(Event("event 2", "object 2")) + database.event_insert(Event(EventType.PackageUpdated, "package")) + + assert database.event_get(event="event 1") == [Event("event 1", "object 1", created=1)] + assert database.event_get(object_id="object 1") == [Event("event 1", "object 1", created=1)] + assert all(event.event == EventType.PackageUpdated for event in database.event_get(event=EventType.PackageUpdated)) + + +def test_event_insert_get_pagination(database: SQLite) -> None: + """ + must insert and get events with pagination + """ + database.event_insert(Event("1", "1")) + database.event_insert(Event("2", "2")) + assert all(event.event == "2" for event in database.event_get(limit=1, offset=1)) diff --git a/tests/ahriman/core/status/test_client.py b/tests/ahriman/core/status/test_client.py index 51b151f3..477c4553 100644 --- a/tests/ahriman/core/status/test_client.py +++ b/tests/ahriman/core/status/test_client.py @@ -11,6 +11,7 @@ from ahriman.core.status.web_client import WebClient from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.changes import Changes from ahriman.models.dependencies import Dependencies +from ahriman.models.event import Event from ahriman.models.internal_status import InternalStatus from ahriman.models.log_record_id import LogRecordId from ahriman.models.package import Package @@ -94,6 +95,22 @@ def test_load_web_client_from_legacy_unix_socket(configuration: Configuration, d assert isinstance(Client.load(repository_id, configuration, database, report=True), WebClient) +def test_event_add(client: Client) -> None: + """ + must raise not implemented on event insertion + """ + with pytest.raises(NotImplementedError): + client.event_add(Event("", "")) + + +def test_event_get(client: Client) -> None: + """ + must raise not implemented on events request + """ + with pytest.raises(NotImplementedError): + client.event_get(None, None) + + def test_package_changes_get(client: Client, package_ahriman: Package) -> None: """ must raise not implemented on package changes request diff --git a/tests/ahriman/core/status/test_local_client.py b/tests/ahriman/core/status/test_local_client.py index 8e3db4f4..c43f8956 100644 --- a/tests/ahriman/core/status/test_local_client.py +++ b/tests/ahriman/core/status/test_local_client.py @@ -7,11 +7,32 @@ from ahriman.core.status.local_client import LocalClient from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.changes import Changes from ahriman.models.dependencies import Dependencies +from ahriman.models.event import Event, EventType from ahriman.models.log_record_id import LogRecordId from ahriman.models.package import Package from ahriman.models.pkgbuild_patch import PkgbuildPatch +def test_event_add(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must add new event + """ + event_mock = mocker.patch("ahriman.core.database.SQLite.event_insert") + event = Event(EventType.PackageUpdated, package_ahriman.base) + + local_client.event_add(event) + event_mock.assert_called_once_with(event, local_client.repository_id) + + +def test_event_get(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must retrieve events + """ + event_mock = mocker.patch("ahriman.core.database.SQLite.event_get") + local_client.event_get(EventType.PackageUpdated, package_ahriman.base, 1, 2) + event_mock.assert_called_once_with(EventType.PackageUpdated, package_ahriman.base, 1, 2, local_client.repository_id) + + def test_package_changes_get(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must retrieve package changes diff --git a/tests/ahriman/core/status/test_web_client.py b/tests/ahriman/core/status/test_web_client.py index 3b75fb95..5bf9843e 100644 --- a/tests/ahriman/core/status/test_web_client.py +++ b/tests/ahriman/core/status/test_web_client.py @@ -10,6 +10,7 @@ from ahriman.core.status.web_client import WebClient from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.changes import Changes from ahriman.models.dependencies import Dependencies +from ahriman.models.event import Event, EventType from ahriman.models.internal_status import InternalStatus from ahriman.models.log_record_id import LogRecordId from ahriman.models.package import Package @@ -54,18 +55,12 @@ def test_dependencies_url(web_client: WebClient, package_ahriman: Package) -> No "/api/v1/packages/some%2Fpackage%25name/dependencies") -def test__patches_url(web_client: WebClient, package_ahriman: Package) -> None: +def test_event_url(web_client: WebClient) -> None: """ - must generate changes url correctly + must generate audit log url correctly """ - assert web_client._patches_url(package_ahriman.base).startswith(web_client.address) - assert web_client._patches_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}/patches") - assert web_client._patches_url("some/package%name").endswith("/api/v1/packages/some%2Fpackage%25name/patches") - - assert web_client._patches_url(package_ahriman.base, "var").endswith( - f"/api/v1/packages/{package_ahriman.base}/patches/var") - assert web_client._patches_url(package_ahriman.base, "some/variable%name").endswith( - f"/api/v1/packages/{package_ahriman.base}/patches/some%2Fvariable%25name") + assert web_client._events_url().startswith(web_client.address) + assert web_client._events_url().endswith("/api/v1/events") def test_logs_url(web_client: WebClient, package_ahriman: Package) -> None: @@ -89,6 +84,20 @@ def test_package_url(web_client: WebClient, package_ahriman: Package) -> None: assert web_client._package_url("some/package%name").endswith("/api/v1/packages/some%2Fpackage%25name") +def test_patches_url(web_client: WebClient, package_ahriman: Package) -> None: + """ + must generate changes url correctly + """ + assert web_client._patches_url(package_ahriman.base).startswith(web_client.address) + assert web_client._patches_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}/patches") + assert web_client._patches_url("some/package%name").endswith("/api/v1/packages/some%2Fpackage%25name/patches") + + assert web_client._patches_url(package_ahriman.base, "var").endswith( + f"/api/v1/packages/{package_ahriman.base}/patches/var") + assert web_client._patches_url(package_ahriman.base, "some/variable%name").endswith( + f"/api/v1/packages/{package_ahriman.base}/patches/some%2Fvariable%25name") + + def test_status_url(web_client: WebClient) -> None: """ must generate package status url correctly @@ -97,6 +106,135 @@ def test_status_url(web_client: WebClient) -> None: assert web_client._status_url().endswith("/api/v1/status") +def test_event_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must create event + """ + event = Event(EventType.PackageUpdated, package_ahriman.base) + requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request") + + web_client.event_add(event) + requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True), + params=web_client.repository_id.query(), json=event.view()) + + +def test_event_add_failed(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress any exception happened during events creation + """ + mocker.patch("requests.Session.request", side_effect=Exception()) + web_client.event_add(Event("", "")) + + +def test_event_add_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress HTTP exception happened during events creation + """ + mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) + web_client.event_add(Event("", "")) + + +def test_event_add_failed_suppress(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress any exception happened during events creaton and don't log + """ + web_client.suppress_errors = True + mocker.patch("requests.Session.request", side_effect=Exception()) + logging_mock = mocker.patch("logging.exception") + + web_client.event_add(Event("", "")) + logging_mock.assert_not_called() + + +def test_event_add_failed_http_error_suppress(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress HTTP exception happened during events creation and don't log + """ + web_client.suppress_errors = True + mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) + logging_mock = mocker.patch("logging.exception") + + web_client.event_add(Event("", "")) + logging_mock.assert_not_called() + + +def test_event_get(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must get events + """ + event = Event(EventType.PackageUpdated, package_ahriman.base) + response_obj = requests.Response() + response_obj._content = json.dumps([event.view()]).encode("utf8") + response_obj.status_code = 200 + + requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request", return_value=response_obj) + + result = web_client.event_get(None, None) + requests_mock.assert_called_once_with("GET", pytest.helpers.anyvar(str, True), + params=web_client.repository_id.query() + [("limit", "-1"), ("offset", "0")]) + assert result == [event] + + +def test_event_get_filter(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must get events with filter + """ + response_obj = requests.Response() + response_obj._content = json.dumps(Event("", "").view()).encode("utf8") + response_obj.status_code = 200 + + requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request", return_value=response_obj) + + web_client.event_get("event", "object", 1, 2) + requests_mock.assert_called_once_with("GET", pytest.helpers.anyvar(str, True), + params=web_client.repository_id.query() + [ + ("limit", "1"), + ("offset", "2"), + ("event", "event"), + ("object_id", "object"), + ]) + + +def test_event_get_failed(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress any exception happened during events fetch + """ + mocker.patch("requests.Session.request", side_effect=Exception()) + web_client.event_get(None, None) + + +def test_event_get_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress HTTP exception happened during events fetch + """ + mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) + web_client.event_get(None, None) + + +def test_event_get_failed_suppress(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress any exception happened during events fetch and don't log + """ + web_client.suppress_errors = True + mocker.patch("requests.Session.request", side_effect=Exception()) + logging_mock = mocker.patch("logging.exception") + + web_client.event_get(None, None) + logging_mock.assert_not_called() + + +def test_event_get_failed_http_error_suppress(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress HTTP exception happened during events fetch and don't log + """ + web_client.suppress_errors = True + mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) + logging_mock = mocker.patch("logging.exception") + + web_client.event_get(None, None) + logging_mock.assert_not_called() + + def test_package_changes_get(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must get changes diff --git a/tests/ahriman/models/test_event.py b/tests/ahriman/models/test_event.py new file mode 100644 index 00000000..e9f4969c --- /dev/null +++ b/tests/ahriman/models/test_event.py @@ -0,0 +1,17 @@ +from ahriman.models.event import Event, EventType + + +def test_post_init() -> None: + """ + must remove replace empty dictionary + """ + assert Event("", "").data == {} + assert isinstance(Event(str(EventType.PackageUpdated), "").event, EventType) + + +def test_from_json_view() -> None: + """ + must construct and serialize event to json + """ + event = Event("event", "object", "message", {"key": "value"}) + assert Event.from_json(event.view()) == event diff --git a/tests/ahriman/web/schemas/test_event_schema.py b/tests/ahriman/web/schemas/test_event_schema.py new file mode 100644 index 00000000..1982fb6b --- /dev/null +++ b/tests/ahriman/web/schemas/test_event_schema.py @@ -0,0 +1 @@ +# schema testing goes in view class tests diff --git a/tests/ahriman/web/schemas/test_event_search_schema.py b/tests/ahriman/web/schemas/test_event_search_schema.py new file mode 100644 index 00000000..1982fb6b --- /dev/null +++ b/tests/ahriman/web/schemas/test_event_search_schema.py @@ -0,0 +1 @@ +# schema testing goes in view class tests diff --git a/tests/ahriman/web/views/v1/auditlog/test_view_v1_auditlog_events.py b/tests/ahriman/web/views/v1/auditlog/test_view_v1_auditlog_events.py new file mode 100644 index 00000000..cf534ee6 --- /dev/null +++ b/tests/ahriman/web/views/v1/auditlog/test_view_v1_auditlog_events.py @@ -0,0 +1,104 @@ +import pytest + +from aiohttp.test_utils import TestClient + +from ahriman.models.event import Event +from ahriman.models.user_access import UserAccess +from ahriman.web.views.v1.auditlog.events import EventsView + + +async def test_get_permission() -> None: + """ + must return correct permission for the request + """ + for method in ("GET", "POST"): + request = pytest.helpers.request("", "", method) + assert await EventsView.get_permission(request) == UserAccess.Full + + +def test_routes() -> None: + """ + must return correct routes + """ + assert EventsView.ROUTES == ["/api/v1/events"] + + +async def test_get(client: TestClient) -> None: + """ + must return all events + """ + event1 = Event("event1", "object1", "message", {"key": "value"}) + event2 = Event("event2", "object2") + await client.post("/api/v1/events", json=event1.view()) + await client.post("/api/v1/events", json=event2.view()) + response_schema = pytest.helpers.schema_response(EventsView.get) + + response = await client.get("/api/v1/events") + assert response.ok + json = await response.json() + assert not response_schema.validate(json, many=True) + + events = [Event.from_json(event) for event in json] + assert events == [event1, event2] + + +async def test_get_with_pagination(client: TestClient) -> None: + """ + must get events with pagination + """ + event1 = Event("event1", "object1", "message", {"key": "value"}) + event2 = Event("event2", "object2") + await client.post("/api/v1/events", json=event1.view()) + await client.post("/api/v1/events", json=event2.view()) + request_schema = pytest.helpers.schema_request(EventsView.get, location="querystring") + response_schema = pytest.helpers.schema_response(EventsView.get) + + payload = {"limit": 1, "offset": 1} + assert not request_schema.validate(payload) + response = await client.get("/api/v1/events", params=payload) + assert response.status == 200 + + json = await response.json() + assert not response_schema.validate(json, many=True) + + assert [Event.from_json(event) for event in json] == [event2] + + +async def test_get_bad_request(client: TestClient) -> None: + """ + must return bad request for invalid query parameters + """ + response_schema = pytest.helpers.schema_response(EventsView.get, code=400) + + response = await client.get("/api/v1/events", params={"limit": "limit"}) + assert response.status == 400 + assert not response_schema.validate(await response.json()) + + response = await client.get("/api/v1/events", params={"offset": "offset"}) + assert response.status == 400 + assert not response_schema.validate(await response.json()) + + +async def test_post(client: TestClient) -> None: + """ + must create event + """ + event = Event("event1", "object1", "message", {"key": "value"}) + request_schema = pytest.helpers.schema_request(EventsView.post) + + payload = event.view() + assert not request_schema.validate(payload) + + response = await client.post("/api/v1/events", json=payload) + assert response.status == 204 + + +async def test_post_exception(client: TestClient) -> None: + """ + must raise exception on invalid payload + """ + response_schema = pytest.helpers.schema_response(EventsView.post, code=400) + + response = await client.post("/api/v1/events", json={}) + assert response.status == 400 + assert not response_schema.validate(await response.json())