Compare commits

...

3 Commits

59 changed files with 994 additions and 84 deletions

View File

@ -59,7 +59,7 @@ class Handler:
repository_id(RepositoryId): repository unique identifier
Returns:
bool: True on success, False otherwise
bool: ``True`` on success, ``False`` otherwise
"""
try:
configuration = Configuration.from_path(args.configuration, repository_id)
@ -129,7 +129,7 @@ class Handler:
check condition and flag and raise ExitCode exception in case if it is enabled and condition match
Args:
enabled(bool): if False no check will be performed
enabled(bool): if ``False`` no check will be performed
predicate(bool): indicates condition on which exception should be thrown
Raises:

View File

@ -97,7 +97,7 @@ class Lock(LazyLogging):
fd(int): file descriptor:
Returns:
bool: True in case if file is locked and False otherwise
bool: ``True`` in case if file is locked and ``False`` otherwise
"""
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
@ -119,7 +119,7 @@ class Lock(LazyLogging):
watch until lock disappear
Returns:
bool: True in case if file is locked and False otherwise
bool: ``True`` in case if file is locked and ``False`` otherwise
"""
# there are reasons why we are not using inotify here. First of all, if we would use it, it would bring to
# race conditions because multiple processes will be notified at the same time. Secondly, it is good library,
@ -223,7 +223,7 @@ class Lock(LazyLogging):
exc_tb(TracebackType): exception traceback if any
Returns:
Literal[False]: always False (do not suppress any exception)
Literal[False]: always ``False`` (do not suppress any exception)
"""
self.clear()
status = BuildStatusEnum.Success if exc_val is None else BuildStatusEnum.Failed

View File

@ -101,7 +101,7 @@ class PacmanDatabase(SyncHttpClient):
local_path(Path): path to locally stored file
Returns:
bool: True in case if remote file is newer than local file
bool: ``True`` in case if remote file is newer than local file
Raises:
PacmanError: in case if no last-modified header was found

View File

@ -96,7 +96,7 @@ class Auth(LazyLogging):
password(str | None): entered password
Returns:
bool: True in case if password matches, False otherwise
bool: ``True`` in case if password matches, ``False`` otherwise
"""
del username, password
return True
@ -109,7 +109,7 @@ class Auth(LazyLogging):
username(str): username
Returns:
bool: True in case if user is known and can be authorized and False otherwise
bool: ``True`` in case if user is known and can be authorized and ``False`` otherwise
"""
del username
return True
@ -124,7 +124,7 @@ class Auth(LazyLogging):
context(str | None): URI request path
Returns:
bool: True in case if user is allowed to do this request and False otherwise
bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
"""
del username, required, context
return True

View File

@ -38,7 +38,7 @@ async def authorized_userid(*args: Any, **kwargs: Any) -> Any:
**kwargs(Any): named argument list as provided by authorized_userid function
Returns:
Any: None in case if no aiohttp_security module found and function call otherwise
Any: ``None`` in case if no aiohttp_security module found and function call otherwise
"""
if _has_aiohttp_security:
return await aiohttp_security.authorized_userid(*args, **kwargs) # pylint: disable=no-value-for-parameter
@ -54,7 +54,7 @@ async def check_authorized(*args: Any, **kwargs: Any) -> Any:
**kwargs(Any): named argument list as provided by authorized_userid function
Returns:
Any: None in case if no aiohttp_security module found and function call otherwise
Any: ``None`` in case if no aiohttp_security module found and function call otherwise
"""
if _has_aiohttp_security:
return await aiohttp_security.check_authorized(*args, **kwargs) # pylint: disable=no-value-for-parameter
@ -70,7 +70,7 @@ async def forget(*args: Any, **kwargs: Any) -> Any:
**kwargs(Any): named argument list as provided by authorized_userid function
Returns:
Any: None in case if no aiohttp_security module found and function call otherwise
Any: ``None`` in case if no aiohttp_security module found and function call otherwise
"""
if _has_aiohttp_security:
return await aiohttp_security.forget(*args, **kwargs) # pylint: disable=no-value-for-parameter
@ -86,7 +86,7 @@ async def remember(*args: Any, **kwargs: Any) -> Any:
**kwargs(Any): named argument list as provided by authorized_userid function
Returns:
Any: None in case if no aiohttp_security module found and function call otherwise
Any: ``None`` in case if no aiohttp_security module found and function call otherwise
"""
if _has_aiohttp_security:
return await aiohttp_security.remember(*args, **kwargs) # pylint: disable=no-value-for-parameter

View File

@ -57,7 +57,7 @@ class Mapping(Auth):
password(str | None): entered password
Returns:
bool: True in case if password matches, False otherwise
bool: ``True`` in case if password matches, ``False`` otherwise
"""
if password is None:
return False # invalid data supplied
@ -72,7 +72,7 @@ class Mapping(Auth):
username(str): username
Returns:
User | None: user descriptor if username is known and None otherwise
User | None: user descriptor if username is known and ``None`` otherwise
"""
return self.database.user_get(username)
@ -84,7 +84,7 @@ class Mapping(Auth):
username(str): username
Returns:
bool: True in case if user is known and can be authorized and False otherwise
bool: ``True`` in case if user is known and can be authorized and ``False`` otherwise
"""
return username is not None and self.get_user(username) is not None
@ -98,7 +98,7 @@ class Mapping(Auth):
context(str | None): URI request path
Returns:
bool: True in case if user is allowed to do this request and False otherwise
bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
"""
user = self.get_user(username)
return user is not None and user.verify_access(required)

View File

@ -79,7 +79,7 @@ class PAM(Mapping):
password(str | None): entered password
Returns:
bool: True in case if password matches, False otherwise
bool: ``True`` in case if password matches, ``False`` otherwise
"""
if password is None:
return False # invalid data supplied
@ -101,7 +101,7 @@ class PAM(Mapping):
username(str): username
Returns:
bool: True in case if user is known and can be authorized and False otherwise
bool: ``True`` in case if user is known and can be authorized and ``False`` otherwise
"""
try:
_ = getpwnam(username)
@ -119,7 +119,7 @@ class PAM(Mapping):
context(str | None): URI request path
Returns:
bool: True in case if user is allowed to do this request and False otherwise
bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
"""
# this method is basically inverted, first we check overrides in database and then fallback to the PAM logic
if (user := self.get_user(username)) is not None:

View File

@ -138,7 +138,7 @@ class Sources(LazyLogging):
sources_dir(Path): local path to git repository
Returns:
bool: True in case if there is any remote and false otherwise
bool: ``True`` in case if there is any remote and false otherwise
"""
instance = Sources()
remotes = check_output(*instance.git(), "remote", cwd=sources_dir, logger=instance.logger)
@ -261,7 +261,7 @@ class Sources(LazyLogging):
commit_author(tuple[str, str] | None, optional): optional commit author if any (Default value = None)
Returns:
bool: True in case if changes have been committed and False otherwise
bool: ``True`` in case if changes have been committed and ``False`` otherwise
"""
if not self.has_changes(sources_dir):
return False # nothing to commit
@ -351,7 +351,7 @@ class Sources(LazyLogging):
sources_dir(Path): local path to git repository
Returns:
bool: True if there are uncommitted changes and False otherwise
bool: ``True`` if there are uncommitted changes and ``False`` otherwise
"""
# there is --exit-code argument to diff, however, there might be other process errors
changes = check_output(*self.git(), "diff", "--cached", "--name-only", cwd=sources_dir, logger=self.logger)

View File

@ -149,7 +149,7 @@ class Validator(RootValidator):
check if paths exists
Args:
constraint(bool): True in case if path must exist and False otherwise
constraint(bool): ``True`` in case if path must exist and ``False`` otherwise
field(str): field name to be checked
value(Path): value to be checked

View File

@ -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 <http://www.gnu.org/licenses/>.
#
__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)
""",
]

View File

@ -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

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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)

View File

@ -74,7 +74,7 @@ class Operations(LazyLogging):
Args:
query(Callable[[Connection], T]): function to be called with connection
commit(bool, optional): if True commit() will be called on success (Default value = False)
commit(bool, optional): if ``True`` commit() will be called on success (Default value = False)
Returns:
T: result of the ``query`` call

View File

@ -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):

View File

@ -32,7 +32,7 @@ class BuildPrinter(StringPrinter):
Args:
package(Package): built package
is_success(bool): True in case if build has success status and False otherwise
is_success(bool): ``True`` in case if build has success status and ``False`` otherwise
use_utf(bool): use utf instead of normal symbols
"""
StringPrinter.__init__(self, f"{self.sign(is_success, use_utf)} {package.base}")
@ -43,7 +43,7 @@ class BuildPrinter(StringPrinter):
generate sign according to settings
Args:
is_success(bool): True in case if build has success status and False otherwise
is_success(bool): ``True`` in case if build has success status and ``False`` otherwise
use_utf(bool): use utf instead of normal symbols
Returns:

View File

@ -57,7 +57,7 @@ class ChangesPrinter(Printer):
generate entry title from content
Returns:
str | None: content title if it can be generated and None otherwise
str | None: content title if it can be generated and ``None`` otherwise
"""
if self.changes.is_empty:
return None

View File

@ -63,7 +63,7 @@ class Printer:
generate entry title from content
Returns:
str | None: content title if it can be generated and None otherwise
str | None: content title if it can be generated and ``None`` otherwise
"""
return None

View File

@ -42,6 +42,6 @@ class StringPrinter(Printer):
generate entry title from content
Returns:
str | None: content title if it can be generated and None otherwise
str | None: content title if it can be generated and ``None`` otherwise
"""
return self.content

View File

@ -85,7 +85,7 @@ class SyncHttpClient(LazyLogging):
exception(requests.RequestException): exception raised
Returns:
str: text of the response if it is not None and empty string otherwise
str: text of the response if it is not ``None`` and empty string otherwise
"""
result: str = exception.response.text if exception.response is not None else ""
return result

View File

@ -38,8 +38,8 @@ class JinjaTemplate:
* homepage - link to homepage, string, optional
* link_path - prefix fo packages to download, string, required
* has_package_signed - True in case if package sign enabled, False otherwise, required
* has_repo_signed - True in case if repository database sign enabled, False otherwise, required
* has_package_signed - ``True`` in case if package sign enabled, ``False`` otherwise, required
* has_repo_signed - ``True`` in case if repository database sign enabled, ``False`` otherwise, required
* packages - sorted list of packages properties, required
* architecture, string
* archive_size, pretty printed size, string

View File

@ -78,7 +78,7 @@ class RemoteCall(Report):
process_id(str): remote process id
Returns:
bool: True in case if remote process is alive and False otherwise
bool: ``True`` in case if remote process is alive and ``False`` otherwise
"""
try:
response = self.client.make_request("GET", f"{self.client.address}/api/v1/service/process/{process_id}")

View File

@ -72,7 +72,7 @@ class Spawn(Thread, LazyLogging):
value(bool): command line argument value
Returns:
str: if ``value`` is True, then returns positive flag and negative otherwise
str: if ``value`` is ``True``, then returns positive flag and negative otherwise
"""
return name if value else f"no-{name}"
@ -153,7 +153,7 @@ class Spawn(Thread, LazyLogging):
process_id(str): process id to be checked as returned by :func:`_spawn_process()`
Returns:
bool: True in case if process still counts as active and False otherwise
bool: ``True`` in case if process still counts as active and ``False`` otherwise
"""
with self._lock:
return process_id in self.active

View File

@ -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
@ -184,7 +216,7 @@ class Client:
Args:
package_base(str): package base
version(str | None): package version to remove logs. If None set, all logs will be removed
version(str | None): package version to remove logs. If ``None`` is set, all logs will be removed
Raises:
NotImplementedError: not implemented method
@ -213,7 +245,7 @@ class Client:
Args:
package_base(str): package base to update
variable(str | None): patch name. If None set, all patches will be removed
variable(str | None): patch name. If ``None`` is set, all patches will be removed
Raises:
NotImplementedError: not implemented method

View File

@ -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
@ -138,7 +164,7 @@ class LocalClient(Client):
Args:
package_base(str): package base
version(str | None): package version to remove logs. If None set, all logs will be removed
version(str | None): package version to remove logs. If ``None`` is set, all logs will be removed
"""
self.database.logs_remove(package_base, version, self.repository_id)
@ -162,7 +188,7 @@ class LocalClient(Client):
Args:
package_base(str): package base to update
variable(str | None): patch name. If None set, all patches will be removed
variable(str | None): patch name. If ``None`` is set, all patches will be removed
"""
variables = [variable] if variable is not None else None
self.database.patches_remove(package_base, variables)

View File

@ -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

View File

@ -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()
@ -289,12 +338,13 @@ class WebClient(Client, SyncAhrimanClient):
Args:
package_base(str): package base
version(str | None): package version to remove logs. If None set, all logs will be removed
version(str | None): package version to remove logs. If ``None`` is 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]:
@ -323,7 +373,7 @@ class WebClient(Client, SyncAhrimanClient):
Args:
package_base(str): package base to update
variable(str | None): patch name. If None set, all patches will be removed
variable(str | None): patch name. If ``None`` is set, all patches will be removed
"""
with contextlib.suppress(Exception):
self.make_request("DELETE", self._patches_url(package_base, variable or ""))
@ -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)

View File

@ -64,7 +64,7 @@ class Leaf:
packages(Iterable[Leaf]): list of known leaves
Returns:
bool: True in case if package is dependency of others and False otherwise
bool: ``True`` in case if package is dependency of others and ``False`` otherwise
"""
for leaf in packages:
if leaf.dependencies.intersection(self.items):
@ -79,7 +79,7 @@ class Leaf:
packages(Iterable[Leaf]): list of known leaves
Returns:
bool: True if any of packages is dependency of the leaf, False otherwise
bool: ``True`` if any of packages is dependency of the leaf, ``False`` otherwise
"""
for leaf in packages:
if self.dependencies.intersection(leaf.items):

View File

@ -160,7 +160,7 @@ class GitHub(Upload, HttpUpload):
get release object if any
Returns:
dict[str, Any] | None: GitHub API release object if release found and None otherwise
dict[str, Any] | None: GitHub API release object if release found and ``None`` otherwise
"""
url = f"https://api.github.com/repos/{self.github_owner}/{
self.github_repository}/releases/tags/{self.github_release_tag}"

View File

@ -225,8 +225,8 @@ def extract_user() -> str | None:
extract user from system environment
Returns:
str | None: SUDO_USER in case if set and USER otherwise. It can return None in case if environment has been
cleared before application start
str | None: SUDO_USER in case if set and USER otherwise. It can return ``None`` in case if environment has been
cleared before application start
"""
return os.getenv("SUDO_USER") or os.getenv("DOAS_USER") or os.getenv("USER")
@ -295,7 +295,7 @@ def package_like(filename: Path) -> bool:
filename(Path): name of file to check
Returns:
bool: True in case if name contains ``.pkg.`` and not signature, False otherwise
bool: ``True`` in case if name contains ``.pkg.`` and not signature, ``False`` otherwise
"""
name = filename.name
return not name.startswith(".") and ".pkg." in name and not name.endswith(".sig")

View File

@ -44,7 +44,7 @@ class AuthSettings(StrEnum):
get enabled flag
Returns:
bool: False in case if authorization is disabled and True otherwise
bool: ``False`` in case if authorization is disabled and ``True`` otherwise
"""
return self != AuthSettings.Disabled

View File

@ -0,0 +1,92 @@
#
# 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 <http://www.gnu.org/licenses/>.
#
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:
PackageOutdated(EventType): (class attribute) package has been marked as out-of-date
PackageRemoved(EventType): (class attribute) package has been removed
PackageUpdateFailed(EventType): (class attribute) package update has been failed
PackageUpdated(EventType): (class attribute) package has been updated
"""
PackageOutdated = "package-outdated"
PackageRemoved = "package-removed"
PackageUpdateFailed = "package-update-failed"
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:
"""
convert event type to enum if it is a well-known event type
"""
if self.event in EventType:
object.__setattr__(self, "event", EventType(self.event))
@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)

View File

@ -41,7 +41,7 @@ class MigrationResult:
check migration and check if there are pending migrations
Returns:
bool: True in case if it requires migrations and False otherwise
bool: ``True`` in case if it requires migrations and ``False`` otherwise
"""
self.validate()
return self.new_version > self.old_version

View File

@ -158,7 +158,7 @@ class Package(LazyLogging):
get VCS flag based on the package base
Returns:
bool: True in case if package base looks like VCS package and False otherwise
bool: ``True`` in case if package base looks like VCS package and ``False`` otherwise
"""
return self.base.endswith("-bzr") \
or self.base.endswith("-csv")\
@ -504,7 +504,7 @@ class Package(LazyLogging):
timestamp(float | int): timestamp to check build date against
Returns:
bool: True in case if package was built after the specified date and False otherwise. In case if build date
bool: ``True`` in case if package was built after the specified date and ``False`` otherwise. In case if build date
is not set by any of packages, it returns False
"""
return any(
@ -528,7 +528,7 @@ class Package(LazyLogging):
(Default value = True)
Returns:
bool: True if the package is out-of-dated and False otherwise
bool: ``True`` if the package is out-of-dated and ``False`` otherwise
"""
min_vcs_build_date = utcnow().timestamp() - vcs_allowed_age
if calculate_version and not self.is_newer_than(min_vcs_build_date):

View File

@ -55,7 +55,7 @@ class PkgbuildPatch:
parse key and define whether it function or not
Returns:
bool: True in case if key ends with parentheses and False otherwise
bool: ``True`` in case if key ends with parentheses and ``False`` otherwise
"""
return self.key is not None and self.key.endswith("()")
@ -65,7 +65,7 @@ class PkgbuildPatch:
check if patch is full diff one or just single-variable patch
Returns:
bool: True in case key set and False otherwise
bool: ``True`` in case key set and ``False`` otherwise
"""
return self.key is None

View File

@ -29,7 +29,7 @@ class Property:
Attributes:
name(str): name of the property
value(Any): property value
is_required(bool): if set to True then this property is required
is_required(bool): if set to ``True`` then this property is required
indent(int): property indentation level
"""

View File

@ -57,7 +57,7 @@ class RemoteSource:
check if source is remote
Returns:
bool: True in case if package is well-known remote source (e.g. AUR) and False otherwise
bool: ``True`` in case if package is well-known remote source (e.g. AUR) and ``False`` otherwise
"""
return self.source in (PackageSource.AUR, PackageSource.Repository)

View File

@ -52,7 +52,7 @@ class RepositoryId:
check if all data is supplied for the loading
Returns:
bool: True in case if architecture or name are not set and False otherwise
bool: ``True`` in case if architecture or name are not set and ``False`` otherwise
"""
return not self.architecture or not self.name
@ -85,7 +85,7 @@ class RepositoryId:
other(Any): other object to compare
Returns:
bool: True in case if this is less than other and False otherwise
bool: ``True`` in case if this is less than other and ``False`` otherwise
Raises:
TypeError: if other is different from RepositoryId type

View File

@ -82,7 +82,7 @@ class Result:
get if build result is empty or not
Returns:
bool: True in case if success list is empty and False otherwise
bool: ``True`` in case if success list is empty and ``False`` otherwise
"""
return not self._added and not self._updated
@ -191,7 +191,7 @@ class Result:
other(Any): other object instance
Returns:
bool: True if the other object is the same and False otherwise
bool: ``True`` if the other object is the same and ``False`` otherwise
"""
if not isinstance(other, Result):
return False

View File

@ -98,7 +98,7 @@ class User:
salt(str): salt for hashed password
Returns:
bool: True in case if password matches, False otherwise
bool: ``True`` in case if password matches, ``False`` otherwise
"""
try:
verified: bool = self._HASHER.verify(password + salt, self.password)
@ -131,7 +131,7 @@ class User:
required(UserAccess): required access level
Returns:
bool: True in case if user is allowed to do this request and False otherwise
bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
"""
return self.access.permits(required)

View File

@ -47,7 +47,7 @@ class UserAccess(StrEnum):
other(UserAccess): other permission to compare
Returns:
bool: True in case if current permission allows the operation and False otherwise
bool: ``True`` in case if current permission allows the operation and ``False`` otherwise
"""
for member in UserAccess:
if member == other:

View File

@ -63,7 +63,7 @@ class _AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy):
identity(str): username
Returns:
str | None: user identity (username) in case if user exists and None otherwise
str | None: user identity (username) in case if user exists and ``None`` otherwise
"""
return identity if await self.validator.known_username(identity) else None
@ -77,7 +77,7 @@ class _AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy):
context(str | None, optional): URI request path (Default value = None)
Returns:
bool: True in case if user is allowed to perform this request and False otherwise
bool: ``True`` in case if user is allowed to perform this request and ``False`` otherwise
"""
# some methods for type checking and parent class compatibility
if identity is None or not isinstance(permission, UserAccess):

View File

@ -38,7 +38,7 @@ def _is_templated_unauthorized(request: Request) -> bool:
request(Request): source request to check
Returns:
bool: True in case if response should be rendered as html and False otherwise
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", [])

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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",
})

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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",
})

View File

@ -247,7 +247,7 @@ class BaseView(View, CorsViewMixin):
extract username from request if any
Returns:
str | None: authorized username if any and None otherwise (e.g. if authorization is disabled)
str | None: authorized username if any and ``None`` otherwise (e.g. if authorization is disabled)
"""
try: # try to read from payload
data: dict[str, str] = await self.request.json() # technically it is not, but we only need str here

View File

@ -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 <http://www.gnu.org/licenses/>.
#

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,17 @@
from ahriman.models.event import Event, EventType
def test_post_init() -> None:
"""
must replace event type for known types
"""
assert Event("random", "")
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

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -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())

View File

@ -11,6 +11,7 @@ flags = --implicit-reexport --strict --allow-untyped-decorators --allow-subclass
[pytest]
addopts = --cov=ahriman --cov-report=term-missing:skip-covered --no-cov-on-fail --cov-fail-under=100 --spec
asyncio_default_fixture_loop_scope = function
asyncio_mode = auto
spec_test_format = {result} {docstring_summary}