diff --git a/.gitignore b/.gitignore index 2c676d87..a604aa9a 100644 --- a/.gitignore +++ b/.gitignore @@ -94,4 +94,6 @@ ENV/ .venv/ *.tar.xz -status_cache.json \ No newline at end of file +status_cache.json + +*.db diff --git a/Dockerfile b/Dockerfile index 96f51dbe..c69a7a68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ FROM archlinux:base-devel ENV AHRIMAN_ARCHITECTURE="x86_64" ENV AHRIMAN_DEBUG="" ENV AHRIMAN_FORCE_ROOT="" +ENV AHRIMAN_HOST="0.0.0.0" ENV AHRIMAN_OUTPUT="syslog" ENV AHRIMAN_PACKAGER="ahriman bot " ENV AHRIMAN_PORT="" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 67946831..2e838ec8 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,7 +5,8 @@ set -e # configuration tune sed -i "s|root = /var/lib/ahriman|root = $AHRIMAN_REPOSITORY_ROOT|g" "/etc/ahriman.ini" -sed -i "s|host = 127.0.0.1|host = 0.0.0.0|g" "/etc/ahriman.ini" +sed -i "s|database = /var/lib/ahriman/ahriman.db|database = $AHRIMAN_REPOSITORY_ROOT/ahriman.db|g" "/etc/ahriman.ini" +sed -i "s|host = 127.0.0.1|host = $AHRIMAN_HOST|g" "/etc/ahriman.ini" sed -i "s|handlers = syslog_handler|handlers = ${AHRIMAN_OUTPUT}_handler|g" "/etc/ahriman.ini.d/logging.ini" AHRIMAN_DEFAULT_ARGS=("-a" "$AHRIMAN_ARCHITECTURE") @@ -40,9 +41,9 @@ systemd-machine-id-setup &> /dev/null # otherwise we prepend executable by sudo command if [ -n "$AHRIMAN_FORCE_ROOT" ]; then AHRIMAN_EXECUTABLE=("ahriman") -elif ahriman help-commands-unsafe | grep -Fxq "$1"; then - AHRIMAN_EXECUTABLE=("ahriman") -else +elif ahriman help-commands-unsafe --command="$*" &> /dev/null; then AHRIMAN_EXECUTABLE=("sudo" "-u" "$AHRIMAN_USER" "--" "ahriman") +else + AHRIMAN_EXECUTABLE=("ahriman") fi exec "${AHRIMAN_EXECUTABLE[@]}" "${AHRIMAN_DEFAULT_ARGS[@]}" "$@" diff --git a/docs/faq.md b/docs/faq.md index efe48697..6181f46f 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -228,6 +228,7 @@ The following environment variables are supported: * `AHRIMAN_ARCHITECTURE` - architecture of the repository, default is `x86_64`. * `AHRIMAN_DEBUG` - if set all commands will be logged to console. * `AHRIMAN_FORCE_ROOT` - force run ahriman as root instead of guessing by subcommand. +* `AHRIMAN_HOST` - host for the web interface, default is `0.0.0.0`. * `AHRIMAN_OUTPUT` - controls logging handler, e.g. `syslog`, `console`. The name must be found in logging configuration. Note that if `syslog` (the default) handler is used you will need to mount `/dev/log` inside container because it is not available there. * `AHRIMAN_PACKAGER` - packager name from which packages will be built, default is `ahriman bot `. * `AHRIMAN_PORT` - HTTP server port if any, default is empty. diff --git a/package/share/ahriman/settings/ahriman.ini b/package/share/ahriman/settings/ahriman.ini index 38dbbbc8..2ce40b5f 100644 --- a/package/share/ahriman/settings/ahriman.ini +++ b/package/share/ahriman/settings/ahriman.ini @@ -1,6 +1,7 @@ [settings] include = ahriman.ini.d logging = ahriman.ini.d/logging.ini +database = /var/lib/ahriman/ahriman.db [alpm] aur_url = https://aur.archlinux.org diff --git a/package/share/ahriman/settings/ahriman.ini.d/logging.ini b/package/share/ahriman/settings/ahriman.ini.d/logging.ini index 4a47dd24..2b0c97dd 100644 --- a/package/share/ahriman/settings/ahriman.ini.d/logging.ini +++ b/package/share/ahriman/settings/ahriman.ini.d/logging.ini @@ -1,5 +1,5 @@ [loggers] -keys = root,build_details,http,stderr,boto3,botocore,nose,s3transfer +keys = root,build_details,database,http,stderr,boto3,botocore,nose,s3transfer [handlers] keys = console_handler,syslog_handler @@ -38,6 +38,12 @@ handlers = syslog_handler qualname = build_details propagate = 0 +[logger_database] +level = DEBUG +handlers = syslog_handler +qualname = database +propagate = 0 + [logger_http] level = DEBUG handlers = syslog_handler diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index 542d010a..df8d6687 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -94,6 +94,7 @@ def _parser() -> argparse.ArgumentParser: _set_repo_sync_parser(subparsers) _set_repo_update_parser(subparsers) _set_user_add_parser(subparsers) + _set_user_list_parser(subparsers) _set_user_remove_parser(subparsers) _set_web_parser(subparsers) @@ -126,6 +127,8 @@ def _set_help_commands_unsafe(root: SubParserAction) -> argparse.ArgumentParser: """ parser = root.add_parser("help-commands-unsafe", help="list unsafe commands", description="list unsafe commands as defined in default args", formatter_class=_formatter) + parser.add_argument("--command", help="instead of showing commands, just test command line for unsafe subcommand " + "and return 0 in case if command is safe and 1 otherwise") parser.set_defaults(handler=handlers.UnsafeCommands, architecture=[""], lock=None, no_report=True, quiet=True, unsafe=True, parser=_parser) return parser @@ -273,7 +276,7 @@ def _set_patch_list_parser(root: SubParserAction) -> argparse.ArgumentParser: """ parser = root.add_parser("patch-list", help="list patch sets", description="list available patches for the package", formatter_class=_formatter) - parser.add_argument("package", help="package base") + parser.add_argument("package", help="package base", nargs="?") parser.set_defaults(handler=handlers.Patch, action=Action.List, architecture=[""], lock=None, no_report=True) return parser @@ -318,12 +321,10 @@ def _set_repo_clean_parser(root: SubParserAction) -> argparse.ArgumentParser: "you should not run this command manually. Also in case if you are going to clear " "the chroot directories you will need root privileges.", formatter_class=_formatter) - parser.add_argument("--build", help="clear directory with package sources", action="store_true") parser.add_argument("--cache", help="clear directory with package caches", action="store_true") parser.add_argument("--chroot", help="clear build chroot", action="store_true") - parser.add_argument("--manual", help="clear directory with manually added packages", action="store_true") + parser.add_argument("--manual", help="clear manually added packages queue", action="store_true") parser.add_argument("--packages", help="clear directory with built packages", action="store_true") - parser.add_argument("--patches", help="clear directory with patches", action="store_true") parser.set_defaults(handler=handlers.Clean, quiet=True, unsafe=True) return parser @@ -487,7 +488,6 @@ def _set_user_add_parser(root: SubParserAction) -> argparse.ArgumentParser: formatter_class=_formatter) parser.add_argument("username", help="username for web service") parser.add_argument("--as-service", help="add user as service user", action="store_true") - parser.add_argument("--no-reload", help="do not reload authentication module", action="store_true") parser.add_argument("-p", "--password", help="user password. Blank password will be treated as empty password, " "which is in particular must be used for OAuth2 authorization type.") parser.add_argument("-r", "--role", help="user access level", @@ -498,6 +498,22 @@ def _set_user_add_parser(root: SubParserAction) -> argparse.ArgumentParser: return parser +def _set_user_list_parser(root: SubParserAction) -> argparse.ArgumentParser: + """ + add parser for user list subcommand + :param root: subparsers for the commands + :return: created argument parser + """ + parser = root.add_parser("user-list", help="user known users and their access", + description="list users from the user mapping and their roles", + formatter_class=_formatter) + parser.add_argument("username", help="filter users by username", nargs="?") + parser.add_argument("-r", "--role", help="filter users by role", type=UserAccess, choices=UserAccess) + parser.set_defaults(handler=handlers.User, action=Action.List, architecture=[""], lock=None, no_report=True, # nosec + password="", quiet=True, role=UserAccess.Read, unsafe=True) + return parser + + def _set_user_remove_parser(root: SubParserAction) -> argparse.ArgumentParser: """ add parser for user removal subcommand @@ -508,7 +524,6 @@ def _set_user_remove_parser(root: SubParserAction) -> argparse.ArgumentParser: description="remove user from the user mapping and update the configuration", formatter_class=_formatter) parser.add_argument("username", help="username for web service") - parser.add_argument("--no-reload", help="do not reload authentication module", action="store_true") parser.add_argument("-s", "--secure", help="set file permissions to user-only", action="store_true") parser.set_defaults(handler=handlers.User, action=Action.Remove, architecture=[""], lock=None, no_report=True, # nosec password="", quiet=True, role=UserAccess.Read, unsafe=True) diff --git a/src/ahriman/application/application/packages.py b/src/ahriman/application/application/packages.py index 852b4fbe..1bfbe59a 100644 --- a/src/ahriman/application/application/packages.py +++ b/src/ahriman/application/application/packages.py @@ -25,7 +25,7 @@ from typing import Any, Iterable, Set from ahriman.application.application.properties import Properties from ahriman.core.build_tools.sources import Sources -from ahriman.core.util import package_like +from ahriman.core.util import package_like, tmpdir from ahriman.models.package import Package from ahriman.models.package_source import PackageSource from ahriman.models.result import Result @@ -67,10 +67,11 @@ class Packages(Properties): :param without_dependencies: if set, dependency check will be disabled """ package = Package.load(source, PackageSource.AUR, self.repository.pacman, self.repository.aur_url) - local_path = self.repository.paths.manual_for(package.base) + self.repository.database.build_queue_insert(package) - Sources.load(local_path, package.git_url, self.repository.paths.patches_for(package.base)) - self._process_dependencies(local_path, known_packages, without_dependencies) + with tmpdir() as local_path: + Sources.load(local_path, package.git_url, self.database.patches_get(package.base)) + self._process_dependencies(local_path, known_packages, without_dependencies) def _add_directory(self, source: str, *_: Any) -> None: """ @@ -92,10 +93,9 @@ class Packages(Properties): cache_dir = self.repository.paths.cache_for(package.base) shutil.copytree(Path(source), cache_dir) # copy package to store in caches Sources.init(cache_dir) # we need to run init command in directory where we do have permissions + self.repository.database.build_queue_insert(package) - dst = self.repository.paths.manual_for(package.base) - shutil.copytree(cache_dir, dst) # copy package for the build - self._process_dependencies(dst, known_packages, without_dependencies) + self._process_dependencies(cache_dir, known_packages, without_dependencies) def _add_remote(self, source: str, *_: Any) -> None: """ diff --git a/src/ahriman/application/application/properties.py b/src/ahriman/application/application/properties.py index 7d6d039e..1c4e341e 100644 --- a/src/ahriman/application/application/properties.py +++ b/src/ahriman/application/application/properties.py @@ -20,6 +20,7 @@ import logging from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.repository import Repository @@ -28,6 +29,7 @@ class Properties: application base properties class :ivar architecture: repository architecture :ivar configuration: configuration instance + :ivar database: database instance :ivar logger: application logger :ivar repository: repository instance """ @@ -43,4 +45,5 @@ class Properties: self.logger = logging.getLogger("root") self.configuration = configuration self.architecture = architecture - self.repository = Repository(architecture, configuration, no_report, unsafe) + self.database = SQLite.load(configuration) + self.repository = Repository(architecture, configuration, self.database, no_report, unsafe) diff --git a/src/ahriman/application/application/repository.py b/src/ahriman/application/application/repository.py index bfa2b291..7117b732 100644 --- a/src/ahriman/application/application/repository.py +++ b/src/ahriman/application/application/repository.py @@ -42,28 +42,22 @@ class Repository(Properties): """ raise NotImplementedError - def clean(self, build: bool, cache: bool, chroot: bool, manual: bool, packages: bool, patches: bool) -> None: + def clean(self, cache: bool, chroot: bool, manual: bool, packages: bool) -> None: """ run all clean methods. Warning: some functions might not be available under non-root - :param build: clear directory with package sources :param cache: clear directory with package caches :param chroot: clear build chroot :param manual: clear directory with manually added packages :param packages: clear directory with built packages - :param patches: clear directory with patches """ - if build: - self.repository.clear_build() if cache: self.repository.clear_cache() if chroot: self.repository.clear_chroot() if manual: - self.repository.clear_manual() + self.repository.clear_queue() if packages: self.repository.clear_packages() - if patches: - self.repository.clear_patches() def report(self, target: Iterable[str], result: Result) -> None: """ @@ -154,7 +148,7 @@ class Repository(Properties): process_update(packages, Result()) # process manual packages - tree = Tree.load(updates, self.repository.paths) + tree = Tree.load(updates, self.database) for num, level in enumerate(tree.levels()): self.logger.info("processing level #%i %s", num, [package.base for package in level]) build_result = self.repository.process_build(level) diff --git a/src/ahriman/application/handlers/clean.py b/src/ahriman/application/handlers/clean.py index 7a438dd6..543c00af 100644 --- a/src/ahriman/application/handlers/clean.py +++ b/src/ahriman/application/handlers/clean.py @@ -43,4 +43,4 @@ class Clean(Handler): :param unsafe: if set no user check will be performed before path creation """ Application(architecture, configuration, no_report, unsafe).clean( - args.build, args.cache, args.chroot, args.manual, args.packages, args.patches) + args.cache, args.chroot, args.manual, args.packages) diff --git a/src/ahriman/application/handlers/handler.py b/src/ahriman/application/handlers/handler.py index 471210ce..c3c76da2 100644 --- a/src/ahriman/application/handlers/handler.py +++ b/src/ahriman/application/handlers/handler.py @@ -27,7 +27,7 @@ from typing import List, Type from ahriman.application.lock import Lock from ahriman.core.configuration import Configuration -from ahriman.core.exceptions import MissingArchitecture, MultipleArchitectures +from ahriman.core.exceptions import ExitCode, MissingArchitecture, MultipleArchitectures from ahriman.models.repository_paths import RepositoryPaths @@ -78,6 +78,8 @@ class Handler: with Lock(args, architecture, configuration): cls.run(args, architecture, configuration, args.no_report, args.unsafe) return True + except ExitCode: + return False except Exception: # we are basically always want to print error to stderr instead of default logger logging.getLogger("stderr").exception("process exception") diff --git a/src/ahriman/application/handlers/patch.py b/src/ahriman/application/handlers/patch.py index a2d6d5b9..093286bc 100644 --- a/src/ahriman/application/handlers/patch.py +++ b/src/ahriman/application/handlers/patch.py @@ -18,15 +18,15 @@ # along with this program. If not, see . # import argparse -import shutil from pathlib import Path -from typing import List, Type +from typing import List, Optional, Type from ahriman.application.application import Application from ahriman.application.handlers.handler import Handler from ahriman.core.build_tools.sources import Sources from ahriman.core.configuration import Configuration +from ahriman.core.formatters.string_printer import StringPrinter from ahriman.models.action import Action from ahriman.models.package import Package from ahriman.models.package_source import PackageSource @@ -37,8 +37,6 @@ class Patch(Handler): patch control handler """ - _print = print - @classmethod def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration, no_report: bool, unsafe: bool) -> None: @@ -69,25 +67,20 @@ class Patch(Handler): """ package = Package.load(sources_dir, PackageSource.Local, application.repository.pacman, application.repository.aur_url) - patch_dir = application.repository.paths.patches_for(package.base) - - Patch.patch_set_remove(application, package.base) # remove old patches - patch_dir.mkdir(mode=0o755, parents=True) - - Sources.patch_create(Path(sources_dir), patch_dir / "00-main.patch", *track) + patch = Sources.patch_create(Path(sources_dir), *track) + application.database.patches_insert(package.base, patch) @staticmethod - def patch_set_list(application: Application, package_base: str) -> None: + def patch_set_list(application: Application, package_base: Optional[str]) -> None: """ list patches available for the package base :param application: application instance :param package_base: package base """ - patch_dir = application.repository.paths.patches_for(package_base) - if not patch_dir.is_dir(): - return - for patch_path in sorted(patch_dir.glob("*.patch")): - Patch._print(patch_path.name) + patches = application.database.patches_list(package_base) + for base, patch in patches.items(): + content = base if package_base is None else patch + StringPrinter(content).print(verbose=True) @staticmethod def patch_set_remove(application: Application, package_base: str) -> None: @@ -96,5 +89,4 @@ class Patch(Handler): :param application: application instance :param package_base: package base """ - patch_dir = application.repository.paths.patches_for(package_base) - shutil.rmtree(patch_dir, ignore_errors=True) + application.database.patches_remove(package_base) diff --git a/src/ahriman/application/handlers/unsafe_commands.py b/src/ahriman/application/handlers/unsafe_commands.py index 3f874cb1..c8d182de 100644 --- a/src/ahriman/application/handlers/unsafe_commands.py +++ b/src/ahriman/application/handlers/unsafe_commands.py @@ -18,11 +18,13 @@ # along with this program. If not, see . # import argparse +import shlex from typing import List, Type from ahriman.application.handlers.handler import Handler from ahriman.core.configuration import Configuration +from ahriman.core.exceptions import ExitCode from ahriman.core.formatters.string_printer import StringPrinter @@ -44,9 +46,25 @@ class UnsafeCommands(Handler): :param no_report: force disable reporting :param unsafe: if set no user check will be performed before path creation """ - unsafe_commands = UnsafeCommands.get_unsafe_commands(args.parser()) - for command in unsafe_commands: - StringPrinter(command).print(verbose=True) + parser = args.parser() + unsafe_commands = UnsafeCommands.get_unsafe_commands(parser) + if args.command is None: + for command in unsafe_commands: + StringPrinter(command).print(verbose=True) + else: + UnsafeCommands.check_unsafe(args.command, unsafe_commands, parser) + + @staticmethod + def check_unsafe(command: str, unsafe_commands: List[str], parser: argparse.ArgumentParser) -> None: + """ + check if command is unsafe + :param command: command to check + :param unsafe_commands: list of unsafe commands + :param parser: generated argument parser + """ + args = parser.parse_args(shlex.split(command)) + if args.command in unsafe_commands: + raise ExitCode() @staticmethod def get_unsafe_commands(parser: argparse.ArgumentParser) -> List[str]: diff --git a/src/ahriman/application/handlers/user.py b/src/ahriman/application/handlers/user.py index 12ca508d..b9eeb887 100644 --- a/src/ahriman/application/handlers/user.py +++ b/src/ahriman/application/handlers/user.py @@ -23,12 +23,12 @@ import getpass from pathlib import Path from typing import Type -from ahriman.application.application import Application from ahriman.application.handlers.handler import Handler from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite +from ahriman.core.formatters.user_printer import UserPrinter from ahriman.models.action import Action from ahriman.models.user import User as MUser -from ahriman.models.user_access import UserAccess class User(Handler): @@ -51,33 +51,35 @@ class User(Handler): """ salt = User.get_salt(configuration) user = User.user_create(args) + auth_configuration = User.configuration_get(configuration.include) + database = SQLite.load(configuration) - User.user_clear(auth_configuration, user) - if args.action == Action.Update: - User.configuration_create(auth_configuration, user, salt, args.as_service) - User.configuration_write(auth_configuration, args.secure) - - if not args.no_reload: - client = Application(architecture, configuration, no_report=False, unsafe=unsafe).repository.reporter - client.reload_auth() + if args.action == Action.List: + for found_user in database.user_list(user.username, user.access): + UserPrinter(found_user).print(verbose=True) + elif args.action == Action.Remove: + database.user_remove(user.username) + elif args.action == Action.Update: + User.configuration_create(auth_configuration, user, salt, args.as_service, args.secure) + database.user_update(user.hash_password(salt)) @staticmethod - def configuration_create(configuration: Configuration, user: MUser, salt: str, as_service_user: bool) -> None: + def configuration_create(configuration: Configuration, user: MUser, salt: str, + as_service_user: bool, secure: bool) -> None: """ - put new user to configuration + enable configuration if it has been disabled :param configuration: configuration instance :param user: user descriptor :param salt: password hash salt :param as_service_user: add user as service user, also set password and user to configuration + :param secure: if true then set file permissions to 0o600 """ - section = Configuration.section_name("auth", user.access.value) configuration.set_option("auth", "salt", salt) - configuration.set_option(section, user.username, user.hash_password(salt)) - if as_service_user: configuration.set_option("web", "username", user.username) configuration.set_option("web", "password", user.password) + User.configuration_write(configuration, secure) @staticmethod def configuration_get(include_path: Path) -> Configuration: @@ -90,6 +92,8 @@ class User(Handler): configuration = Configuration() configuration.load(target) + configuration.architecture = "" # not user anyway + return configuration @staticmethod @@ -99,12 +103,11 @@ class User(Handler): :param configuration: configuration instance :param secure: if true then set file permissions to 0o600 """ - if configuration.path is None: - return # should never happen actually - with configuration.path.open("w") as ahriman_configuration: + path, _ = configuration.check_loaded() + with path.open("w") as ahriman_configuration: configuration.write(ahriman_configuration) if secure: - configuration.path.chmod(0o600) + path.chmod(0o600) @staticmethod def get_salt(configuration: Configuration, salt_length: int = 20) -> str: @@ -118,19 +121,6 @@ class User(Handler): return salt return MUser.generate_password(salt_length) - @staticmethod - def user_clear(configuration: Configuration, user: MUser) -> None: - """ - remove user user from configuration file in case if it exists - :param configuration: configuration instance - :param user: user descriptor - """ - for role in UserAccess: - section = Configuration.section_name("auth", role.value) - if not configuration.has_option(section, user.username): - continue - configuration.remove_option(section, user.username) - @staticmethod def user_create(args: argparse.Namespace) -> MUser: """ diff --git a/src/ahriman/application/lock.py b/src/ahriman/application/lock.py index 90e96584..a7886964 100644 --- a/src/ahriman/application/lock.py +++ b/src/ahriman/application/lock.py @@ -32,7 +32,6 @@ from ahriman.core.exceptions import DuplicateRun from ahriman.core.status.client import Client from ahriman.core.util import check_user from ahriman.models.build_status import BuildStatusEnum -from ahriman.models.repository_paths import RepositoryPaths class Lock: @@ -56,7 +55,7 @@ class Lock: self.force = args.force self.unsafe = args.unsafe - self.paths = RepositoryPaths(configuration.getpath("repository", "root"), architecture) + self.paths = configuration.repository_paths self.reporter = Client() if args.no_report else Client.load(configuration) def __enter__(self) -> Lock: diff --git a/src/ahriman/core/auth/auth.py b/src/ahriman/core/auth/auth.py index e20ba508..429369d6 100644 --- a/src/ahriman/core/auth/auth.py +++ b/src/ahriman/core/auth/auth.py @@ -21,12 +21,11 @@ from __future__ import annotations import logging -from typing import Dict, Optional, Type +from typing import Optional, Type from ahriman.core.configuration import Configuration -from ahriman.core.exceptions import DuplicateUser +from ahriman.core.database.sqlite import SQLite from ahriman.models.auth_settings import AuthSettings -from ahriman.models.user import User from ahriman.models.user_access import UserAccess @@ -63,40 +62,22 @@ class Auth: return """""" @classmethod - def load(cls: Type[Auth], configuration: Configuration) -> Auth: + def load(cls: Type[Auth], configuration: Configuration, database: SQLite) -> Auth: """ load authorization module from settings :param configuration: configuration instance + :param database: database instance :return: authorization module according to current settings """ provider = AuthSettings.from_option(configuration.get("auth", "target", fallback="disabled")) if provider == AuthSettings.Configuration: from ahriman.core.auth.mapping import Mapping - return Mapping(configuration) + return Mapping(configuration, database) if provider == AuthSettings.OAuth: from ahriman.core.auth.oauth import OAuth - return OAuth(configuration) + return OAuth(configuration, database) return cls(configuration) - @staticmethod - def get_users(configuration: Configuration) -> Dict[str, User]: - """ - load users from settings - :param configuration: configuration instance - :return: map of username to its descriptor - """ - users: Dict[str, User] = {} - for role in UserAccess: - section = configuration.section_name("auth", role.value) - if not configuration.has_section(section): - continue - for user, password in configuration[section].items(): - normalized_user = user.lower() - if normalized_user in users: - raise DuplicateUser(normalized_user) - users[normalized_user] = User(normalized_user, password, role) - return users - async def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool: # pylint: disable=no-self-use """ validate user password diff --git a/src/ahriman/core/auth/mapping.py b/src/ahriman/core/auth/mapping.py index 778e7e6f..0c671a13 100644 --- a/src/ahriman/core/auth/mapping.py +++ b/src/ahriman/core/auth/mapping.py @@ -20,7 +20,9 @@ from typing import Optional from ahriman.core.auth.auth import Auth + from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.models.auth_settings import AuthSettings from ahriman.models.user import User from ahriman.models.user_access import UserAccess @@ -30,18 +32,20 @@ class Mapping(Auth): """ user authorization based on mapping from configuration file :ivar salt: random generated string to salt passwords - :ivar _users: map of username to its descriptor + :ivar database: database instance """ - def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Configuration) -> None: + def __init__(self, configuration: Configuration, database: SQLite, + provider: AuthSettings = AuthSettings.Configuration) -> None: """ default constructor :param configuration: configuration instance + :param database: database instance :param provider: authorization type definition """ Auth.__init__(self, configuration, provider) + self.database = database self.salt = configuration.get("auth", "salt") - self._users = self.get_users(configuration) async def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool: """ @@ -61,8 +65,7 @@ class Mapping(Auth): :param username: username :return: user descriptor if username is known and None otherwise """ - normalized_user = username.lower() - return self._users.get(normalized_user) + return self.database.user_get(username) async def known_username(self, username: Optional[str]) -> bool: """ diff --git a/src/ahriman/core/auth/oauth.py b/src/ahriman/core/auth/oauth.py index eb0167e0..a567c96a 100644 --- a/src/ahriman/core/auth/oauth.py +++ b/src/ahriman/core/auth/oauth.py @@ -23,6 +23,7 @@ from typing import Optional, Type from ahriman.core.auth.mapping import Mapping from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.exceptions import InvalidOption from ahriman.models.auth_settings import AuthSettings @@ -38,13 +39,15 @@ class OAuth(Mapping): :ivar scopes: list of scopes required by the application """ - def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.OAuth) -> None: + def __init__(self, configuration: Configuration, database: SQLite, + provider: AuthSettings = AuthSettings.OAuth) -> None: """ default constructor :param configuration: configuration instance + :param database: database instance :param provider: authorization type definition """ - Mapping.__init__(self, configuration, provider) + Mapping.__init__(self, configuration, database, provider) self.client_id = configuration.get("auth", "client_id") self.client_secret = configuration.get("auth", "client_secret") # in order to use OAuth feature the service must be publicity available diff --git a/src/ahriman/core/build_tools/sources.py b/src/ahriman/core/build_tools/sources.py index 20a97d9c..bdc07f24 100644 --- a/src/ahriman/core/build_tools/sources.py +++ b/src/ahriman/core/build_tools/sources.py @@ -47,6 +47,8 @@ class Sources: found_files: List[Path] = [] for glob in pattern: found_files.extend(sources_dir.glob(glob)) + if not found_files: + return # no additional files found Sources.logger.info("found matching files %s", found_files) # add them to index Sources._check_output("git", "add", "--intent-to-add", @@ -54,14 +56,13 @@ class Sources: exception=None, cwd=sources_dir, logger=Sources.logger) @staticmethod - def diff(sources_dir: Path, patch_path: Path) -> None: + def diff(sources_dir: Path) -> str: """ generate diff from the current version and write it to the output file :param sources_dir: local path to git repository - :param patch_path: path to result patch + :return: patch as plain string """ - patch = Sources._check_output("git", "diff", exception=None, cwd=sources_dir, logger=Sources.logger) - patch_path.write_text(patch) + return Sources._check_output("git", "diff", exception=None, cwd=sources_dir, logger=Sources.logger) @staticmethod def fetch(sources_dir: Path, remote: Optional[str]) -> None: @@ -112,41 +113,39 @@ class Sources: exception=None, cwd=sources_dir, logger=Sources.logger) @staticmethod - def load(sources_dir: Path, remote: str, patch_dir: Path) -> None: + def load(sources_dir: Path, remote: str, patch: Optional[str]) -> None: """ fetch sources from remote and apply patches :param sources_dir: local path to fetch :param remote: remote target (from where to fetch) - :param patch_dir: path to directory with package patches + :param patch: optional patch to be applied """ Sources.fetch(sources_dir, remote) - Sources.patch_apply(sources_dir, patch_dir) + if patch is None: + Sources.logger.info("no patches found") + return + Sources.patch_apply(sources_dir, patch) @staticmethod - def patch_apply(sources_dir: Path, patch_dir: Path) -> None: + def patch_apply(sources_dir: Path, patch: str) -> None: """ apply patches if any :param sources_dir: local path to directory with git sources - :param patch_dir: path to directory with package patches + :param patch: patch to be applied """ - # check if even there are patches - if not patch_dir.is_dir(): - return # no patches provided - # find everything that looks like patch and sort it - patches = sorted(patch_dir.glob("*.patch")) - Sources.logger.info("found %s patches", patches) - for patch in patches: - Sources.logger.info("apply patch %s", patch.name) - Sources._check_output("git", "apply", "--ignore-space-change", "--ignore-whitespace", str(patch), - exception=None, cwd=sources_dir, logger=Sources.logger) + # create patch + Sources.logger.info("apply patch from database") + Sources._check_output("git", "apply", "--ignore-space-change", "--ignore-whitespace", + exception=None, cwd=sources_dir, input_data=patch, logger=Sources.logger) @staticmethod - def patch_create(sources_dir: Path, patch_path: Path, *pattern: str) -> None: + def patch_create(sources_dir: Path, *pattern: str) -> str: """ create patch set for the specified local path :param sources_dir: local path to git repository - :param patch_path: path to result patch :param pattern: glob patterns + :return: patch as plain text """ Sources.add(sources_dir, *pattern) - Sources.diff(sources_dir, patch_path) + diff = Sources.diff(sources_dir) + return f"{diff}\n" # otherwise, patch will be broken diff --git a/src/ahriman/core/build_tools/task.py b/src/ahriman/core/build_tools/task.py index 52857fc8..bfeedd1e 100644 --- a/src/ahriman/core/build_tools/task.py +++ b/src/ahriman/core/build_tools/task.py @@ -21,10 +21,11 @@ import logging import shutil from pathlib import Path -from typing import List, Optional +from typing import List from ahriman.core.build_tools.sources import Sources from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.exceptions import BuildFailed from ahriman.core.util import check_output from ahriman.models.package import Package @@ -61,9 +62,10 @@ class Task: self.makepkg_flags = configuration.getlist("build", "makepkg_flags", fallback=[]) self.makechrootpkg_flags = configuration.getlist("build", "makechrootpkg_flags", fallback=[]) - def build(self) -> List[Path]: + def build(self, sources_path: Path) -> List[Path]: """ run package build + :param sources_path: path to where sources are :return: paths of produced packages """ command = [self.build_command, "-r", str(self.paths.chroot)] @@ -75,24 +77,24 @@ class Task: Task._check_output( *command, exception=BuildFailed(self.package.base), - cwd=self.paths.sources_for(self.package.base), + cwd=sources_path, logger=self.build_logger, user=self.uid) # well it is not actually correct, but we can deal with it packages = Task._check_output("makepkg", "--packagelist", exception=BuildFailed(self.package.base), - cwd=self.paths.sources_for(self.package.base), + cwd=sources_path, logger=self.build_logger).splitlines() return [Path(package) for package in packages] - def init(self, path: Optional[Path] = None) -> None: + def init(self, path: Path, database: SQLite) -> None: """ fetch package from git - :param path: optional local path to fetch. If not set default path will be used + :param path: local path to fetch + :param database: database instance """ - git_path = path or self.paths.sources_for(self.package.base) if self.paths.cache_for(self.package.base).is_dir(): # no need to clone whole repository, just copy from cache first - shutil.copytree(self.paths.cache_for(self.package.base), git_path) - Sources.load(git_path, self.package.git_url, self.paths.patches_for(self.package.base)) + shutil.copytree(self.paths.cache_for(self.package.base), path, dirs_exist_ok=True) + Sources.load(path, self.package.git_url, database.patches_get(self.package.base)) diff --git a/src/ahriman/core/configuration.py b/src/ahriman/core/configuration.py index 2b212632..0f0c47c5 100644 --- a/src/ahriman/core/configuration.py +++ b/src/ahriman/core/configuration.py @@ -28,6 +28,7 @@ from pathlib import Path from typing import Any, Dict, Generator, List, Optional, Tuple, Type from ahriman.core.exceptions import InitializeException +from ahriman.models.repository_paths import RepositoryPaths class Configuration(configparser.RawConfigParser): @@ -72,6 +73,14 @@ class Configuration(configparser.RawConfigParser): """ return self.getpath("settings", "logging") + @property + def repository_paths(self) -> RepositoryPaths: + """ + :return: repository paths instance + """ + _, architecture = self.check_loaded() + return RepositoryPaths(self.getpath("repository", "root"), architecture) + @classmethod def from_path(cls: Type[Configuration], path: Path, architecture: str, quiet: bool) -> Configuration: """ @@ -134,6 +143,15 @@ class Configuration(configparser.RawConfigParser): return path return self.path.parent / path + def check_loaded(self) -> Tuple[Path, str]: + """ + check if service was actually loaded + :return: configuration root path and architecture if loaded + """ + if self.path is None or self.architecture is None: + raise InitializeException("Configuration path and/or architecture are not set") + return self.path, self.architecture + def dump(self) -> Dict[str, Dict[str, str]]: """ dump configuration to dictionary @@ -233,12 +251,11 @@ class Configuration(configparser.RawConfigParser): """ reload configuration if possible or raise exception otherwise """ - if self.path is None or self.architecture is None: - raise InitializeException("Configuration path and/or architecture are not set") + path, architecture = self.check_loaded() for section in self.sections(): # clear current content self.remove_section(section) - self.load(self.path) - self.merge_sections(self.architecture) + self.load(path) + self.merge_sections(architecture) def set_option(self, section: str, option: str, value: Optional[str]) -> None: """ diff --git a/src/ahriman/core/database/__init__.py b/src/ahriman/core/database/__init__.py new file mode 100644 index 00000000..fb32931e --- /dev/null +++ b/src/ahriman/core/database/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright (c) 2021 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/core/database/data/__init__.py b/src/ahriman/core/database/data/__init__.py new file mode 100644 index 00000000..7b9aebb4 --- /dev/null +++ b/src/ahriman/core/database/data/__init__.py @@ -0,0 +1,43 @@ +# +# Copyright (c) 2021 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.configuration import Configuration +from ahriman.core.database.data.patches import migrate_patches +from ahriman.core.database.data.users import migrate_users_data +from ahriman.core.database.data.package_statuses import migrate_package_statuses +from ahriman.models.migration_result import MigrationResult +from ahriman.models.repository_paths import RepositoryPaths + + +def migrate_data(result: MigrationResult, connection: Connection, + configuration: Configuration, paths: RepositoryPaths) -> None: + """ + perform data migration + :param result: result of the schema migration + :param connection: database connection + :param configuration: configuration instance + :param paths: repository paths instance + """ + # initial data migration + if result.old_version == 0: + migrate_package_statuses(connection, paths) + migrate_users_data(connection, configuration) + migrate_patches(connection, paths) diff --git a/src/ahriman/core/database/data/package_statuses.py b/src/ahriman/core/database/data/package_statuses.py new file mode 100644 index 00000000..4a640ec7 --- /dev/null +++ b/src/ahriman/core/database/data/package_statuses.py @@ -0,0 +1,80 @@ +# +# Copyright (c) 2021 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 json + +from sqlite3 import Connection + +from ahriman.models.build_status import BuildStatus +from ahriman.models.package import Package +from ahriman.models.repository_paths import RepositoryPaths + + +def migrate_package_statuses(connection: Connection, paths: RepositoryPaths) -> None: + """ + perform migration for package statuses + :param connection: database connection + :param paths: repository paths instance + """ + def insert_base(metadata: Package, last_status: BuildStatus) -> None: + connection.execute( + """ + insert into package_bases + (package_base, version, aur_url) + values + (:package_base, :version, :aur_url) + """, + dict(package_base=metadata.base, version=metadata.version, aur_url=metadata.aur_url)) + connection.execute( + """ + insert into package_statuses + (package_base, status, last_updated) + values + (:package_base, :status, :last_updated)""", + dict(package_base=metadata.base, status=last_status.status.value, last_updated=last_status.timestamp)) + + def insert_packages(metadata: Package) -> None: + package_list = [] + for name, description in metadata.packages.items(): + package_list.append(dict(package=name, package_base=metadata.base, **description.view())) + connection.executemany( + """ + insert into packages + (package, package_base, architecture, archive_size, build_date, depends, description, + filename, "groups", installed_size, licenses, provides, url) + values + (:package, :package_base, :architecture, :archive_size, :build_date, :depends, :description, + :filename, :groups, :installed_size, :licenses, :provides, :url) + """, + package_list) + + cache_path = paths.root / "status_cache.json" + if not cache_path.is_file(): + return # no file found + with cache_path.open() as cache: + dump = json.load(cache) + + for item in dump.get("packages", []): + package = Package.from_json(item["package"]) + status = BuildStatus.from_json(item["status"]) + insert_base(package, status) + insert_packages(package) + + connection.commit() + cache_path.unlink() diff --git a/src/ahriman/core/database/data/patches.py b/src/ahriman/core/database/data/patches.py new file mode 100644 index 00000000..6ef5fe2c --- /dev/null +++ b/src/ahriman/core/database/data/patches.py @@ -0,0 +1,44 @@ +# +# Copyright (c) 2021 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.models.repository_paths import RepositoryPaths + + +def migrate_patches(connection: Connection, paths: RepositoryPaths) -> None: + """ + perform migration for patches + :param connection: database connection + :param paths: repository paths instance + """ + root = paths.root / "patches" + if not root.is_dir(): + return # no directory found + + for package in root.iterdir(): + patch_path = package / "00-main.patch" + if not patch_path.is_file(): + continue # not exist + content = patch_path.read_text(encoding="utf8") + connection.execute( + """insert into patches (package_base, patch) values (:package_base, :patch)""", + {"package_base": package.name, "patch": content}) + + connection.commit() diff --git a/src/ahriman/core/database/data/users.py b/src/ahriman/core/database/data/users.py new file mode 100644 index 00000000..937f1cc1 --- /dev/null +++ b/src/ahriman/core/database/data/users.py @@ -0,0 +1,40 @@ +# +# Copyright (c) 2021 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.configuration import Configuration + + +def migrate_users_data(connection: Connection, configuration: Configuration) -> None: + """ + perform migration for users + :param connection: database connection + :param configuration: configuration instance + """ + for section in configuration.sections(): + for option, value in configuration[section].items(): + if not section.startswith("auth:"): + continue + permission = section[5:] + connection.execute( + """insert into users (username, permission, password) values (:username, :permission, :password)""", + {"username": option.lower(), "permission": permission, "password": value}) + + connection.commit() diff --git a/src/ahriman/core/database/migrations/__init__.py b/src/ahriman/core/database/migrations/__init__.py new file mode 100644 index 00000000..dfdcb798 --- /dev/null +++ b/src/ahriman/core/database/migrations/__init__.py @@ -0,0 +1,125 @@ +# +# Copyright (c) 2021 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 __future__ import annotations + +import logging + +from importlib import import_module +from pathlib import Path +from pkgutil import iter_modules +from sqlite3 import Connection +from typing import List, Type + +from ahriman.models.migration import Migration +from ahriman.models.migration_result import MigrationResult + + +class Migrations: + """ + simple migration wrapper for the sqlite + idea comes from https://www.ash.dev/blog/simple-migration-system-in-sqlite/ + :ivar connection: database connection + :ivar logger: class logger + """ + + def __init__(self, connection: Connection) -> None: + """ + default constructor + :param connection: database connection + """ + self.connection = connection + self.logger = logging.getLogger("database") + + @classmethod + def migrate(cls: Type[Migrations], connection: Connection) -> MigrationResult: + """ + perform migrations implicitly + :param connection: database connection + :return: current schema version + """ + return cls(connection).run() + + def migrations(self) -> List[Migration]: + """ + extract all migrations from the current package + idea comes from https://julienharbulot.com/python-dynamical-import.html + + """ + migrations: List[Migration] = [] + package_dir = Path(__file__).resolve().parent + modules = [module_name for (_, module_name, _) in iter_modules([str(package_dir)])] + + for index, module_name in enumerate(sorted(modules)): + module = import_module(f"{__name__}.{module_name}") + steps: List[str] = getattr(module, "steps", []) + self.logger.debug("found migration %s at index %s with steps count %s", module_name, index, len(steps)) + migrations.append(Migration(index, module_name, steps)) + + return migrations + + def run(self) -> MigrationResult: + """ + perform migrations + :return: current schema version + """ + migrations = self.migrations() + current_version = self.user_version() + expected_version = len(migrations) + result = MigrationResult(current_version, expected_version) + + if not result.is_outdated: + self.logger.info("no migrations required") + return result + + previous_isolation = self.connection.isolation_level + try: + self.connection.isolation_level = None + cursor = self.connection.cursor() + try: + cursor.execute("begin exclusive") + for migration in migrations[current_version:]: + self.logger.info("applying migration %s at index %s", migration.name, migration.index) + for statement in migration.steps: + cursor.execute(statement) + self.logger.info("migration %s at index %s has been applied", migration.name, migration.index) + + cursor.execute(f"pragma user_version = {expected_version}") # no support for ? placeholders + except Exception: + self.logger.exception("migration failed with exception") + cursor.execute("rollback") + raise + else: + cursor.execute("commit") + finally: + cursor.close() + finally: + self.connection.isolation_level = previous_isolation + + self.logger.info("migrations have been performed from version %s to %s", result.old_version, result.new_version) + return result + + def user_version(self) -> int: + """ + get schema version from sqlite database + ;return: current schema version + """ + cursor = self.connection.execute("pragma user_version") + current_version: int = cursor.fetchone()["user_version"] + return current_version diff --git a/src/ahriman/core/database/migrations/m000_initial.py b/src/ahriman/core/database/migrations/m000_initial.py new file mode 100644 index 00000000..a9c6059d --- /dev/null +++ b/src/ahriman/core/database/migrations/m000_initial.py @@ -0,0 +1,73 @@ +# +# Copyright (c) 2021 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 . +# + +steps = [ + """ + create table build_queue ( + package_base text not null unique, + properties json not null + ) + """, + """ + create table package_bases ( + package_base text not null unique, + version text not null, + aur_url text not null + ) + """, + """ + create table package_statuses ( + package_base text not null unique, + status text not null, + last_updated integer + ) + """, + """ + create table packages ( + package text not null, + package_base text not null, + architecture text, + archive_size integer, + build_date integer, + depends json, + description text, + filename text, + "groups" json, + installed_size integer, + licenses json, + provides json, + url text, + unique (package, architecture) + ) + """, + """ + create table patches ( + package_base text not null unique, + patch blob not null + ) + """, + """ + create table users ( + username text not null unique, + access text not null, + password text + ) + """, +] diff --git a/src/ahriman/core/database/operations/__init__.py b/src/ahriman/core/database/operations/__init__.py new file mode 100644 index 00000000..fb32931e --- /dev/null +++ b/src/ahriman/core/database/operations/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright (c) 2021 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/core/database/operations/auth_operations.py b/src/ahriman/core/database/operations/auth_operations.py new file mode 100644 index 00000000..d3456512 --- /dev/null +++ b/src/ahriman/core/database/operations/auth_operations.py @@ -0,0 +1,93 @@ +# +# Copyright (c) 2021 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 __future__ import annotations + +from sqlite3 import Connection +from typing import List, Optional + +from ahriman.core.database.operations.operations import Operations +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess + + +class AuthOperations(Operations): + """ + authorization operations + """ + + def user_get(self, username: str) -> Optional[User]: + """ + get user by username + :param username: username + :return: user if it was found + """ + return next(iter(self.user_list(username, None)), None) + + def user_list(self, username: Optional[str], access: Optional[UserAccess]) -> List[User]: + """ + get users by filter + :param username: optional filter by username + :param access: optional filter by role + :return: list of users who match criteria + """ + username_filter = username.lower() if username is not None else username + access_filter = access.value if access is not None else access + + def run(connection: Connection) -> List[User]: + return [ + User(cursor["username"], cursor["password"], UserAccess(cursor["access"])) + for cursor in connection.execute( + """ + select * from users + where (:username is null or username = :username) and (:access is null or access = :access) + """, + {"username": username_filter, "access": access_filter}) + ] + + return self.with_connection(run) + + def user_remove(self, username: str) -> None: + """ + remove user from storage + :param username: username + """ + def run(connection: Connection) -> None: + connection.execute("""delete from users where username = :username""", {"username": username.lower()}) + + return self.with_connection(run, commit=True) + + def user_update(self, user: User) -> None: + """ + get user by username + :param user: user descriptor + """ + def run(connection: Connection) -> None: + connection.execute( + """ + insert into users + (username, access, password) + values + (:username, :access, :password) + on conflict (username) do update set + access = :access, password = :password + """, + {"username": user.username.lower(), "access": user.access.value, "password": user.password}) + + self.with_connection(run, commit=True) diff --git a/src/ahriman/core/database/operations/build_operations.py b/src/ahriman/core/database/operations/build_operations.py new file mode 100644 index 00000000..c826f950 --- /dev/null +++ b/src/ahriman/core/database/operations/build_operations.py @@ -0,0 +1,77 @@ +# +# Copyright (c) 2021 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 typing import List, Optional + +from ahriman.core.database.operations.operations import Operations +from ahriman.models.package import Package + + +class BuildOperations(Operations): + """ + operations for main functions + """ + + def build_queue_clear(self, package_base: Optional[str]) -> None: + """ + remove packages from build queue + :param package_base: optional filter by package base + """ + def run(connection: Connection) -> None: + connection.execute( + """ + delete from build_queue + where :package_base is null or package_base = :package_base + """, + {"package_base": package_base}) + + return self.with_connection(run, commit=True) + + def build_queue_get(self) -> List[Package]: + """ + retrieve packages from build queue + :return: list of packages to be built + """ + def run(connection: Connection) -> List[Package]: + return [ + Package.from_json(row["properties"]) + for row in connection.execute("""select * from build_queue""") + ] + + return self.with_connection(run) + + def build_queue_insert(self, package: Package) -> None: + """ + insert packages to build queue + :param package: package to be inserted + """ + def run(connection: Connection) -> None: + connection.execute( + """ + insert into build_queue + (package_base, properties) + values + (:package_base, :properties) + on conflict (package_base) do update set + properties = :properties + """, + {"package_base": package.base, "properties": package.view()}) + + return self.with_connection(run, commit=True) diff --git a/src/ahriman/core/database/operations/operations.py b/src/ahriman/core/database/operations/operations.py new file mode 100644 index 00000000..a52d3d70 --- /dev/null +++ b/src/ahriman/core/database/operations/operations.py @@ -0,0 +1,71 @@ +# +# Copyright (c) 2021 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 logging +import sqlite3 + +from pathlib import Path +from sqlite3 import Connection, Cursor +from typing import Any, Dict, Tuple, TypeVar, Callable + + +T = TypeVar("T") + + +class Operations: + """ + base operation class + :ivar logger: class logger + :ivar path: path to the database file + """ + + def __init__(self, path: Path) -> None: + """ + default constructor + :param path: path to the database file + """ + self.path = path + self.logger = logging.getLogger("database") + + @staticmethod + def factory(cursor: Cursor, row: Tuple[Any, ...]) -> Dict[str, Any]: + """ + dictionary factory based on official documentation + :param cursor: cursor descriptor + :param row: fetched row + :return: row converted to dictionary + """ + result = {} + for index, column in enumerate(cursor.description): + result[column[0]] = row[index] + return result + + def with_connection(self, query: Callable[[Connection], T], commit: bool = False) -> T: + """ + perform operation in connection + :param query: function to be called with connection + :param commit: if True commit() will be called on success + :return: result of the `query` call + """ + with sqlite3.connect(self.path, detect_types=sqlite3.PARSE_DECLTYPES) as connection: + connection.row_factory = self.factory + result = query(connection) + if commit: + connection.commit() + return result diff --git a/src/ahriman/core/database/operations/package_operations.py b/src/ahriman/core/database/operations/package_operations.py new file mode 100644 index 00000000..21ac2fbe --- /dev/null +++ b/src/ahriman/core/database/operations/package_operations.py @@ -0,0 +1,199 @@ +# +# Copyright (c) 2021 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 typing import Dict, Generator, Iterable, List, Tuple + +from ahriman.core.database.operations.operations import Operations +from ahriman.models.build_status import BuildStatus +from ahriman.models.package import Package +from ahriman.models.package_description import PackageDescription + + +class PackageOperations(Operations): + """ + package operations + """ + + @staticmethod + def _package_remove_package_base(connection: Connection, package_base: str) -> None: + """ + remove package base information + :param connection: database connection + :param package_base: package base name + """ + connection.execute("""delete from package_statuses where package_base = :package_base""", + {"package_base": package_base}) + connection.execute("""delete from package_bases where package_base = :package_base""", + {"package_base": package_base}) + + @staticmethod + def _package_remove_packages(connection: Connection, package_base: str, current_packages: Iterable[str]) -> None: + """ + remove packages belong to the package base + :param connection: database connection + :param package_base: package base name + :param current_packages: current packages list which has to be left in database + """ + packages = [ + package + for package in connection.execute( + """select package from packages where package_base = :package_base""", {"package_base": package_base}) + if package["package"] not in current_packages + ] + connection.executemany("""delete from packages where package = :package""", packages) + + @staticmethod + def _package_update_insert_base(connection: Connection, package: Package) -> None: + """ + insert base package into table + :param connection: database connection + :param package: package properties + """ + connection.execute( + """ + insert into package_bases + (package_base, version, aur_url) + values + (:package_base, :version, :aur_url) + on conflict (package_base) do update set + version = :version, aur_url = :aur_url + """, + dict(package_base=package.base, version=package.version, aur_url=package.aur_url)) + + @staticmethod + def _package_update_insert_packages(connection: Connection, package: Package) -> None: + """ + insert packages into table + :param connection: database connection + :param package: package properties + """ + package_list = [] + for name, description in package.packages.items(): + package_list.append(dict(package=name, package_base=package.base, **description.view())) + connection.executemany( + """ + insert into packages + (package, package_base, architecture, archive_size, + build_date, depends, description, filename, + "groups", installed_size, licenses, provides, url) + values + (:package, :package_base, :architecture, :archive_size, + :build_date, :depends, :description, :filename, + :groups, :installed_size, :licenses, :provides, :url) + on conflict (package, architecture) do update set + package_base = :package_base, archive_size = :archive_size, + build_date = :build_date, depends = :depends, description = :description, filename = :filename, + "groups" = :groups, installed_size = :installed_size, licenses = :licenses, provides = :provides, url = :url + """, + package_list) + + @staticmethod + def _package_update_insert_status(connection: Connection, package_base: str, status: BuildStatus) -> None: + """ + insert base package status into table + :param connection: database connection + :param package_base: package base name + :param status: new build status + """ + connection.execute( + """ + insert into package_statuses (package_base, status, last_updated) + values + (:package_base, :status, :last_updated) + on conflict (package_base) do update set + status = :status, last_updated = :last_updated + """, + dict(package_base=package_base, status=status.status.value, last_updated=status.timestamp)) + + @staticmethod + def _packages_get_select_package_bases(connection: Connection) -> Dict[str, Package]: + """ + select package bases from the table + :param connection: database connection + :return: map of the package base to its descriptor (without packages themselves) + """ + return { + row["package_base"]: Package(row["package_base"], row["version"], row["aur_url"], {}) + for row in connection.execute("""select * from package_bases""") + } + + @staticmethod + def _packages_get_select_packages(connection: Connection, packages: Dict[str, Package]) -> Dict[str, Package]: + """ + select packages from the table + :param connection: database connection + :param packages: packages descriptor map + :return: map of the package base to its descriptor including individual packages + """ + for row in connection.execute("""select * from packages"""): + if row["package_base"] not in packages: + continue # normally must never happen though + packages[row["package_base"]].packages[row["package"]] = PackageDescription.from_json(row) + return packages + + @staticmethod + def _packages_get_select_statuses(connection: Connection) -> Dict[str, BuildStatus]: + """ + select package build statuses from the table + :param connection: database connection + :return: map of the package base to its status + """ + return { + row["package_base"]: BuildStatus.from_json({"status": row["status"], "timestamp": row["last_updated"]}) + for row in connection.execute("""select * from package_statuses""") + } + + def package_remove(self, package_base: str) -> None: + """ + remove package from database + :param package_base: package base name + """ + def run(connection: Connection) -> None: + self._package_remove_packages(connection, package_base, []) + self._package_remove_package_base(connection, package_base) + + return self.with_connection(run, commit=True) + + def package_update(self, package: Package, status: BuildStatus) -> None: + """ + update package status + :param package: package properties + :param status: new build status + """ + def run(connection: Connection) -> None: + self._package_update_insert_base(connection, package) + self._package_update_insert_status(connection, package.base, status) + self._package_update_insert_packages(connection, package) + self._package_remove_packages(connection, package.base, package.packages.keys()) + + return self.with_connection(run, commit=True) + + def packages_get(self) -> List[Tuple[Package, BuildStatus]]: + """ + get package list and their build statuses from database + :return: list of package properties and their statuses + """ + def run(connection: Connection) -> Generator[Tuple[Package, BuildStatus], None, None]: + packages = self._packages_get_select_package_bases(connection) + statuses = self._packages_get_select_statuses(connection) + for package_base, package in self._packages_get_select_packages(connection, packages).items(): + yield package, statuses.get(package_base, BuildStatus()) + + return self.with_connection(lambda connection: list(run(connection))) diff --git a/src/ahriman/core/database/operations/patch_operations.py b/src/ahriman/core/database/operations/patch_operations.py new file mode 100644 index 00000000..67980689 --- /dev/null +++ b/src/ahriman/core/database/operations/patch_operations.py @@ -0,0 +1,85 @@ +# +# Copyright (c) 2021 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 typing import Dict, Optional + +from ahriman.core.database.operations.operations import Operations + + +class PatchOperations(Operations): + """ + operations for patches + """ + + def patches_get(self, package_base: str) -> Optional[str]: + """ + retrieve patches for the package + :param package_base: package base to search for patches + :return: plain text patch for the package + """ + return self.patches_list(package_base).get(package_base) + + def patches_insert(self, package_base: str, patch: str) -> None: + """ + insert or update patch in database + :param package_base: package base to insert + :param patch: patch content + """ + def run(connection: Connection) -> None: + connection.execute( + """ + insert into patches + (package_base, patch) + values + (:package_base, :patch) + on conflict (package_base) do update set + patch = :patch + """, + {"package_base": package_base, "patch": patch}) + + return self.with_connection(run, commit=True) + + def patches_list(self, package_base: Optional[str]) -> Dict[str, str]: + """ + extract all patches + :param package_base: optional filter by package base + :return: map of package base to patch content + """ + def run(connection: Connection) -> Dict[str, str]: + return { + cursor["package_base"]: cursor["patch"] + for cursor in connection.execute( + """select * from patches where :package_base is null or package_base = :package_base""", + {"package_base": package_base}) + } + + return self.with_connection(run) + + def patches_remove(self, package_base: str) -> None: + """ + remove patch set + :param package_base: package base to clear patches + """ + def run(connection: Connection) -> None: + connection.execute( + """delete from patches where package_base = :package_base""", + {"package_base": package_base}) + + return self.with_connection(run, commit=True) diff --git a/src/ahriman/core/database/sqlite.py b/src/ahriman/core/database/sqlite.py new file mode 100644 index 00000000..725307ee --- /dev/null +++ b/src/ahriman/core/database/sqlite.py @@ -0,0 +1,70 @@ +# +# Copyright (c) 2021 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 __future__ import annotations + +import json +import sqlite3 + +from sqlite3 import Connection +from typing import Type + +from ahriman.core.configuration import Configuration +from ahriman.core.database.data import migrate_data +from ahriman.core.database.migrations import Migrations +from ahriman.core.database.operations.auth_operations import AuthOperations +from ahriman.core.database.operations.build_operations import BuildOperations +from ahriman.core.database.operations.package_operations import PackageOperations +from ahriman.core.database.operations.patch_operations import PatchOperations + + +class SQLite(AuthOperations, BuildOperations, PackageOperations, PatchOperations): + """ + wrapper for sqlite3 database + """ + + @classmethod + def load(cls: Type[SQLite], configuration: Configuration) -> SQLite: + """ + construct instance from configuration + :param configuration: configuration instance + :return: fully initialized instance of the database + """ + database = cls(configuration.getpath("settings", "database")) + database.init(configuration) + return database + + def init(self, configuration: Configuration) -> None: + """ + perform database migrations + :param configuration: configuration instance + """ + # custom types support + sqlite3.register_adapter(dict, json.dumps) + sqlite3.register_adapter(list, json.dumps) + sqlite3.register_converter("json", json.loads) + + paths = configuration.repository_paths + + def run(connection: Connection) -> None: + result = Migrations.migrate(connection) + migrate_data(result, connection, configuration, paths) + + self.with_connection(run) + paths.chown(self.path) diff --git a/src/ahriman/core/exceptions.py b/src/ahriman/core/exceptions.py index f37ea3cc..a98b9610 100644 --- a/src/ahriman/core/exceptions.py +++ b/src/ahriman/core/exceptions.py @@ -26,12 +26,12 @@ class BuildFailed(RuntimeError): base exception for failed builds """ - def __init__(self, package: str) -> None: + def __init__(self, package_base: str) -> None: """ default constructor - :param package: package base raised exception + :param package_base: package base raised exception """ - RuntimeError.__init__(self, f"Package {package} build failed, check logs for details") + RuntimeError.__init__(self, f"Package {package_base} build failed, check logs for details") class DuplicateRun(RuntimeError): @@ -47,18 +47,11 @@ class DuplicateRun(RuntimeError): self, "Another application instance is run. This error can be suppressed by using --force flag.") -class DuplicateUser(ValueError): +class ExitCode(RuntimeError): """ - exception which will be thrown in case if there are two users with different settings + special exception which has to be thrown to return non-zero status without error message """ - def __init__(self, username: str) -> None: - """ - default constructor - :param username: username with duplicates - """ - ValueError.__init__(self, f"Found duplicate user with username {username}") - class InitializeException(RuntimeError): """ @@ -113,6 +106,19 @@ class InvalidPackageInfo(RuntimeError): RuntimeError.__init__(self, f"There are errors during reading package information: `{details}`") +class MigrationError(RuntimeError): + """ + exception which will be raised on migration error + """ + + def __init__(self, details: str) -> None: + """ + default constructor + :param details: error details + """ + RuntimeError.__init__(self, details) + + class MissingArchitecture(ValueError): """ exception which will be raised if architecture is required, but missing diff --git a/src/ahriman/core/formatters/user_printer.py b/src/ahriman/core/formatters/user_printer.py new file mode 100644 index 00000000..0db9b1c2 --- /dev/null +++ b/src/ahriman/core/formatters/user_printer.py @@ -0,0 +1,46 @@ +# +# Copyright (c) 2021 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 typing import List + +from ahriman.core.formatters.string_printer import StringPrinter +from ahriman.models.property import Property +from ahriman.models.user import User + + +class UserPrinter(StringPrinter): + """ + print properties of user + :ivar user: stored user + """ + + def __init__(self, user: User) -> None: + """ + default constructor + :param user: user to print + """ + StringPrinter.__init__(self, user.username) + self.user = user + + def properties(self) -> List[Property]: + """ + convert content into printable data + :return: list of content properties + """ + return [Property("role", self.user.access.value, is_required=True)] diff --git a/src/ahriman/core/repository/cleaner.py b/src/ahriman/core/repository/cleaner.py index bdd8e7d4..c8f298cf 100644 --- a/src/ahriman/core/repository/cleaner.py +++ b/src/ahriman/core/repository/cleaner.py @@ -37,14 +37,6 @@ class Cleaner(Properties): """ raise NotImplementedError - def clear_build(self) -> None: - """ - clear sources directory - """ - self.logger.info("clear package sources directory") - for package in self.paths.sources.iterdir(): - shutil.rmtree(package) - def clear_cache(self) -> None: """ clear cache directory @@ -61,14 +53,6 @@ class Cleaner(Properties): for chroot in self.paths.chroot.iterdir(): shutil.rmtree(chroot) - def clear_manual(self) -> None: - """ - clear directory with manual package updates - """ - self.logger.info("clear manual packages") - for package in self.paths.manual.iterdir(): - shutil.rmtree(package) - def clear_packages(self) -> None: """ clear directory with built packages (NOT repository itself) @@ -77,10 +61,9 @@ class Cleaner(Properties): for package in self.packages_built(): package.unlink() - def clear_patches(self) -> None: + def clear_queue(self) -> None: """ - clear directory with patches + clear packages which were queued for the update """ - self.logger.info("clear patches directory") - for package in self.paths.patches.iterdir(): - shutil.rmtree(package) + self.logger.info("clear build queue") + self.database.build_queue_clear(None) diff --git a/src/ahriman/core/repository/executor.py b/src/ahriman/core/repository/executor.py index 1403d9ce..04cd6b50 100644 --- a/src/ahriman/core/repository/executor.py +++ b/src/ahriman/core/repository/executor.py @@ -26,6 +26,7 @@ from ahriman.core.build_tools.task import Task from ahriman.core.report.report import Report from ahriman.core.repository.cleaner import Cleaner from ahriman.core.upload.upload import Upload +from ahriman.core.util import tmpdir from ahriman.models.package import Package from ahriman.models.result import Result @@ -56,25 +57,25 @@ class Executor(Cleaner): :param updates: list of packages properties to build :return: `packages_built` """ - def build_single(package: Package) -> None: + def build_single(package: Package, local_path: Path) -> None: self.reporter.set_building(package.base) task = Task(package, self.configuration, self.paths) - task.init() - built = task.build() + task.init(local_path, self.database) + built = task.build(local_path) for src in built: dst = self.paths.packages / src.name shutil.move(src, dst) result = Result() for single in updates: - try: - build_single(single) - result.add_success(single) - except Exception: - self.reporter.set_failed(single.base) - result.add_failed(single) - self.logger.exception("%s (%s) build exception", single.base, self.architecture) - self.clear_build() + with tmpdir() as build_dir: + try: + build_single(single, build_dir) + result.add_success(single) + except Exception: + self.reporter.set_failed(single.base) + result.add_failed(single) + self.logger.exception("%s (%s) build exception", single.base, self.architecture) return result @@ -87,6 +88,8 @@ class Executor(Cleaner): def remove_base(package_base: str) -> None: try: self.paths.tree_clear(package_base) # remove all internal files + self.database.build_queue_clear(package_base) + self.database.patches_remove(package_base) self.reporter.remove(package_base) # we only update status page in case of base removal except Exception: self.logger.exception("could not remove base %s", package_base) diff --git a/src/ahriman/core/repository/properties.py b/src/ahriman/core/repository/properties.py index 10918b37..d98ee948 100644 --- a/src/ahriman/core/repository/properties.py +++ b/src/ahriman/core/repository/properties.py @@ -22,11 +22,11 @@ import logging from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.repo import Repo from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.exceptions import UnsafeRun from ahriman.core.sign.gpg import GPG from ahriman.core.status.client import Client from ahriman.core.util import check_user -from ahriman.models.repository_paths import RepositoryPaths class Properties: @@ -35,6 +35,7 @@ class Properties: :ivar architecture: repository architecture :ivar aur_url: base AUR url :ivar configuration: configuration instance + :ivar database: database instance :ivar ignore_list: package bases which will be ignored during auto updates :ivar logger: class logger :ivar name: repository name @@ -45,22 +46,25 @@ class Properties: :ivar sign: GPG wrapper instance """ - def __init__(self, architecture: str, configuration: Configuration, no_report: bool, unsafe: bool) -> None: + def __init__(self, architecture: str, configuration: Configuration, database: SQLite, + no_report: bool, unsafe: bool) -> None: """ default constructor :param architecture: repository architecture :param configuration: configuration instance + :param database: database instance :param no_report: force disable reporting :param unsafe: if set no user check will be performed before path creation """ self.logger = logging.getLogger("root") self.architecture = architecture self.configuration = configuration + self.database = database self.aur_url = configuration.get("alpm", "aur_url") self.name = configuration.get("repository", "name") - self.paths = RepositoryPaths(configuration.getpath("repository", "root"), architecture) + self.paths = configuration.repository_paths try: check_user(self.paths, unsafe) self.paths.tree_create() diff --git a/src/ahriman/core/repository/update_handler.py b/src/ahriman/core/repository/update_handler.py index 2ec463dc..49d62787 100644 --- a/src/ahriman/core/repository/update_handler.py +++ b/src/ahriman/core/repository/update_handler.py @@ -90,7 +90,7 @@ class UpdateHandler(Cleaner): else: self.reporter.set_success(local) except Exception: - self.logger.exception("could not procees package at %s", dirname) + self.logger.exception("could not process package at %s", dirname) return result @@ -102,16 +102,15 @@ class UpdateHandler(Cleaner): result: List[Package] = [] known_bases = {package.base for package in self.packages()} - for dirname in self.paths.manual.iterdir(): - try: - local = Package.load(str(dirname), PackageSource.Local, self.pacman, self.aur_url) + try: + for local in self.database.build_queue_get(): result.append(local) if local.base not in known_bases: self.reporter.set_unknown(local) else: self.reporter.set_pending(local.base) - except Exception: - self.logger.exception("could not add package from %s", dirname) - self.clear_manual() + except Exception: + self.logger.exception("could not load packages from database") + self.clear_queue() return result diff --git a/src/ahriman/core/status/client.py b/src/ahriman/core/status/client.py index e605f257..0129a4b9 100644 --- a/src/ahriman/core/status/client.py +++ b/src/ahriman/core/status/client.py @@ -77,11 +77,6 @@ class Client: """ return BuildStatus() - def reload_auth(self) -> None: - """ - reload authentication module call - """ - def remove(self, base: str) -> None: """ remove packages from watcher diff --git a/src/ahriman/core/status/watcher.py b/src/ahriman/core/status/watcher.py index f918b72c..1bead116 100644 --- a/src/ahriman/core/status/watcher.py +++ b/src/ahriman/core/status/watcher.py @@ -17,13 +17,12 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -import json import logging -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.exceptions import UnknownPackage from ahriman.core.repository import Repository from ahriman.models.build_status import BuildStatus, BuildStatusEnum @@ -34,33 +33,29 @@ class Watcher: """ package status watcher :ivar architecture: repository architecture + :ivar database: database instance :ivar known: list of known packages. For the most cases `packages` should be used instead :ivar logger: class logger :ivar repository: repository object :ivar status: daemon status """ - def __init__(self, architecture: str, configuration: Configuration) -> None: + def __init__(self, architecture: str, configuration: Configuration, database: SQLite) -> None: """ default constructor :param architecture: repository architecture :param configuration: configuration instance + :param database: database instance """ self.logger = logging.getLogger("http") self.architecture = architecture - self.repository = Repository(architecture, configuration, no_report=True, unsafe=False) + self.database = database + self.repository = Repository(architecture, configuration, database, no_report=True, unsafe=False) self.known: Dict[str, Tuple[Package, BuildStatus]] = {} self.status = BuildStatus() - @property - def cache_path(self) -> Path: - """ - :return: path to dump with json cache - """ - return self.repository.paths.root / "status_cache.json" - @property def packages(self) -> List[Tuple[Package, BuildStatus]]: """ @@ -68,48 +63,6 @@ class Watcher: """ return list(self.known.values()) - def _cache_load(self) -> None: - """ - update current state from cache - """ - def parse_single(properties: Dict[str, Any]) -> None: - package = Package.from_json(properties["package"]) - status = BuildStatus.from_json(properties["status"]) - if package.base in self.known: - self.known[package.base] = (package, status) - - if not self.cache_path.is_file(): - return - with self.cache_path.open() as cache: - try: - dump = json.load(cache) - except Exception: - self.logger.exception("cannot parse json from file") - dump = {} - for item in dump.get("packages", []): - try: - parse_single(item) - except Exception: - self.logger.exception("cannot parse item %s to package", item) - - def _cache_save(self) -> None: - """ - dump current cache to filesystem - """ - dump = { - "packages": [ - { - "package": package.view(), - "status": status.view() - } for package, status in self.packages - ] - } - try: - with self.cache_path.open("w") as cache: - json.dump(dump, cache) - except Exception: - self.logger.exception("cannot dump cache") - def get(self, base: str) -> Tuple[Package, BuildStatus]: """ get current package base build status @@ -131,31 +84,34 @@ class Watcher: else: status = BuildStatus() self.known[package.base] = (package, status) - self._cache_load() - def remove(self, base: str) -> None: + for package, status in self.database.packages_get(): + if package.base in self.known: + self.known[package.base] = (package, status) + + def remove(self, package_base: str) -> None: """ remove package base from known list if any - :param base: package base + :param package_base: package base """ - self.known.pop(base, None) - self._cache_save() + self.known.pop(package_base, None) + self.database.package_remove(package_base) - def update(self, base: str, status: BuildStatusEnum, package: Optional[Package]) -> None: + def update(self, package_base: str, status: BuildStatusEnum, package: Optional[Package]) -> None: """ update package status and description - :param base: package base to update + :param package_base: package base to update :param status: new build status :param package: optional new package description. In case if not set current properties will be used """ if package is None: try: - package, _ = self.known[base] + package, _ = self.known[package_base] except KeyError: - raise UnknownPackage(base) + raise UnknownPackage(package_base) full_status = BuildStatus(status) - self.known[base] = (package, full_status) - self._cache_save() + self.known[package_base] = (package, full_status) + self.database.package_update(package, full_status) def update_self(self, status: BuildStatusEnum) -> None: """ diff --git a/src/ahriman/core/status/web_client.py b/src/ahriman/core/status/web_client.py index c516ced5..dff2e9fd 100644 --- a/src/ahriman/core/status/web_client.py +++ b/src/ahriman/core/status/web_client.py @@ -67,13 +67,6 @@ class WebClient(Client): """ return f"{self.address}/user-api/v1/login" - @property - def _reload_auth_url(self) -> str: - """ - :return: full url for web service to reload authentication module - """ - return f"{self.address}/service-api/v1/reload-auth" - @property def _status_url(self) -> str: """ @@ -198,18 +191,6 @@ class WebClient(Client): self.logger.exception("could not get service status") return BuildStatus() - def reload_auth(self) -> None: - """ - reload authentication module call - """ - try: - response = self.__session.post(self._reload_auth_url) - response.raise_for_status() - except requests.HTTPError as e: - self.logger.exception("could not reload auth module: %s", exception_response_text(e)) - except Exception: - self.logger.exception("could not reload auth module") - def remove(self, base: str) -> None: """ remove packages from watcher diff --git a/src/ahriman/core/tree.py b/src/ahriman/core/tree.py index e7cb7ff7..4cd193da 100644 --- a/src/ahriman/core/tree.py +++ b/src/ahriman/core/tree.py @@ -26,8 +26,8 @@ from pathlib import Path from typing import Iterable, List, Set, Type from ahriman.core.build_tools.sources import Sources +from ahriman.core.database.sqlite import SQLite from ahriman.models.package import Package -from ahriman.models.repository_paths import RepositoryPaths class Leaf: @@ -54,16 +54,16 @@ class Leaf: return self.package.packages.keys() @classmethod - def load(cls: Type[Leaf], package: Package, paths: RepositoryPaths) -> Leaf: + def load(cls: Type[Leaf], package: Package, database: SQLite) -> Leaf: """ load leaf from package with dependencies :param package: package properties - :param paths: repository paths instance + :param database: database instance :return: loaded class """ clone_dir = Path(tempfile.mkdtemp()) try: - Sources.load(clone_dir, package.git_url, paths.patches_for(package.base)) + Sources.load(clone_dir, package.git_url, database.patches_get(package.base)) dependencies = Package.dependencies(clone_dir) finally: shutil.rmtree(clone_dir, ignore_errors=True) @@ -95,14 +95,14 @@ class Tree: self.leaves = leaves @classmethod - def load(cls: Type[Tree], packages: Iterable[Package], paths: RepositoryPaths) -> Tree: + def load(cls: Type[Tree], packages: Iterable[Package], database: SQLite) -> Tree: """ load tree from packages :param packages: packages list - :param paths: repository paths instance + :param database: database instance :return: loaded class """ - return cls([Leaf.load(package, paths) for package in packages]) + return cls([Leaf.load(package, database) for package in packages]) def levels(self) -> List[List[Package]]: """ diff --git a/src/ahriman/core/util.py b/src/ahriman/core/util.py index bb768dd9..cbe7d43f 100644 --- a/src/ahriman/core/util.py +++ b/src/ahriman/core/util.py @@ -19,9 +19,12 @@ # import datetime import os -import subprocess import requests +import shutil +import subprocess +import tempfile +from contextlib import contextmanager from logging import Logger from pathlib import Path from typing import Any, Dict, Generator, Iterable, Optional, Union @@ -143,6 +146,19 @@ def pretty_size(size: Optional[float], level: int = 0) -> str: return pretty_size(size / 1024, level + 1) +@contextmanager +def tmpdir() -> Generator[Path, None, None]: + """ + wrapper for tempfile to remove directory after all + :return: path to the created directory + """ + path = Path(tempfile.mkdtemp()) + try: + yield path + finally: + shutil.rmtree(path, ignore_errors=True) + + def walk(directory_path: Path) -> Generator[Path, None, None]: """ list all file paths in given directory diff --git a/src/ahriman/models/migration.py b/src/ahriman/models/migration.py new file mode 100644 index 00000000..474193c9 --- /dev/null +++ b/src/ahriman/models/migration.py @@ -0,0 +1,35 @@ +# +# Copyright (c) 2021 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 +from typing import List + + +@dataclass +class Migration: + """ + migration implementation + :ivar index: migration position + :ivar name: migration name + :ivar steps: migration steps + """ + + index: int + name: str + steps: List[str] diff --git a/src/ahriman/models/migration_result.py b/src/ahriman/models/migration_result.py new file mode 100644 index 00000000..f432ad89 --- /dev/null +++ b/src/ahriman/models/migration_result.py @@ -0,0 +1,50 @@ +# +# Copyright (c) 2021 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 + +from ahriman.core.exceptions import MigrationError + + +@dataclass +class MigrationResult: + """ + migration result implementation model + :ivar old_version: old schema version before migrations + :ivar new_version: new schema version after migrations + """ + + old_version: int + new_version: int + + @property + def is_outdated(self) -> bool: + """ + :return: True in case if it requires migrations and False otherwise + """ + self.validate() + return self.new_version > self.old_version + + def validate(self) -> None: + """ + perform version validation + """ + if self.old_version < 0 or self.old_version > self.new_version: + raise MigrationError(f"Invalid current schema version, expected less or equal to {self.new_version}, " + f"got {self.old_version}") diff --git a/src/ahriman/models/package.py b/src/ahriman/models/package.py index 63db4b66..5915e924 100644 --- a/src/ahriman/models/package.py +++ b/src/ahriman/models/package.py @@ -239,7 +239,7 @@ class Package: from ahriman.core.build_tools.sources import Sources logger = logging.getLogger("build_details") - Sources.load(paths.cache_for(self.base), self.git_url, paths.patches_for(self.base)) + Sources.load(paths.cache_for(self.base), self.git_url, None) try: # update pkgver first diff --git a/src/ahriman/models/package_description.py b/src/ahriman/models/package_description.py index eb4d6bc4..b20226ea 100644 --- a/src/ahriman/models/package_description.py +++ b/src/ahriman/models/package_description.py @@ -19,7 +19,7 @@ # from __future__ import annotations -from dataclasses import dataclass, field, fields +from dataclasses import asdict, dataclass, field, fields from pathlib import Path from pyalpm import Package # type: ignore from typing import Any, Dict, List, Optional, Type @@ -94,3 +94,10 @@ class PackageDescription: licenses=package.licenses, provides=package.provides, url=package.url) + + def view(self) -> Dict[str, Any]: + """ + generate json package view + :return: json-friendly dictionary + """ + return asdict(self) diff --git a/src/ahriman/models/repository_paths.py b/src/ahriman/models/repository_paths.py index 55dc88dd..7d126ca2 100644 --- a/src/ahriman/models/repository_paths.py +++ b/src/ahriman/models/repository_paths.py @@ -52,16 +52,9 @@ class RepositoryPaths: """ :return: directory for devtools chroot """ - # for the chroot directory devtools will create own tree and we don"t have to specify architecture here + # for the chroot directory devtools will create own tree, and we don"t have to specify architecture here return self.root / "chroot" - @property - def manual(self) -> Path: - """ - :return: directory for manual updates (i.e. from add command) - """ - return self.root / "manual" / self.architecture - @property def packages(self) -> Path: """ @@ -69,13 +62,6 @@ class RepositoryPaths: """ return self.root / "packages" / self.architecture - @property - def patches(self) -> Path: - """ - :return: directory for source patches - """ - return self.root / "patches" - @property def repository(self) -> Path: """ @@ -90,13 +76,6 @@ class RepositoryPaths: """ return self.owner(self.root) - @property - def sources(self) -> Path: - """ - :return: directory for downloaded PKGBUILDs for current build - """ - return self.root / "sources" / self.architecture - @classmethod def known_architectures(cls: Type[RepositoryPaths], root: Path) -> Set[str]: """ @@ -151,30 +130,6 @@ class RepositoryPaths: set_owner(path) path = path.parent - def manual_for(self, package_base: str) -> Path: - """ - get manual path for specific package base - :param package_base: package base name - :return: full path to directory for specified package base manual updates - """ - return self.manual / package_base - - def patches_for(self, package_base: str) -> Path: - """ - get patches path for specific package base - :param package_base: package base name - :return: full path to directory for specified package base patches - """ - return self.patches / package_base - - def sources_for(self, package_base: str) -> Path: - """ - get path to directory from where build will start for the package base - :param package_base: package base name - :return: full path to directory for specified package base sources - """ - return self.sources / package_base - def tree_clear(self, package_base: str) -> None: """ clear package specific files @@ -182,9 +137,7 @@ class RepositoryPaths: """ for directory in ( self.cache_for(package_base), - self.manual_for(package_base), - self.patches_for(package_base), - self.sources_for(package_base)): + ): shutil.rmtree(directory, ignore_errors=True) def tree_create(self) -> None: @@ -194,10 +147,8 @@ class RepositoryPaths: for directory in ( self.cache, self.chroot, - self.manual, self.packages, - self.patches, self.repository, - self.sources): + ): directory.mkdir(mode=0o755, parents=True, exist_ok=True) self.chown(directory) diff --git a/src/ahriman/models/user.py b/src/ahriman/models/user.py index 977fd0bf..7efa2841 100644 --- a/src/ahriman/models/user.py +++ b/src/ahriman/models/user.py @@ -79,18 +79,18 @@ class User: verified = False # the absence of evidence is not the evidence of absence (c) Gin Rummy return verified - def hash_password(self, salt: str) -> str: + def hash_password(self, salt: str) -> User: """ generate hashed password from plain text :param salt: salt for hashed password - :return: hashed string to store in configuration + :return: user with hashed password to store in configuration """ if not self.password: # in case of empty password we leave it empty. This feature is used by any external (like OAuth) provider # when we do not store any password here - return "" + return self password_hash: str = self._HASHER.hash(self.password + salt) - return password_hash + return User(self.username, password_hash, self.access) def verify_access(self, required: UserAccess) -> bool: """ diff --git a/src/ahriman/web/routes.py b/src/ahriman/web/routes.py index b00096cf..3a26ada1 100644 --- a/src/ahriman/web/routes.py +++ b/src/ahriman/web/routes.py @@ -22,7 +22,6 @@ from pathlib import Path from ahriman.web.views.index import IndexView from ahriman.web.views.service.add import AddView -from ahriman.web.views.service.reload_auth import ReloadAuthView from ahriman.web.views.service.remove import RemoveView from ahriman.web.views.service.request import RequestView from ahriman.web.views.service.search import SearchView @@ -45,8 +44,6 @@ def setup_routes(application: Application, static_path: Path) -> None: POST /service-api/v1/add add new packages to repository - POST /service-api/v1/reload-auth reload authentication module - POST /service-api/v1/remove remove existing package from repository POST /service-api/v1/request request to add new packages to repository @@ -81,8 +78,6 @@ def setup_routes(application: Application, static_path: Path) -> None: application.router.add_post("/service-api/v1/add", AddView) - application.router.add_post("/service-api/v1/reload-auth", ReloadAuthView) - application.router.add_post("/service-api/v1/remove", RemoveView) application.router.add_post("/service-api/v1/request", RequestView) diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py index 3c54106f..b12486d9 100644 --- a/src/ahriman/web/views/base.py +++ b/src/ahriman/web/views/base.py @@ -24,6 +24,7 @@ from typing import Any, Dict, List, Optional, Type from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher from ahriman.models.user_access import UserAccess @@ -42,6 +43,14 @@ class BaseView(View): configuration: Configuration = self.request.app["configuration"] return configuration + @property + def database(self) -> SQLite: + """ + :return: database instance + """ + database: SQLite = self.request.app["database"] + return database + @property def service(self) -> Watcher: """ diff --git a/src/ahriman/web/views/service/reload_auth.py b/src/ahriman/web/views/service/reload_auth.py deleted file mode 100644 index 8d9121f9..00000000 --- a/src/ahriman/web/views/service/reload_auth.py +++ /dev/null @@ -1,51 +0,0 @@ -# -# Copyright (c) 2021 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 aiohttp.web import HTTPNoContent, Response - -from ahriman.core.auth.auth import Auth -from ahriman.models.user_access import UserAccess -from ahriman.web.views.base import BaseView - - -class ReloadAuthView(BaseView): - """ - reload authentication module web view - :cvar POST_PERMISSION: post permissions of self - """ - - POST_PERMISSION = UserAccess.Write - - async def post(self) -> Response: - """ - reload authentication module. No parameters supported here - :return: 204 on success - """ - self.configuration.reload() - - try: - import aiohttp_security # type: ignore - self.request.app[aiohttp_security.api.AUTZ_KEY].validator =\ - self.request.app["validator"] =\ - Auth.load(self.configuration) - except (ImportError, KeyError): - self.request.app.logger.warning("could not update authentication module validator", exc_info=True) - raise - - raise HTTPNoContent() diff --git a/src/ahriman/web/views/user/login.py b/src/ahriman/web/views/user/login.py index ef91aabc..70a170f3 100644 --- a/src/ahriman/web/views/user/login.py +++ b/src/ahriman/web/views/user/login.py @@ -58,7 +58,7 @@ class LoginView(BaseView): identity = UserIdentity.from_username(username, self.validator.max_age) if identity is not None and await self.validator.known_username(username): await remember(self.request, response, identity.to_identity()) - return response + raise response raise HTTPUnauthorized() @@ -81,6 +81,6 @@ class LoginView(BaseView): identity = UserIdentity.from_username(username, self.validator.max_age) if identity is not None and await self.validator.check_credentials(username, data.get("password")): await remember(self.request, response, identity.to_identity()) - return response + raise response raise HTTPUnauthorized() diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index c68ae38c..ea166f35 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -25,6 +25,7 @@ from aiohttp import web from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.exceptions import InitializeException from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher @@ -93,8 +94,11 @@ def setup_service(architecture: str, configuration: Configuration, spawner: Spaw application.logger.info("setup configuration") application["configuration"] = configuration + application.logger.info("setup database and perform migrations") + database = application["database"] = SQLite.load(configuration) + application.logger.info("setup watcher") - application["watcher"] = Watcher(architecture, configuration) + application["watcher"] = Watcher(architecture, configuration, database) application.logger.info("setup process spawner") application["spawn"] = spawner @@ -108,7 +112,7 @@ def setup_service(architecture: str, configuration: Configuration, spawner: Spaw check_host=configuration.getboolean("web", "debug_check_host", fallback=False)) application.logger.info("setup authorization") - validator = application["validator"] = Auth.load(configuration) + validator = application["validator"] = Auth.load(configuration, database) if validator.enabled: from ahriman.web.middlewares.auth_handler import setup_auth setup_auth(application, validator) diff --git a/tests/ahriman/application/application/conftest.py b/tests/ahriman/application/application/conftest.py index 3cf080f2..4239c155 100644 --- a/tests/ahriman/application/application/conftest.py +++ b/tests/ahriman/application/application/conftest.py @@ -6,39 +6,46 @@ from ahriman.application.application.packages import Packages from ahriman.application.application.properties import Properties from ahriman.application.application.repository import Repository from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite @pytest.fixture -def application_packages(configuration: Configuration, mocker: MockerFixture) -> Packages: +def application_packages(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> Packages: """ fixture for application with package functions :param configuration: configuration fixture + :param database: database fixture :param mocker: mocker object :return: application test instance """ mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") + mocker.patch("ahriman.core.database.sqlite.SQLite.load", return_value=database) return Packages("x86_64", configuration, no_report=True, unsafe=False) @pytest.fixture -def application_properties(configuration: Configuration, mocker: MockerFixture) -> Properties: +def application_properties(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> Properties: """ fixture for application with properties only :param configuration: configuration fixture + :param database: database fixture :param mocker: mocker object :return: application test instance """ mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") + mocker.patch("ahriman.core.database.sqlite.SQLite.load", return_value=database) return Properties("x86_64", configuration, no_report=True, unsafe=False) @pytest.fixture -def application_repository(configuration: Configuration, mocker: MockerFixture) -> Repository: +def application_repository(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> Repository: """ fixture for application with repository functions :param configuration: configuration fixture + :param database: database fixture :param mocker: mocker object :return: application test instance """ mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") + mocker.patch("ahriman.core.database.sqlite.SQLite.load", return_value=database) return Repository("x86_64", configuration, no_report=True, unsafe=False) diff --git a/tests/ahriman/application/application/test_application_packages.py b/tests/ahriman/application/application/test_application_packages.py index 7ba56b34..50d4c07c 100644 --- a/tests/ahriman/application/application/test_application_packages.py +++ b/tests/ahriman/application/application/test_application_packages.py @@ -2,7 +2,6 @@ import pytest from pathlib import Path from pytest_mock import MockerFixture -from unittest import mock from unittest.mock import MagicMock from ahriman.application.application.packages import Packages @@ -43,16 +42,17 @@ def test_add_aur(application_packages: Packages, package_ahriman: Package, mocke must add package from AUR """ mocker.patch("ahriman.models.package.Package.load", return_value=package_ahriman) + insert_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.build_queue_insert") load_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.load") dependencies_mock = mocker.patch("ahriman.application.application.packages.Packages._process_dependencies") application_packages._add_aur(package_ahriman.base, set(), False) + insert_mock.assert_called_once_with(package_ahriman) load_mock.assert_called_once_with( - application_packages.repository.paths.manual_for(package_ahriman.base), + pytest.helpers.anyvar(int), package_ahriman.git_url, - application_packages.repository.paths.patches_for(package_ahriman.base)) - dependencies_mock.assert_called_once_with( - application_packages.repository.paths.manual_for(package_ahriman.base), set(), False) + pytest.helpers.anyvar(int)) + dependencies_mock.assert_called_once_with(pytest.helpers.anyvar(int), set(), False) def test_add_directory(application_packages: Packages, package_ahriman: Package, mocker: MockerFixture) -> None: @@ -75,18 +75,16 @@ def test_add_local(application_packages: Packages, package_ahriman: Package, moc """ mocker.patch("ahriman.models.package.Package.load", return_value=package_ahriman) init_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.init") + insert_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.build_queue_insert") copytree_mock = mocker.patch("shutil.copytree") dependencies_mock = mocker.patch("ahriman.application.application.packages.Packages._process_dependencies") application_packages._add_local(package_ahriman.base, set(), False) + copytree_mock.assert_called_once_with( + Path(package_ahriman.base), application_packages.repository.paths.cache_for(package_ahriman.base)) init_mock.assert_called_once_with(application_packages.repository.paths.cache_for(package_ahriman.base)) - copytree_mock.assert_has_calls([ - mock.call(Path(package_ahriman.base), application_packages.repository.paths.cache_for(package_ahriman.base)), - mock.call(application_packages.repository.paths.cache_for(package_ahriman.base), - application_packages.repository.paths.manual_for(package_ahriman.base)), - ]) - dependencies_mock.assert_called_once_with( - application_packages.repository.paths.manual_for(package_ahriman.base), set(), False) + insert_mock.assert_called_once_with(package_ahriman) + dependencies_mock.assert_called_once_with(pytest.helpers.anyvar(int), set(), False) def test_add_remote(application_packages: Packages, package_description_ahriman: PackageDescription, diff --git a/tests/ahriman/application/application/test_application_repository.py b/tests/ahriman/application/application/test_application_repository.py index 3c3d9e98..221705c6 100644 --- a/tests/ahriman/application/application/test_application_repository.py +++ b/tests/ahriman/application/application/test_application_repository.py @@ -17,21 +17,12 @@ def test_finalize(application_repository: Repository) -> None: application_repository._finalize([]) -def test_clean_build(application_repository: Repository, mocker: MockerFixture) -> None: - """ - must clean build directory - """ - clear_mock = mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_build") - application_repository.clean(True, False, False, False, False, False) - clear_mock.assert_called_once_with() - - def test_clean_cache(application_repository: Repository, mocker: MockerFixture) -> None: """ must clean cache directory """ clear_mock = mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_cache") - application_repository.clean(False, True, False, False, False, False) + application_repository.clean(True, False, False, False) clear_mock.assert_called_once_with() @@ -40,7 +31,7 @@ def test_clean_chroot(application_repository: Repository, mocker: MockerFixture) must clean chroot directory """ clear_mock = mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_chroot") - application_repository.clean(False, False, True, False, False, False) + application_repository.clean(False, True, False, False) clear_mock.assert_called_once_with() @@ -48,8 +39,8 @@ def test_clean_manual(application_repository: Repository, mocker: MockerFixture) """ must clean manual directory """ - clear_mock = mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_manual") - application_repository.clean(False, False, False, True, False, False) + clear_mock = mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_queue") + application_repository.clean(False, False, True, False) clear_mock.assert_called_once_with() @@ -58,16 +49,7 @@ def test_clean_packages(application_repository: Repository, mocker: MockerFixtur must clean packages directory """ clear_mock = mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_packages") - application_repository.clean(False, False, False, False, True, False) - clear_mock.assert_called_once_with() - - -def test_clean_patches(application_repository: Repository, mocker: MockerFixture) -> None: - """ - must clean packages directory - """ - clear_mock = mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_patches") - application_repository.clean(False, False, False, False, False, True) + application_repository.clean(False, False, False, True) clear_mock.assert_called_once_with() diff --git a/tests/ahriman/application/conftest.py b/tests/ahriman/application/conftest.py index b8083459..d8a44970 100644 --- a/tests/ahriman/application/conftest.py +++ b/tests/ahriman/application/conftest.py @@ -7,17 +7,20 @@ from ahriman.application.ahriman import _parser from ahriman.application.application import Application from ahriman.application.lock import Lock from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite @pytest.fixture -def application(configuration: Configuration, mocker: MockerFixture) -> Application: +def application(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> Application: """ fixture for application :param configuration: configuration fixture + :param database: database fixture :param mocker: mocker object :return: application test instance """ mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") + mocker.patch("ahriman.core.database.sqlite.SQLite.load", return_value=database) return Application("x86_64", configuration, no_report=True, unsafe=False) diff --git a/tests/ahriman/application/handlers/test_handler.py b/tests/ahriman/application/handlers/test_handler.py index ec4fb98b..f8c6f842 100644 --- a/tests/ahriman/application/handlers/test_handler.py +++ b/tests/ahriman/application/handlers/test_handler.py @@ -6,7 +6,7 @@ from pytest_mock import MockerFixture from ahriman.application.handlers import Handler from ahriman.core.configuration import Configuration -from ahriman.core.exceptions import MissingArchitecture, MultipleArchitectures +from ahriman.core.exceptions import ExitCode, MissingArchitecture, MultipleArchitectures def test_architectures_extract(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: @@ -71,8 +71,26 @@ def test_call_exception(args: argparse.Namespace, mocker: MockerFixture) -> None """ must process exception """ - mocker.patch("ahriman.application.lock.Lock.__enter__", side_effect=Exception()) + args.configuration = Path("") + args.quiet = False + mocker.patch("ahriman.core.configuration.Configuration.from_path", side_effect=Exception()) + logging_mock = mocker.patch("logging.Logger.exception") + assert not Handler.call(args, "x86_64") + logging_mock.assert_called_once_with(pytest.helpers.anyvar(str, strict=True)) + + +def test_call_exit_code(args: argparse.Namespace, mocker: MockerFixture) -> None: + """ + must process exitcode exception + """ + args.configuration = Path("") + args.quiet = False + mocker.patch("ahriman.core.configuration.Configuration.from_path", side_effect=ExitCode()) + logging_mock = mocker.patch("logging.Logger.exception") + + assert not Handler.call(args, "x86_64") + logging_mock.assert_not_called() def test_execute(args: argparse.Namespace, mocker: MockerFixture) -> None: @@ -98,11 +116,14 @@ def test_execute_multiple_not_supported(args: argparse.Namespace, mocker: Mocker Handler.execute(args) -def test_execute_single(args: argparse.Namespace, mocker: MockerFixture) -> None: +def test_execute_single(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: """ must run execution in current process if only one architecture supplied """ args.architecture = ["x86_64"] + args.configuration = Path("") + args.quiet = False + mocker.patch("ahriman.core.configuration.Configuration.from_path", return_value=configuration) starmap_mock = mocker.patch("multiprocessing.pool.Pool.starmap") Handler.execute(args) diff --git a/tests/ahriman/application/handlers/test_handler_clean.py b/tests/ahriman/application/handlers/test_handler_clean.py index 3ae41265..719fac90 100644 --- a/tests/ahriman/application/handlers/test_handler_clean.py +++ b/tests/ahriman/application/handlers/test_handler_clean.py @@ -12,12 +12,10 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace: :param args: command line arguments fixture :return: generated arguments for these test cases """ - args.build = False args.cache = False args.chroot = False args.manual = False args.packages = False - args.patches = False return args @@ -30,4 +28,4 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc application_mock = mocker.patch("ahriman.application.application.Application.clean") Clean.run(args, "x86_64", configuration, True, False) - application_mock.assert_called_once_with(False, False, False, False, False, False) + application_mock.assert_called_once_with(False, False, False, False) diff --git a/tests/ahriman/application/handlers/test_handler_patch.py b/tests/ahriman/application/handlers/test_handler_patch.py index 65b02ff9..1357289d 100644 --- a/tests/ahriman/application/handlers/test_handler_patch.py +++ b/tests/ahriman/application/handlers/test_handler_patch.py @@ -67,24 +67,23 @@ def test_patch_set_list(application: Application, mocker: MockerFixture) -> None must list available patches for the command """ mocker.patch("pathlib.Path.is_dir", return_value=True) - glob_mock = mocker.patch("pathlib.Path.glob", return_value=[Path("local")]) - print_mock = mocker.patch("ahriman.application.handlers.patch.Patch._print") + get_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.patches_list", return_value={"ahriman": "patch"}) + print_mock = mocker.patch("ahriman.core.formatters.printer.Printer.print") Patch.patch_set_list(application, "ahriman") - glob_mock.assert_called_once_with("*.patch") - print_mock.assert_called() + get_mock.assert_called_once_with("ahriman") + print_mock.assert_called_once_with(verbose=True) -def test_patch_set_list_no_dir(application: Application, mocker: MockerFixture) -> None: +def test_patch_set_list_no_patches(application: Application, mocker: MockerFixture) -> None: """ must not fail if no patches directory found """ mocker.patch("pathlib.Path.is_dir", return_value=False) - glob_mock = mocker.patch("pathlib.Path.glob") - print_mock = mocker.patch("ahriman.application.handlers.patch.Patch._print") + mocker.patch("ahriman.core.database.sqlite.SQLite.patches_get", return_value=None) + print_mock = mocker.patch("ahriman.core.formatters.printer.Printer.print") Patch.patch_set_list(application, "ahriman") - glob_mock.assert_not_called() print_mock.assert_not_called() @@ -94,21 +93,17 @@ def test_patch_set_create(application: Application, package_ahriman: Package, mo """ mocker.patch("pathlib.Path.mkdir") mocker.patch("ahriman.models.package.Package.load", return_value=package_ahriman) - remove_mock = mocker.patch("ahriman.application.handlers.patch.Patch.patch_set_remove") - create_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.patch_create") - patch_dir = application.repository.paths.patches_for(package_ahriman.base) + mocker.patch("ahriman.core.build_tools.sources.Sources.patch_create", return_value="patch") + create_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.patches_insert") - Patch.patch_set_create(application, Path("path"), ["*.patch"]) - remove_mock.assert_called_once_with(application, package_ahriman.base) - create_mock.assert_called_once_with(Path("path"), patch_dir / "00-main.patch", "*.patch") + Patch.patch_set_create(application, "path", ["*.patch"]) + create_mock.assert_called_once_with(package_ahriman.base, "patch") def test_patch_set_remove(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None: """ must remove patch set for the package """ - remove_mock = mocker.patch("shutil.rmtree") - patch_dir = application.repository.paths.patches_for(package_ahriman.base) - + remove_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.patches_remove") Patch.patch_set_remove(application, package_ahriman.base) - remove_mock.assert_called_once_with(patch_dir, ignore_errors=True) + remove_mock.assert_called_once_with(package_ahriman.base) diff --git a/tests/ahriman/application/handlers/test_handler_unsafe_commands.py b/tests/ahriman/application/handlers/test_handler_unsafe_commands.py index 2da5145c..720e3d26 100644 --- a/tests/ahriman/application/handlers/test_handler_unsafe_commands.py +++ b/tests/ahriman/application/handlers/test_handler_unsafe_commands.py @@ -6,13 +6,25 @@ from pytest_mock import MockerFixture from ahriman.application.ahriman import _parser from ahriman.application.handlers import UnsafeCommands from ahriman.core.configuration import Configuration +from ahriman.core.exceptions import ExitCode + + +def _default_args(args: argparse.Namespace) -> argparse.Namespace: + """ + default arguments for these test cases + :param args: command line arguments fixture + :return: generated arguments for these test cases + """ + args.parser = _parser + args.command = None + return args def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: """ must run command """ - args.parser = _parser + args = _default_args(args) commands_mock = mocker.patch("ahriman.application.handlers.UnsafeCommands.get_unsafe_commands", return_value=["command"]) print_mock = mocker.patch("ahriman.core.formatters.printer.Printer.print") @@ -22,6 +34,36 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc print_mock.assert_called_once_with(verbose=True) +def test_run_check(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: + """ + must run command and check if command is unsafe + """ + args = _default_args(args) + args.command = "clean" + commands_mock = mocker.patch("ahriman.application.handlers.UnsafeCommands.get_unsafe_commands", + return_value=["command"]) + check_mock = mocker.patch("ahriman.application.handlers.UnsafeCommands.check_unsafe") + + UnsafeCommands.run(args, "x86_64", configuration, True, False) + commands_mock.assert_called_once_with(pytest.helpers.anyvar(int)) + check_mock.assert_called_once_with("clean", ["command"], pytest.helpers.anyvar(int)) + + +def test_check_unsafe() -> None: + """ + must check if command is unsafe + """ + with pytest.raises(ExitCode): + UnsafeCommands.check_unsafe("repo-clean", ["repo-clean"], _parser()) + + +def test_check_unsafe_safe() -> None: + """ + must check if command is unsafe + """ + UnsafeCommands.check_unsafe("package-status", ["repo-clean"], _parser()) + + def test_get_unsafe_commands() -> None: """ must return unsafe commands diff --git a/tests/ahriman/application/handlers/test_handler_user.py b/tests/ahriman/application/handlers/test_handler_user.py index 84500495..178a35ba 100644 --- a/tests/ahriman/application/handlers/test_handler_user.py +++ b/tests/ahriman/application/handlers/test_handler_user.py @@ -3,10 +3,11 @@ import pytest from pathlib import Path from pytest_mock import MockerFixture -from unittest import mock from ahriman.application.handlers import User from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite +from ahriman.core.exceptions import InitializeException from ahriman.models.action import Action from ahriman.models.user import User as MUser from ahriman.models.user_access import UserAccess @@ -21,96 +22,78 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace: args.username = "user" args.action = Action.Update args.as_service = False - args.no_reload = False args.password = "pa55w0rd" args.role = UserAccess.Read args.secure = False return args -def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: +def test_run(args: argparse.Namespace, configuration: Configuration, database: SQLite, mocker: MockerFixture) -> None: """ must run command """ args = _default_args(args) - mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") + user = MUser(args.username, args.password, args.role) + mocker.patch("ahriman.core.database.sqlite.SQLite.load", return_value=database) + mocker.patch("ahriman.models.user.User.hash_password", return_value=user) get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.User.configuration_get") create_configuration_mock = mocker.patch("ahriman.application.handlers.User.configuration_create") - write_configuration_mock = mocker.patch("ahriman.application.handlers.User.configuration_write") - create_user_mock = mocker.patch("ahriman.application.handlers.User.user_create") - get_salt_mock = mocker.patch("ahriman.application.handlers.User.get_salt") - reload_mock = mocker.patch("ahriman.core.status.client.Client.reload_auth") + create_user_mock = mocker.patch("ahriman.application.handlers.User.user_create", return_value=user) + get_salt_mock = mocker.patch("ahriman.application.handlers.User.get_salt", return_value="salt") + update_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.user_update") User.run(args, "x86_64", configuration, True, False) get_auth_configuration_mock.assert_called_once_with(configuration.include) - create_configuration_mock.assert_called_once_with( - pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), args.as_service) + create_configuration_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), + pytest.helpers.anyvar(int), args.as_service, args.secure) create_user_mock.assert_called_once_with(args) get_salt_mock.assert_called_once_with(configuration) - write_configuration_mock.assert_called_once_with(pytest.helpers.anyvar(int), args.secure) - reload_mock.assert_called_once_with() + update_mock.assert_called_once_with(user) -def test_run_remove(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: +def test_run_list(args: argparse.Namespace, configuration: Configuration, database: SQLite, user: User, + mocker: MockerFixture) -> None: + """ + must list avaiable users + """ + args = _default_args(args) + args.action = Action.List + mocker.patch("ahriman.core.database.sqlite.SQLite.load", return_value=database) + get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.User.configuration_get") + list_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.user_list", return_value=[user]) + + User.run(args, "x86_64", configuration, True, False) + get_auth_configuration_mock.assert_called_once_with(configuration.include) + list_mock.assert_called_once_with("user", UserAccess.Read) + + +def test_run_remove(args: argparse.Namespace, configuration: Configuration, database: SQLite, + mocker: MockerFixture) -> None: """ must remove user if remove flag supplied """ args = _default_args(args) args.action = Action.Remove - mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") + mocker.patch("ahriman.core.database.sqlite.SQLite.load", return_value=database) get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.User.configuration_get") - create_configuration_mock = mocker.patch("ahriman.application.handlers.User.configuration_create") - write_configuration_mock = mocker.patch("ahriman.application.handlers.User.configuration_write") - reload_mock = mocker.patch("ahriman.core.status.client.Client.reload_auth") + remove_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.user_remove") User.run(args, "x86_64", configuration, True, False) get_auth_configuration_mock.assert_called_once_with(configuration.include) - create_configuration_mock.assert_not_called() - write_configuration_mock.assert_called_once_with(pytest.helpers.anyvar(int), args.secure) - reload_mock.assert_called_once_with() - - -def test_run_no_reload(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: - """ - must run command with no reload - """ - args = _default_args(args) - args.no_reload = True - mocker.patch("ahriman.application.handlers.User.configuration_get") - mocker.patch("ahriman.application.handlers.User.configuration_create") - mocker.patch("ahriman.application.handlers.User.configuration_write") - reload_mock = mocker.patch("ahriman.core.status.client.Client.reload_auth") - - User.run(args, "x86_64", configuration, True, False) - reload_mock.assert_not_called() + remove_mock.assert_called_once_with(args.username) def test_configuration_create(configuration: Configuration, user: MUser, mocker: MockerFixture) -> None: """ must correctly create configuration file """ - section = Configuration.section_name("auth", user.access.value) mocker.patch("pathlib.Path.open") set_mock = mocker.patch("ahriman.core.configuration.Configuration.set_option") + write_mock = mocker.patch("ahriman.application.handlers.User.configuration_write") - User.configuration_create(configuration, user, "salt", False) - set_mock.assert_has_calls([ - mock.call("auth", "salt", pytest.helpers.anyvar(int)), - mock.call(section, user.username, pytest.helpers.anyvar(int)) - ]) - - -def test_configuration_create_user_exists(configuration: Configuration, user: MUser, mocker: MockerFixture) -> None: - """ - must correctly update configuration file if user already exists - """ - section = Configuration.section_name("auth", user.access.value) - configuration.set_option(section, user.username, "") - mocker.patch("pathlib.Path.open") - - User.configuration_create(configuration, user, "salt", False) - generated = MUser.from_option(user.username, configuration.get(section, user.username)) - assert generated.check_credentials(user.password, configuration.get("auth", "salt")) + User.configuration_create(configuration, user, "salt", False, False) + set_mock.assert_called_once_with("auth", "salt", pytest.helpers.anyvar(int)) + write_mock.assert_called_once_with(configuration, False) def test_configuration_create_with_plain_password( @@ -120,12 +103,11 @@ def test_configuration_create_with_plain_password( """ must set plain text password and user for the service """ - section = Configuration.section_name("auth", user.access.value) mocker.patch("pathlib.Path.open") - User.configuration_create(configuration, user, "salt", True) + User.configuration_create(configuration, user, "salt", True, False) - generated = MUser.from_option(user.username, configuration.get(section, user.username)) + generated = MUser.from_option(user.username, user.password).hash_password("salt") service = MUser.from_option(configuration.get("web", "username"), configuration.get("web", "password")) assert generated.username == service.username assert generated.check_credentials(service.password, configuration.get("auth", "salt")) @@ -174,12 +156,9 @@ def test_configuration_write_not_loaded(configuration: Configuration, mocker: Mo """ configuration.path = None mocker.patch("pathlib.Path.open") - write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") - chmod_mock = mocker.patch("pathlib.Path.chmod") - User.configuration_write(configuration, secure=True) - write_mock.assert_not_called() - chmod_mock.assert_not_called() + with pytest.raises(InitializeException): + User.configuration_write(configuration, secure=True) def test_get_salt_read(configuration: Configuration) -> None: @@ -200,31 +179,6 @@ def test_get_salt_generate(configuration: Configuration) -> None: assert len(salt) == 16 -def test_user_clear(configuration: Configuration, user: MUser) -> None: - """ - must clear user from configuration - """ - section = Configuration.section_name("auth", user.access.value) - configuration.set_option(section, user.username, user.password) - - User.user_clear(configuration, user) - assert configuration.get(section, user.username, fallback=None) is None - - -def test_user_clear_multiple_sections(configuration: Configuration, user: MUser) -> None: - """ - must clear user from configuration from all sections - """ - for role in UserAccess: - section = Configuration.section_name("auth", role.value) - configuration.set_option(section, user.username, user.password) - - User.user_clear(configuration, user) - for role in UserAccess: - section = Configuration.section_name("auth", role.value) - assert configuration.get(section, user.username, fallback=None) is None - - def test_user_create(args: argparse.Namespace, user: MUser) -> None: """ must create user diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index ae558ade..34dacf65 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -438,6 +438,38 @@ def test_subparsers_user_add_option_role(parser: argparse.ArgumentParser) -> Non assert isinstance(args.role, UserAccess) +def test_subparsers_user_list(parser: argparse.ArgumentParser) -> None: + """ + user-list command must imply action, architecture, lock, no-report, password, quiet and unsafe + """ + args = parser.parse_args(["user-list"]) + assert args.action == Action.List + assert args.architecture == [""] + assert args.lock is None + assert args.no_report + assert args.password is not None + assert args.quiet + assert args.unsafe + + +def test_subparsers_user_list_architecture(parser: argparse.ArgumentParser) -> None: + """ + user-list command must correctly parse architecture list + """ + args = parser.parse_args(["-a", "x86_64", "user-list"]) + assert args.architecture == [""] + + +def test_subparsers_user_list_option_role(parser: argparse.ArgumentParser) -> None: + """ + user-list command must convert role option to useraccess instance + """ + args = parser.parse_args(["user-list"]) + assert isinstance(args.role, UserAccess) + args = parser.parse_args(["user-list", "--role", "write"]) + assert isinstance(args.role, UserAccess) + + def test_subparsers_user_remove(parser: argparse.ArgumentParser) -> None: """ user-remove command must imply action, architecture, lock, no-report, password, quiet, role and unsafe diff --git a/tests/ahriman/conftest.py b/tests/ahriman/conftest.py index 1208e278..fe9ae82d 100644 --- a/tests/ahriman/conftest.py +++ b/tests/ahriman/conftest.py @@ -3,14 +3,16 @@ import pytest from pathlib import Path from pytest_mock import MockerFixture -from typing import Any, Type, TypeVar +from typing import Any, Dict, Type, TypeVar from unittest.mock import MagicMock from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher from ahriman.models.aur_package import AURPackage +from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.package import Package from ahriman.models.package_description import PackageDescription from ahriman.models.repository_paths import RepositoryPaths @@ -48,6 +50,26 @@ def anyvar(cls: Type[T], strict: bool = False) -> T: return AnyVar() +@pytest.helpers.register +def get_package_status(package: Package) -> Dict[str, Any]: + """ + helper to extract package status from package + :param package: package object + :return: simplified package status map (with only status and view) + """ + return {"status": BuildStatusEnum.Unknown.value, "package": package.view()} + + +@pytest.helpers.register +def get_package_status_extended(package: Package) -> Dict[str, Any]: + """ + helper to extract package status from package + :param package: package object + :return: full package status map (with timestamped build status and view) + """ + return {"status": BuildStatus().view(), "package": package.view()} + + # generic fixtures @pytest.fixture def aur_package_ahriman() -> AURPackage: @@ -123,6 +145,18 @@ def configuration(resource_path_root: Path) -> Configuration: return Configuration.from_path(path=path, architecture="x86_64", quiet=False) +@pytest.fixture +def database(configuration: Configuration) -> SQLite: + """ + database fixture + :param: configuration: configuration fixture + :return: database test instance + """ + database = SQLite.load(configuration) + yield database + database.path.unlink() + + @pytest.fixture def package_ahriman(package_description_ahriman: PackageDescription) -> Package: """ @@ -267,12 +301,13 @@ def user() -> User: @pytest.fixture -def watcher(configuration: Configuration, mocker: MockerFixture) -> Watcher: +def watcher(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> Watcher: """ package status watcher fixture :param configuration: configuration fixture + :param database: database fixture :param mocker: mocker object :return: package status watcher test instance """ mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") - return Watcher("x86_64", configuration) + return Watcher("x86_64", configuration, database) diff --git a/tests/ahriman/core/auth/conftest.py b/tests/ahriman/core/auth/conftest.py index a21974f0..6820964a 100644 --- a/tests/ahriman/core/auth/conftest.py +++ b/tests/ahriman/core/auth/conftest.py @@ -3,24 +3,27 @@ import pytest from ahriman.core.auth.mapping import Mapping from ahriman.core.auth.oauth import OAuth from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite @pytest.fixture -def mapping(configuration: Configuration) -> Mapping: +def mapping(configuration: Configuration, database: SQLite) -> Mapping: """ auth provider fixture :param configuration: configuration fixture + :param database: database fixture :return: auth service instance """ - return Mapping(configuration) + return Mapping(configuration, database) @pytest.fixture -def oauth(configuration: Configuration) -> OAuth: +def oauth(configuration: Configuration, database: SQLite) -> OAuth: """ OAuth provider fixture :param configuration: configuration fixture + :param database: database fixture :return: OAuth2 service instance """ configuration.set("web", "address", "https://example.com") - return OAuth(configuration) + return OAuth(configuration, database) diff --git a/tests/ahriman/core/auth/test_auth.py b/tests/ahriman/core/auth/test_auth.py index 8ec51f84..e3e1bed3 100644 --- a/tests/ahriman/core/auth/test_auth.py +++ b/tests/ahriman/core/auth/test_auth.py @@ -1,10 +1,8 @@ -import pytest - from ahriman.core.auth.auth import Auth from ahriman.core.auth.mapping import Mapping from ahriman.core.auth.oauth import OAuth from ahriman.core.configuration import Configuration -from ahriman.core.exceptions import DuplicateUser +from ahriman.core.database.sqlite import SQLite from ahriman.models.user import User from ahriman.models.user_access import UserAccess @@ -17,88 +15,42 @@ def test_auth_control(auth: Auth) -> None: assert "button" in auth.auth_control # I think it should be button -def test_load_dummy(configuration: Configuration) -> None: +def test_load_dummy(configuration: Configuration, database: SQLite) -> None: """ must load dummy validator if authorization is not enabled """ configuration.set_option("auth", "target", "disabled") - auth = Auth.load(configuration) + auth = Auth.load(configuration, database) assert isinstance(auth, Auth) -def test_load_dummy_empty(configuration: Configuration) -> None: +def test_load_dummy_empty(configuration: Configuration, database: SQLite) -> None: """ must load dummy validator if no option set """ - auth = Auth.load(configuration) + auth = Auth.load(configuration, database) assert isinstance(auth, Auth) -def test_load_mapping(configuration: Configuration) -> None: +def test_load_mapping(configuration: Configuration, database: SQLite) -> None: """ must load mapping validator if option set """ configuration.set_option("auth", "target", "configuration") - auth = Auth.load(configuration) + auth = Auth.load(configuration, database) assert isinstance(auth, Mapping) -def test_load_oauth(configuration: Configuration) -> None: +def test_load_oauth(configuration: Configuration, database: SQLite) -> None: """ must load OAuth2 validator if option set """ configuration.set_option("auth", "target", "oauth") configuration.set_option("web", "address", "https://example.com") - auth = Auth.load(configuration) + auth = Auth.load(configuration, database) assert isinstance(auth, OAuth) -def test_get_users(mapping: Auth, configuration: Configuration) -> None: - """ - must return valid user list - """ - user_write = User("user_write", "pwd_write", UserAccess.Write) - write_section = Configuration.section_name("auth", user_write.access.value) - configuration.set_option(write_section, user_write.username, user_write.password) - user_read = User("user_read", "pwd_read", UserAccess.Read) - read_section = Configuration.section_name("auth", user_read.access.value) - configuration.set_option(read_section, user_read.username, user_read.password) - user_read = User("user_read", "pwd_read", UserAccess.Read) - read_section = Configuration.section_name("auth", user_read.access.value) - configuration.set_option(read_section, user_read.username, user_read.password) - - users = mapping.get_users(configuration) - expected = {user_write.username: user_write, user_read.username: user_read} - assert users == expected - - -def test_get_users_normalized(mapping: Auth, configuration: Configuration) -> None: - """ - must return user list with normalized usernames in keys - """ - user = User("UsEr", "pwd_read", UserAccess.Read) - read_section = Configuration.section_name("auth", user.access.value) - configuration.set_option(read_section, user.username, user.password) - - users = mapping.get_users(configuration) - expected = user.username.lower() - assert expected in users - assert users[expected].username == expected - - -def test_get_users_duplicate(mapping: Auth, configuration: Configuration, user: User) -> None: - """ - must raise exception on duplicate username - """ - write_section = Configuration.section_name("auth", UserAccess.Write.value) - configuration.set_option(write_section, user.username, user.password) - read_section = Configuration.section_name("auth", UserAccess.Read.value) - configuration.set_option(read_section, user.username, user.password) - - with pytest.raises(DuplicateUser): - mapping.get_users(configuration) - - async def test_check_credentials(auth: Auth, user: User) -> None: """ must pass any credentials diff --git a/tests/ahriman/core/auth/test_mapping.py b/tests/ahriman/core/auth/test_mapping.py index 216aab24..27eada70 100644 --- a/tests/ahriman/core/auth/test_mapping.py +++ b/tests/ahriman/core/auth/test_mapping.py @@ -1,15 +1,17 @@ +from pytest_mock import MockerFixture + from ahriman.core.auth.mapping import Mapping from ahriman.models.user import User from ahriman.models.user_access import UserAccess -async def test_check_credentials(mapping: Mapping, user: User) -> None: +async def test_check_credentials(mapping: Mapping, user: User, mocker: MockerFixture) -> None: """ must return true for valid credentials """ current_password = user.password - user.password = user.hash_password(mapping.salt) - mapping._users[user.username] = user + user = user.hash_password(mapping.salt) + mocker.patch("ahriman.core.database.sqlite.SQLite.user_get", return_value=user) assert await mapping.check_credentials(user.username, current_password) # here password is hashed so it is invalid assert not await mapping.check_credentials(user.username, user.password) @@ -31,19 +33,19 @@ async def test_check_credentials_unknown(mapping: Mapping, user: User) -> None: assert not await mapping.check_credentials(user.username, user.password) -def test_get_user(mapping: Mapping, user: User) -> None: +def test_get_user(mapping: Mapping, user: User, mocker: MockerFixture) -> None: """ must return user from storage by username """ - mapping._users[user.username] = user + mocker.patch("ahriman.core.database.sqlite.SQLite.user_get", return_value=user) assert mapping.get_user(user.username) == user -def test_get_user_normalized(mapping: Mapping, user: User) -> None: +def test_get_user_normalized(mapping: Mapping, user: User, mocker: MockerFixture) -> None: """ must return user from storage by username case-insensitive """ - mapping._users[user.username] = user + mocker.patch("ahriman.core.database.sqlite.SQLite.user_get", return_value=user) assert mapping.get_user(user.username.upper()) == user @@ -54,20 +56,27 @@ def test_get_user_unknown(mapping: Mapping, user: User) -> None: assert mapping.get_user(user.username) is None -async def test_known_username(mapping: Mapping, user: User) -> None: +async def test_known_username(mapping: Mapping, user: User, mocker: MockerFixture) -> None: """ must allow only known users """ - mapping._users[user.username] = user + mocker.patch("ahriman.core.database.sqlite.SQLite.user_get", return_value=user) assert await mapping.known_username(user.username) + + +async def test_known_username_unknown(mapping: Mapping, user: User, mocker: MockerFixture) -> None: + """ + must not allow only known users + """ assert not await mapping.known_username(None) + mocker.patch("ahriman.core.database.sqlite.SQLite.user_get", return_value=None) assert not await mapping.known_username(user.password) -async def test_verify_access(mapping: Mapping, user: User) -> None: +async def test_verify_access(mapping: Mapping, user: User, mocker: MockerFixture) -> None: """ must verify user access """ - mapping._users[user.username] = user + mocker.patch("ahriman.core.database.sqlite.SQLite.user_get", return_value=user) assert await mapping.verify_access(user.username, user.access, None) assert not await mapping.verify_access(user.username, UserAccess.Write, None) diff --git a/tests/ahriman/core/build_tools/test_sources.py b/tests/ahriman/core/build_tools/test_sources.py index 571e2b29..49924475 100644 --- a/tests/ahriman/core/build_tools/test_sources.py +++ b/tests/ahriman/core/build_tools/test_sources.py @@ -22,16 +22,25 @@ def test_add(mocker: MockerFixture) -> None: exception=None, cwd=local, logger=pytest.helpers.anyvar(int)) +def test_add_skip(mocker: MockerFixture) -> None: + """ + must skip addition of files to index if no fiels found + """ + mocker.patch("pathlib.Path.glob", return_value=[]) + check_output_mock = mocker.patch("ahriman.core.build_tools.sources.Sources._check_output") + + Sources.add(Path("local"), "pattern1") + check_output_mock.assert_not_called() + + def test_diff(mocker: MockerFixture) -> None: """ must calculate diff """ - write_mock = mocker.patch("pathlib.Path.write_text") check_output_mock = mocker.patch("ahriman.core.build_tools.sources.Sources._check_output") local = Path("local") - Sources.diff(local, Path("patch")) - write_mock.assert_called_once_with(pytest.helpers.anyvar(int)) + assert Sources.diff(local) check_output_mock.assert_called_once_with("git", "diff", exception=None, cwd=local, logger=pytest.helpers.anyvar(int)) @@ -142,51 +151,34 @@ def test_load(mocker: MockerFixture) -> None: fetch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.fetch") patch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.patch_apply") - Sources.load(Path("local"), "remote", Path("patches")) + Sources.load(Path("local"), "remote", "patch") fetch_mock.assert_called_once_with(Path("local"), "remote") - patch_mock.assert_called_once_with(Path("local"), Path("patches")) + patch_mock.assert_called_once_with(Path("local"), "patch") + + +def test_load_no_patch(mocker: MockerFixture) -> None: + """ + must load packages sources correctly without patches + """ + mocker.patch("ahriman.core.build_tools.sources.Sources.fetch") + patch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.patch_apply") + + Sources.load(Path("local"), "remote", None) + patch_mock.assert_not_called() def test_patch_apply(mocker: MockerFixture) -> None: """ must apply patches if any """ - mocker.patch("pathlib.Path.is_dir", return_value=True) - glob_mock = mocker.patch("pathlib.Path.glob", return_value=[Path("01.patch"), Path("02.patch")]) check_output_mock = mocker.patch("ahriman.core.build_tools.sources.Sources._check_output") local = Path("local") - Sources.patch_apply(local, Path("patches")) - glob_mock.assert_called_once_with("*.patch") - check_output_mock.assert_has_calls([ - mock.call("git", "apply", "--ignore-space-change", "--ignore-whitespace", "01.patch", - exception=None, cwd=local, logger=pytest.helpers.anyvar(int)), - mock.call("git", "apply", "--ignore-space-change", "--ignore-whitespace", "02.patch", - exception=None, cwd=local, logger=pytest.helpers.anyvar(int)), - ]) - - -def test_patch_apply_no_dir(mocker: MockerFixture) -> None: - """ - must not fail if no patches directory exists - """ - mocker.patch("pathlib.Path.is_dir", return_value=False) - glob_mock = mocker.patch("pathlib.Path.glob") - - Sources.patch_apply(Path("local"), Path("patches")) - glob_mock.assert_not_called() - - -def test_patch_apply_no_patches(mocker: MockerFixture) -> None: - """ - must not fail if no patches exist - """ - mocker.patch("pathlib.Path.is_dir", return_value=True) - mocker.patch("pathlib.Path.glob", return_value=[]) - check_output_mock = mocker.patch("ahriman.core.build_tools.sources.Sources._check_output") - - Sources.patch_apply(Path("local"), Path("patches")) - check_output_mock.assert_not_called() + Sources.patch_apply(local, "patches") + check_output_mock.assert_called_once_with( + "git", "apply", "--ignore-space-change", "--ignore-whitespace", + exception=None, cwd=local, input_data="patches", logger=pytest.helpers.anyvar(int) + ) def test_patch_create(mocker: MockerFixture) -> None: @@ -196,6 +188,15 @@ def test_patch_create(mocker: MockerFixture) -> None: add_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.add") diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff") - Sources.patch_create(Path("local"), Path("patch"), "glob") + Sources.patch_create(Path("local"), "glob") add_mock.assert_called_once_with(Path("local"), "glob") - diff_mock.assert_called_once_with(Path("local"), Path("patch")) + diff_mock.assert_called_once_with(Path("local")) + + +def test_patch_create_with_newline(mocker: MockerFixture) -> None: + """ + created patch must have new line at the end + """ + mocker.patch("ahriman.core.build_tools.sources.Sources.add") + mocker.patch("ahriman.core.build_tools.sources.Sources.diff", return_value="diff") + assert Sources.patch_create(Path("local"), "glob").endswith("\n") diff --git a/tests/ahriman/core/build_tools/test_task.py b/tests/ahriman/core/build_tools/test_task.py index 6c0c6ce8..0df00c02 100644 --- a/tests/ahriman/core/build_tools/test_task.py +++ b/tests/ahriman/core/build_tools/test_task.py @@ -1,6 +1,8 @@ +from pathlib import Path from pytest_mock import MockerFixture from ahriman.core.build_tools.task import Task +from ahriman.core.database.sqlite import SQLite def test_build(task_ahriman: Task, mocker: MockerFixture) -> None: @@ -8,11 +10,11 @@ def test_build(task_ahriman: Task, mocker: MockerFixture) -> None: must build package """ check_output_mock = mocker.patch("ahriman.core.build_tools.task.Task._check_output") - task_ahriman.build() + task_ahriman.build(Path("ahriman")) check_output_mock.assert_called() -def test_init_with_cache(task_ahriman: Task, mocker: MockerFixture) -> None: +def test_init_with_cache(task_ahriman: Task, database: SQLite, mocker: MockerFixture) -> None: """ must copy tree instead of fetch """ @@ -20,5 +22,5 @@ def test_init_with_cache(task_ahriman: Task, mocker: MockerFixture) -> None: mocker.patch("ahriman.core.build_tools.sources.Sources.load") copytree_mock = mocker.patch("shutil.copytree") - task_ahriman.init(None) + task_ahriman.init(Path("ahriman"), database) copytree_mock.assert_called_once() # we do not check full command here, sorry diff --git a/tests/ahriman/core/database/conftest.py b/tests/ahriman/core/database/conftest.py new file mode 100644 index 00000000..86f68dde --- /dev/null +++ b/tests/ahriman/core/database/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from sqlite3 import Connection +from unittest.mock import MagicMock + + +@pytest.fixture +def connection() -> Connection: + """ + mock object for sqlite3 connection + :return: sqlite3 connection test instance + """ + return MagicMock() diff --git a/tests/ahriman/core/database/data/test_data_init.py b/tests/ahriman/core/database/data/test_data_init.py new file mode 100644 index 00000000..cd7bf1fa --- /dev/null +++ b/tests/ahriman/core/database/data/test_data_init.py @@ -0,0 +1,33 @@ +from pytest_mock import MockerFixture +from sqlite3 import Connection + +from ahriman.core.configuration import Configuration +from ahriman.core.database.data import migrate_data +from ahriman.models.migration_result import MigrationResult +from ahriman.models.repository_paths import RepositoryPaths + + +def test_migrate_data_initial(connection: Connection, configuration: Configuration, + repository_paths: RepositoryPaths, mocker: MockerFixture) -> None: + """ + must perform initial migration + """ + packages = mocker.patch("ahriman.core.database.data.migrate_package_statuses") + users = mocker.patch("ahriman.core.database.data.migrate_users_data") + + migrate_data(MigrationResult(old_version=0, new_version=900), connection, configuration, repository_paths) + packages.assert_called_once_with(connection, repository_paths) + users.assert_called_once_with(connection, configuration) + + +def test_migrate_data_skip(connection: Connection, configuration: Configuration, + repository_paths: RepositoryPaths, mocker: MockerFixture) -> None: + """ + must not migrate data if version is up-to-date + """ + packages = mocker.patch("ahriman.core.database.data.migrate_package_statuses") + users = mocker.patch("ahriman.core.database.data.migrate_users_data") + + migrate_data(MigrationResult(old_version=900, new_version=900), connection, configuration, repository_paths) + packages.assert_not_called() + users.assert_not_called() diff --git a/tests/ahriman/core/database/data/test_package_statuses.py b/tests/ahriman/core/database/data/test_package_statuses.py new file mode 100644 index 00000000..5bb8cebb --- /dev/null +++ b/tests/ahriman/core/database/data/test_package_statuses.py @@ -0,0 +1,43 @@ +import pytest + +from pytest_mock import MockerFixture +from sqlite3 import Connection +from unittest import mock + +from ahriman.core.database.data import migrate_package_statuses +from ahriman.models.package import Package +from ahriman.models.repository_paths import RepositoryPaths + + +def test_migrate_package_statuses(connection: Connection, package_ahriman: Package, repository_paths: RepositoryPaths, + mocker: MockerFixture) -> None: + """ + must migrate packages to database + """ + response = {"packages": [pytest.helpers.get_package_status_extended(package_ahriman)]} + + mocker.patch("pathlib.Path.is_file", return_value=True) + mocker.patch("pathlib.Path.open") + mocker.patch("json.load", return_value=response) + unlink_mock = mocker.patch("pathlib.Path.unlink") + + migrate_package_statuses(connection, repository_paths) + unlink_mock.assert_called_once_with() + connection.execute.assert_has_calls([ + mock.call(pytest.helpers.anyvar(str, strict=True), pytest.helpers.anyvar(int)), + mock.call(pytest.helpers.anyvar(str, strict=True), pytest.helpers.anyvar(int)), + ]) + connection.executemany.assert_has_calls([ + mock.call(pytest.helpers.anyvar(str, strict=True), pytest.helpers.anyvar(int)), + ]) + connection.commit.assert_called_once_with() + + +def test_migrate_package_statuses_skip(connection: Connection, repository_paths: RepositoryPaths, + mocker: MockerFixture) -> None: + """ + must skip packages migration if no cache file found + """ + mocker.patch("pathlib.Path.is_file", return_value=False) + migrate_package_statuses(connection, repository_paths) + connection.commit.assert_not_called() diff --git a/tests/ahriman/core/database/data/test_patches.py b/tests/ahriman/core/database/data/test_patches.py new file mode 100644 index 00000000..0abaa3d7 --- /dev/null +++ b/tests/ahriman/core/database/data/test_patches.py @@ -0,0 +1,53 @@ +import pytest + +from pathlib import Path +from pytest_mock import MockerFixture +from sqlite3 import Connection + +from ahriman.core.database.data import migrate_patches +from ahriman.models.package import Package +from ahriman.models.repository_paths import RepositoryPaths + + +def test_migrate_patches(connection: Connection, repository_paths: RepositoryPaths, + package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must perform migration for patches + """ + mocker.patch("pathlib.Path.is_dir", return_value=True) + mocker.patch("pathlib.Path.is_file", return_value=True) + iterdir_mock = mocker.patch("pathlib.Path.iterdir", return_value=[Path(package_ahriman.base)]) + read_mock = mocker.patch("pathlib.Path.read_text", return_value="patch") + + migrate_patches(connection, repository_paths) + iterdir_mock.assert_called_once_with() + read_mock.assert_called_once_with(encoding="utf8") + connection.execute.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), pytest.helpers.anyvar(int)) + connection.commit.assert_called_once_with() + + +def test_migrate_patches_skip(connection: Connection, repository_paths: RepositoryPaths, + mocker: MockerFixture) -> None: + """ + must skip patches migration in case if no root found + """ + mocker.patch("pathlib.Path.is_dir", return_value=False) + iterdir_mock = mocker.patch("pathlib.Path.iterdir") + + migrate_patches(connection, repository_paths) + iterdir_mock.assert_not_called() + + +def test_migrate_patches_no_patch(connection: Connection, repository_paths: RepositoryPaths, + package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must skip package if no match found + """ + mocker.patch("pathlib.Path.is_dir", return_value=True) + mocker.patch("pathlib.Path.is_file", return_value=False) + iterdir_mock = mocker.patch("pathlib.Path.iterdir", return_value=[Path(package_ahriman.base)]) + read_mock = mocker.patch("pathlib.Path.read_text") + + migrate_patches(connection, repository_paths) + iterdir_mock.assert_called_once_with() + read_mock.assert_not_called() diff --git a/tests/ahriman/core/database/data/test_users.py b/tests/ahriman/core/database/data/test_users.py new file mode 100644 index 00000000..1903fbd8 --- /dev/null +++ b/tests/ahriman/core/database/data/test_users.py @@ -0,0 +1,22 @@ +import pytest + +from sqlite3 import Connection +from unittest import mock + +from ahriman.core.configuration import Configuration +from ahriman.core.database.data import migrate_users_data + + +def test_migrate_users_data(connection: Connection, configuration: Configuration) -> None: + """ + must users to database + """ + configuration.set_option("auth:read", "user1", "password1") + configuration.set_option("auth:write", "user2", "password2") + + migrate_users_data(connection, configuration) + connection.execute.assert_has_calls([ + mock.call(pytest.helpers.anyvar(str, strict=True), pytest.helpers.anyvar(int)), + mock.call(pytest.helpers.anyvar(str, strict=True), pytest.helpers.anyvar(int)), + ]) + connection.commit.assert_called_once_with() diff --git a/tests/ahriman/core/database/migrations/conftest.py b/tests/ahriman/core/database/migrations/conftest.py new file mode 100644 index 00000000..cfa0d6cf --- /dev/null +++ b/tests/ahriman/core/database/migrations/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from sqlite3 import Connection + +from ahriman.core.database.migrations import Migrations + + +@pytest.fixture +def migrations(connection: Connection) -> Migrations: + """ + fixture for migrations object + :param connection: sqlite connection fixture + :return: migrations test instance + """ + return Migrations(connection) diff --git a/tests/ahriman/core/database/migrations/test_m000_initial.py b/tests/ahriman/core/database/migrations/test_m000_initial.py new file mode 100644 index 00000000..b73f47b0 --- /dev/null +++ b/tests/ahriman/core/database/migrations/test_m000_initial.py @@ -0,0 +1,8 @@ +from ahriman.core.database.migrations.m000_initial import steps + + +def test_migration_initial() -> None: + """ + migration must not be empty + """ + assert steps diff --git a/tests/ahriman/core/database/migrations/test_migrations_init.py b/tests/ahriman/core/database/migrations/test_migrations_init.py new file mode 100644 index 00000000..f0fec1fd --- /dev/null +++ b/tests/ahriman/core/database/migrations/test_migrations_init.py @@ -0,0 +1,109 @@ +import pytest + +from pytest_mock import MockerFixture +from sqlite3 import Connection +from unittest import mock +from unittest.mock import MagicMock + +from ahriman.core.database.migrations import Migrations +from ahriman.models.migration import Migration +from ahriman.models.migration_result import MigrationResult + + +def test_migrate(connection: Connection, mocker: MockerFixture) -> None: + """ + must perform migrations + """ + run_mock = mocker.patch("ahriman.core.database.migrations.Migrations.run") + Migrations.migrate(connection) + run_mock.assert_called_once_with() + + +def test_migrations(migrations: Migrations) -> None: + """ + must retrieve migrations + """ + assert migrations.migrations() + + +def test_run_skip(migrations: Migrations, mocker: MockerFixture) -> None: + """ + must skip migration if version is the same + """ + mocker.patch.object(MigrationResult, "is_outdated", False) + + migrations.run() + migrations.connection.cursor.assert_not_called() + + +def test_run(migrations: Migrations, mocker: MockerFixture) -> None: + """ + must run migration + """ + cursor = MagicMock() + mocker.patch("ahriman.core.database.migrations.Migrations.user_version", return_value=0) + mocker.patch("ahriman.core.database.migrations.Migrations.migrations", + return_value=[Migration(0, "test", ["select 1"])]) + migrations.connection.cursor.return_value = cursor + validate_mock = mocker.patch("ahriman.models.migration_result.MigrationResult.validate") + + migrations.run() + validate_mock.assert_called_once_with() + cursor.execute.assert_has_calls([ + mock.call("begin exclusive"), + mock.call("select 1"), + mock.call("pragma user_version = 1"), + mock.call("commit"), + ]) + cursor.close.assert_called_once_with() + + +def test_run_migration_exception(migrations: Migrations, mocker: MockerFixture) -> None: + """ + must rollback and close cursor on exception during migration + """ + cursor = MagicMock() + mocker.patch("logging.Logger.info", side_effect=Exception()) + mocker.patch("ahriman.core.database.migrations.Migrations.user_version", return_value=0) + mocker.patch("ahriman.core.database.migrations.Migrations.migrations", + return_value=[Migration(0, "test", ["select 1"])]) + mocker.patch("ahriman.models.migration_result.MigrationResult.validate") + migrations.connection.cursor.return_value = cursor + + with pytest.raises(Exception): + migrations.run() + cursor.execute.assert_has_calls([ + mock.call("begin exclusive"), + mock.call("rollback"), + ]) + cursor.close.assert_called_once_with() + + +def test_run_sql_exception(migrations: Migrations, mocker: MockerFixture) -> None: + """ + must close cursor on general migration error + """ + cursor = MagicMock() + cursor.execute.side_effect = Exception() + mocker.patch("ahriman.core.database.migrations.Migrations.user_version", return_value=0) + mocker.patch("ahriman.core.database.migrations.Migrations.migrations", + return_value=[Migration(0, "test", ["select 1"])]) + mocker.patch("ahriman.models.migration_result.MigrationResult.validate") + migrations.connection.cursor.return_value = cursor + + with pytest.raises(Exception): + migrations.run() + cursor.close.assert_called_once_with() + + +def test_user_version(migrations: Migrations) -> None: + """ + must correctly extract current migration version + """ + cursor = MagicMock() + cursor.fetchone.return_value = {"user_version": 42} + migrations.connection.execute.return_value = cursor + + version = migrations.user_version() + migrations.connection.execute.assert_called_once_with("pragma user_version") + assert version == 42 diff --git a/tests/ahriman/core/database/operations/test_auth_operations.py b/tests/ahriman/core/database/operations/test_auth_operations.py new file mode 100644 index 00000000..b265bb0f --- /dev/null +++ b/tests/ahriman/core/database/operations/test_auth_operations.py @@ -0,0 +1,97 @@ +from ahriman.core.database.sqlite import SQLite +from ahriman.models.user import User +from ahriman.models.user_access import UserAccess + + +def test_user_get_update(database: SQLite, user: User) -> None: + """ + must retrieve user from the database + """ + database.user_update(user) + assert database.user_get(user.username) == user + + +def test_user_list(database: SQLite, user: User) -> None: + """ + must return all users + """ + database.user_update(user) + database.user_update(User(user.password, user.username, user.access)) + + users = database.user_list(None, None) + assert len(users) == 2 + assert user in users + assert User(user.password, user.username, user.access) in users + + +def test_user_list_filter_by_username(database: SQLite) -> None: + """ + must return users filtered by its id + """ + first = User("1", "", UserAccess.Read) + second = User("2", "", UserAccess.Write) + third = User("3", "", UserAccess.Read) + + database.user_update(first) + database.user_update(second) + database.user_update(third) + + assert database.user_list("1", None) == [first] + assert database.user_list("2", None) == [second] + assert database.user_list("3", None) == [third] + + +def test_user_list_filter_by_access(database: SQLite) -> None: + """ + must return users filtered by its access + """ + first = User("1", "", UserAccess.Read) + second = User("2", "", UserAccess.Write) + third = User("3", "", UserAccess.Read) + + database.user_update(first) + database.user_update(second) + database.user_update(third) + + users = database.user_list(None, UserAccess.Read) + assert len(users) == 2 + assert first in users + assert third in users + + +def test_user_list_filter_by_username_access(database: SQLite) -> None: + """ + must return users filtered by its access and username + """ + first = User("1", "", UserAccess.Read) + second = User("2", "", UserAccess.Write) + third = User("3", "", UserAccess.Read) + + database.user_update(first) + database.user_update(second) + database.user_update(third) + + assert database.user_list("1", UserAccess.Read) == [first] + assert not database.user_list("1", UserAccess.Write) + + +def test_user_remove_update(database: SQLite, user: User) -> None: + """ + must remove user from the database + """ + database.user_update(user) + database.user_remove(user.username) + assert database.user_get(user.username) is None + + +def test_user_update(database: SQLite, user: User) -> None: + """ + must update user in the database + """ + database.user_update(user) + assert database.user_get(user.username) == user + + new_user = user.hash_password("salt") + new_user.access = UserAccess.Write + database.user_update(new_user) + assert database.user_get(new_user.username) == new_user diff --git a/tests/ahriman/core/database/operations/test_build_operations.py b/tests/ahriman/core/database/operations/test_build_operations.py new file mode 100644 index 00000000..b231b5c7 --- /dev/null +++ b/tests/ahriman/core/database/operations/test_build_operations.py @@ -0,0 +1,45 @@ +from ahriman.core.database.sqlite import SQLite +from ahriman.models.package import Package + + +def test_build_queue_insert_clear(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None: + """ + must clear all packages from queue + """ + database.build_queue_insert(package_ahriman) + database.build_queue_insert(package_python_schedule) + + database.build_queue_clear(None) + assert not database.build_queue_get() + + +def test_build_queue_insert_clear_specific(database: SQLite, package_ahriman: Package, + package_python_schedule: Package) -> None: + """ + must remove only specified package from the queue + """ + database.build_queue_insert(package_ahriman) + database.build_queue_insert(package_python_schedule) + + database.build_queue_clear(package_python_schedule.base) + assert database.build_queue_get() == [package_ahriman] + + +def test_build_queue_insert_get(database: SQLite, package_ahriman: Package) -> None: + """ + must insert and get package from the database + """ + database.build_queue_insert(package_ahriman) + assert database.build_queue_get() == [package_ahriman] + + +def test_build_queue_insert(database: SQLite, package_ahriman: Package) -> None: + """ + must update user in the database + """ + database.build_queue_insert(package_ahriman) + assert database.build_queue_get() == [package_ahriman] + + package_ahriman.version = "42" + database.build_queue_insert(package_ahriman) + assert database.build_queue_get() == [package_ahriman] diff --git a/tests/ahriman/core/database/operations/test_operations.py b/tests/ahriman/core/database/operations/test_operations.py new file mode 100644 index 00000000..facd7a02 --- /dev/null +++ b/tests/ahriman/core/database/operations/test_operations.py @@ -0,0 +1,39 @@ +import sqlite3 + +from pytest_mock import MockerFixture +from unittest.mock import MagicMock + +from ahriman.core.database.sqlite import SQLite + + +def test_factory(database: SQLite) -> None: + """ + must convert response to dictionary + """ + result = database.with_connection(lambda conn: conn.execute("select 1 as result").fetchone()) + assert isinstance(result, dict) + assert result["result"] == 1 + + +def test_with_connection(database: SQLite, mocker: MockerFixture) -> None: + """ + must run query inside connection + """ + connection_mock = MagicMock() + connect_mock = mocker.patch("sqlite3.connect", return_value=connection_mock) + + database.with_connection(lambda conn: conn.execute("select 1")) + connect_mock.assert_called_once_with(database.path, detect_types=sqlite3.PARSE_DECLTYPES) + connection_mock.__enter__().commit.assert_not_called() + + +def test_with_connection_with_commit(database: SQLite, mocker: MockerFixture) -> None: + """ + must run query inside connection and commit after + """ + connection_mock = MagicMock() + connection_mock.commit.return_value = 42 + mocker.patch("sqlite3.connect", return_value=connection_mock) + + database.with_connection(lambda conn: conn.execute("select 1"), commit=True) + connection_mock.__enter__().commit.assert_called_once_with() diff --git a/tests/ahriman/core/database/operations/test_package_operations.py b/tests/ahriman/core/database/operations/test_package_operations.py new file mode 100644 index 00000000..c916f844 --- /dev/null +++ b/tests/ahriman/core/database/operations/test_package_operations.py @@ -0,0 +1,168 @@ +import pytest + +from pytest_mock import MockerFixture +from sqlite3 import Connection +from unittest import mock + +from ahriman.core.database.sqlite import SQLite +from ahriman.models.build_status import BuildStatus, BuildStatusEnum +from ahriman.models.package import Package + + +def test_package_remove_package_base(database: SQLite, connection: Connection) -> None: + """ + must remove package base + """ + database._package_remove_package_base(connection, "package") + connection.execute.assert_has_calls([ + mock.call(pytest.helpers.anyvar(str, strict=True), {"package_base": "package"}), + mock.call(pytest.helpers.anyvar(str, strict=True), {"package_base": "package"}), + ]) + + +def test_package_remove_packages(database: SQLite, connection: Connection, package_ahriman: Package) -> None: + """ + must remove packages belong to base + """ + database._package_remove_packages(connection, package_ahriman.base, package_ahriman.packages.keys()) + connection.execute.assert_called_once_with( + pytest.helpers.anyvar(str, strict=True), {"package_base": package_ahriman.base}) + connection.executemany.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), []) + + +def test_package_update_insert_base(database: SQLite, connection: Connection, package_ahriman: Package) -> None: + """ + must insert base package + """ + database._package_update_insert_base(connection, package_ahriman) + connection.execute.assert_called_once_with(pytest.helpers.anyvar(str, strict=True), pytest.helpers.anyvar(int)) + + +def test_package_update_insert_packages(database: SQLite, connection: Connection, package_ahriman: Package) -> None: + """ + must insert single packages + """ + database._package_update_insert_packages(connection, package_ahriman) + connection.executemany(pytest.helpers.anyvar(str, strict=True), pytest.helpers.anyvar(int)) + + +def test_package_update_insert_status(database: SQLite, connection: Connection, package_ahriman: Package) -> None: + """ + must insert single package status + """ + database._package_update_insert_status(connection, package_ahriman.base, BuildStatus()) + connection.execute(pytest.helpers.anyvar(str, strict=True), pytest.helpers.anyvar(int)) + + +def test_packages_get_select_package_bases(database: SQLite, connection: Connection) -> None: + """ + must select all bases + """ + database._packages_get_select_package_bases(connection) + connection.execute(pytest.helpers.anyvar(str, strict=True)) + + +def test_packages_get_select_packages(database: SQLite, connection: Connection, package_ahriman: Package) -> None: + """ + must select all packages + """ + database._packages_get_select_packages(connection, {package_ahriman.base: package_ahriman}) + connection.execute(pytest.helpers.anyvar(str, strict=True)) + + +def test_packages_get_select_packages_skip(database: SQLite, connection: Connection, package_ahriman: Package) -> None: + """ + must skip unknown packages + """ + view = {"package_base": package_ahriman.base} + for package, properties in package_ahriman.packages.items(): + view.update({"package": package}) + view.update(properties.view()) + connection.execute.return_value = [{"package_base": "random name"}, view] + + database._packages_get_select_packages(connection, {package_ahriman.base: package_ahriman}) + + +def test_packages_get_select_statuses(database: SQLite, connection: Connection) -> None: + """ + must select all statuses + """ + database._packages_get_select_statuses(connection) + connection.execute(pytest.helpers.anyvar(str, strict=True)) + + +def test_package_remove(database: SQLite, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must totally remove package from the database + """ + remove_package_mock = mocker.patch("ahriman.core.database.sqlite.SQLite._package_remove_package_base") + remove_packages_mock = mocker.patch("ahriman.core.database.sqlite.SQLite._package_remove_packages") + + database.package_remove(package_ahriman.base) + remove_package_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman.base) + remove_packages_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman.base, []) + + +def test_package_update(database: SQLite, package_ahriman: Package, mocker: MockerFixture): + """ + must update package status + """ + status = BuildStatus() + insert_base_mock = mocker.patch("ahriman.core.database.sqlite.SQLite._package_update_insert_base") + insert_status_mock = mocker.patch("ahriman.core.database.sqlite.SQLite._package_update_insert_status") + insert_packages_mock = mocker.patch("ahriman.core.database.sqlite.SQLite._package_update_insert_packages") + remove_packages_mock = mocker.patch("ahriman.core.database.sqlite.SQLite._package_remove_packages") + + database.package_update(package_ahriman, status) + insert_base_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman) + insert_status_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman.base, status) + insert_packages_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman) + remove_packages_mock.assert_called_once_with( + pytest.helpers.anyvar(int), package_ahriman.base, package_ahriman.packages.keys()) + + +def test_packages_get(database: SQLite, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must return all packages + """ + select_bases_mock = mocker.patch("ahriman.core.database.sqlite.SQLite._packages_get_select_package_bases", + return_value={package_ahriman.base: package_ahriman}) + select_packages_mock = mocker.patch("ahriman.core.database.sqlite.SQLite._packages_get_select_packages") + select_statuses_mock = mocker.patch("ahriman.core.database.sqlite.SQLite._packages_get_select_statuses") + + database.packages_get() + select_bases_mock.assert_called_once_with(pytest.helpers.anyvar(int)) + select_statuses_mock.assert_called_once_with(pytest.helpers.anyvar(int)) + select_packages_mock.assert_called_once_with(pytest.helpers.anyvar(int), {package_ahriman.base: package_ahriman}) + + +def test_package_update_get(database: SQLite, package_ahriman: Package) -> None: + """ + must insert and retrieve package + """ + status = BuildStatus() + database.package_update(package_ahriman, status) + assert next((db_package, db_status) + for db_package, db_status in database.packages_get() + if db_package.base == package_ahriman.base) == (package_ahriman, status) + + +def test_package_update_remove_get(database: SQLite, package_ahriman: Package) -> None: + """ + must insert, remove and retrieve package + """ + status = BuildStatus() + database.package_update(package_ahriman, status) + database.package_remove(package_ahriman.base) + assert not database.packages_get() + + +def test_package_update_update(database: SQLite, package_ahriman: Package) -> None: + """ + must perform update for existing package + """ + database.package_update(package_ahriman, BuildStatus()) + database.package_update(package_ahriman, BuildStatus(BuildStatusEnum.Failed)) + assert next(db_status.status + for db_package, db_status in database.packages_get() + if db_package.base == package_ahriman.base) == BuildStatusEnum.Failed diff --git a/tests/ahriman/core/database/operations/test_patch_operations.py b/tests/ahriman/core/database/operations/test_patch_operations.py new file mode 100644 index 00000000..72fb930a --- /dev/null +++ b/tests/ahriman/core/database/operations/test_patch_operations.py @@ -0,0 +1,55 @@ +from ahriman.core.database.sqlite import SQLite +from ahriman.models.package import Package + + +def test_patches_get_insert(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None: + """ + must insert patch to database + """ + database.patches_insert(package_ahriman.base, "patch_1") + database.patches_insert(package_python_schedule.base, "patch_2") + assert database.patches_get(package_ahriman.base) == "patch_1" + assert not database.build_queue_get() + + +def test_patches_list(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None: + """ + must list all patches + """ + database.patches_insert(package_ahriman.base, "patch1") + database.patches_insert(package_python_schedule.base, "patch2") + assert database.patches_list(None) == {package_ahriman.base: "patch1", package_python_schedule.base: "patch2"} + + +def test_patches_list_filter(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None: + """ + must list all patches filtered by package name (same as get) + """ + database.patches_insert(package_ahriman.base, "patch1") + database.patches_insert(package_python_schedule.base, "patch2") + + assert database.patches_list(package_ahriman.base) == {package_ahriman.base: "patch1"} + assert database.patches_list(package_python_schedule.base) == {package_python_schedule.base: "patch2"} + + +def test_patches_insert_remove(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None: + """ + must remove patch from database + """ + database.patches_insert(package_ahriman.base, "patch_1") + database.patches_insert(package_python_schedule.base, "patch_2") + database.patches_remove(package_ahriman.base) + + assert database.patches_get(package_ahriman.base) is None + database.patches_insert(package_python_schedule.base, "patch_2") + + +def test_patches_insert_insert(database: SQLite, package_ahriman: Package) -> None: + """ + must update patch in database + """ + database.patches_insert(package_ahriman.base, "patch_1") + assert database.patches_get(package_ahriman.base) == "patch_1" + + database.patches_insert(package_ahriman.base, "patch_2") + assert database.patches_get(package_ahriman.base) == "patch_2" diff --git a/tests/ahriman/core/database/test_sqlite.py b/tests/ahriman/core/database/test_sqlite.py new file mode 100644 index 00000000..07d02454 --- /dev/null +++ b/tests/ahriman/core/database/test_sqlite.py @@ -0,0 +1,28 @@ +import pytest + +from pytest_mock import MockerFixture + +from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite + + +def test_load(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must correctly load instance + """ + init_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.init") + SQLite.load(configuration) + init_mock.assert_called_once_with(configuration) + + +def test_init(database: SQLite, configuration: Configuration, mocker: MockerFixture) -> None: + """ + must run migrations on init + """ + migrate_schema_mock = mocker.patch("ahriman.core.database.migrations.Migrations.migrate") + migrate_data_mock = mocker.patch("ahriman.core.database.sqlite.migrate_data") + + database.init(configuration) + migrate_schema_mock.assert_called_once_with(pytest.helpers.anyvar(int)) + migrate_data_mock.assert_called_once_with( + pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), configuration, configuration.repository_paths) diff --git a/tests/ahriman/core/formatters/conftest.py b/tests/ahriman/core/formatters/conftest.py index d193a759..c48c45e0 100644 --- a/tests/ahriman/core/formatters/conftest.py +++ b/tests/ahriman/core/formatters/conftest.py @@ -6,9 +6,11 @@ from ahriman.core.formatters.package_printer import PackagePrinter from ahriman.core.formatters.status_printer import StatusPrinter from ahriman.core.formatters.string_printer import StringPrinter from ahriman.core.formatters.update_printer import UpdatePrinter +from ahriman.core.formatters.user_printer import UserPrinter from ahriman.models.aur_package import AURPackage from ahriman.models.build_status import BuildStatus from ahriman.models.package import Package +from ahriman.models.user import User @pytest.fixture @@ -65,3 +67,13 @@ def update_printer(package_ahriman: Package) -> UpdatePrinter: :return: build status printer test instance """ return UpdatePrinter(package_ahriman, None) + + +@pytest.fixture +def user_printer(user: User) -> UserPrinter: + """ + fixture for user printer + :param user: user fixture + :return: user printer test instance + """ + return UserPrinter(user) diff --git a/tests/ahriman/core/formatters/test_user_printer.py b/tests/ahriman/core/formatters/test_user_printer.py new file mode 100644 index 00000000..99b81a4f --- /dev/null +++ b/tests/ahriman/core/formatters/test_user_printer.py @@ -0,0 +1,15 @@ +from ahriman.core.formatters.user_printer import UserPrinter + + +def test_properties(user_printer: UserPrinter) -> None: + """ + must return non empty properties list + """ + assert user_printer.properties() + + +def test_title(user_printer: UserPrinter) -> None: + """ + must return non empty title + """ + assert user_printer.title() is not None diff --git a/tests/ahriman/core/repository/conftest.py b/tests/ahriman/core/repository/conftest.py index b3902a28..754fe023 100644 --- a/tests/ahriman/core/repository/conftest.py +++ b/tests/ahriman/core/repository/conftest.py @@ -3,6 +3,7 @@ import pytest from pytest_mock import MockerFixture from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.repository import Repository from ahriman.core.repository.cleaner import Cleaner from ahriman.core.repository.executor import Executor @@ -11,68 +12,71 @@ from ahriman.core.repository.update_handler import UpdateHandler @pytest.fixture -def cleaner(configuration: Configuration, mocker: MockerFixture) -> Cleaner: +def cleaner(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> Cleaner: """ fixture for cleaner :param configuration: configuration fixture + :param database: database fixture :param mocker: mocker object :return: cleaner test instance """ mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") - return Cleaner("x86_64", configuration, no_report=True, unsafe=False) + return Cleaner("x86_64", configuration, database, no_report=True, unsafe=False) @pytest.fixture -def executor(configuration: Configuration, mocker: MockerFixture) -> Executor: +def executor(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> Executor: """ fixture for executor :param configuration: configuration fixture + :param database: database fixture :param mocker: mocker object :return: executor test instance """ - mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_build") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_cache") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_chroot") - mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_manual") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_packages") + mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_queue") mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") - return Executor("x86_64", configuration, no_report=True, unsafe=False) + return Executor("x86_64", configuration, database, no_report=True, unsafe=False) @pytest.fixture -def repository(configuration: Configuration, mocker: MockerFixture) -> Repository: +def repository(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> Repository: """ fixture for repository :param configuration: configuration fixture + :param database: database fixture :param mocker: mocker object :return: repository test instance """ mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") - return Repository("x86_64", configuration, no_report=True, unsafe=False) + return Repository("x86_64", configuration, database, no_report=True, unsafe=False) @pytest.fixture -def properties(configuration: Configuration) -> Properties: +def properties(configuration: Configuration, database: SQLite) -> Properties: """ fixture for properties :param configuration: configuration fixture + :param database: database fixture :return: properties test instance """ - return Properties("x86_64", configuration, no_report=True, unsafe=False) + return Properties("x86_64", configuration, database, no_report=True, unsafe=False) @pytest.fixture -def update_handler(configuration: Configuration, mocker: MockerFixture) -> UpdateHandler: +def update_handler(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> UpdateHandler: """ fixture for update handler :param configuration: configuration fixture + :param database: database fixture :param mocker: mocker object :return: update handler test instance """ - mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_build") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_cache") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_chroot") - mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_manual") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_packages") + mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_queue") mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") - return UpdateHandler("x86_64", configuration, no_report=True, unsafe=False) + return UpdateHandler("x86_64", configuration, database, no_report=True, unsafe=False) diff --git a/tests/ahriman/core/repository/test_cleaner.py b/tests/ahriman/core/repository/test_cleaner.py index 84e32996..2b7a06cb 100644 --- a/tests/ahriman/core/repository/test_cleaner.py +++ b/tests/ahriman/core/repository/test_cleaner.py @@ -36,15 +36,6 @@ def test_packages_built(cleaner: Cleaner) -> None: cleaner.packages_built() -def test_clear_build(cleaner: Cleaner, mocker: MockerFixture) -> None: - """ - must remove directories with sources - """ - _mock_clear(mocker) - cleaner.clear_build() - _mock_clear_check() - - def test_clear_cache(cleaner: Cleaner, mocker: MockerFixture) -> None: """ must remove every cached sources @@ -63,15 +54,6 @@ def test_clear_chroot(cleaner: Cleaner, mocker: MockerFixture) -> None: _mock_clear_check() -def test_clear_manual(cleaner: Cleaner, mocker: MockerFixture) -> None: - """ - must clear directory with manual packages - """ - _mock_clear(mocker) - cleaner.clear_manual() - _mock_clear_check() - - def test_clear_packages(cleaner: Cleaner, mocker: MockerFixture) -> None: """ must delete built packages @@ -84,10 +66,10 @@ def test_clear_packages(cleaner: Cleaner, mocker: MockerFixture) -> None: Path.unlink.assert_has_calls([mock.call(), mock.call(), mock.call()]) -def test_clear_patches(cleaner: Cleaner, mocker: MockerFixture) -> None: +def test_clear_queue(cleaner: Cleaner, mocker: MockerFixture) -> None: """ - must clear directory with patches + must clear queued packages from the database """ - _mock_clear(mocker) - cleaner.clear_patches() - _mock_clear_check() + clear_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.build_queue_clear") + cleaner.clear_queue() + clear_mock.assert_called_once_with(None) diff --git a/tests/ahriman/core/repository/test_executor.py b/tests/ahriman/core/repository/test_executor.py index 579b766f..eb51e7dd 100644 --- a/tests/ahriman/core/repository/test_executor.py +++ b/tests/ahriman/core/repository/test_executor.py @@ -41,9 +41,6 @@ def test_process_build(executor: Executor, package_ahriman: Package, mocker: Moc move_mock.assert_called_once_with(Path(package_ahriman.base), executor.paths.packages / package_ahriman.base) # must update status status_client_mock.assert_called_once_with(package_ahriman.base) - # must clear directory - from ahriman.core.repository.cleaner import Cleaner - Cleaner.clear_build.assert_called_once_with() def test_process_build_failure(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None: diff --git a/tests/ahriman/core/repository/test_properties.py b/tests/ahriman/core/repository/test_properties.py index 743542e5..893f7122 100644 --- a/tests/ahriman/core/repository/test_properties.py +++ b/tests/ahriman/core/repository/test_properties.py @@ -1,51 +1,52 @@ from pytest_mock import MockerFixture from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.exceptions import UnsafeRun from ahriman.core.repository.properties import Properties from ahriman.core.status.web_client import WebClient -def test_create_tree_on_load(configuration: Configuration, mocker: MockerFixture) -> None: +def test_create_tree_on_load(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> None: """ must create tree on load """ mocker.patch("ahriman.core.repository.properties.check_user") tree_create_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") - Properties("x86_64", configuration, True, False) + Properties("x86_64", configuration, database, True, False) tree_create_mock.assert_called_once_with() -def test_create_tree_on_load_unsafe(configuration: Configuration, mocker: MockerFixture) -> None: +def test_create_tree_on_load_unsafe(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> None: """ must not create tree on load in case if user differs from the root owner """ mocker.patch("ahriman.core.repository.properties.check_user", side_effect=UnsafeRun(0, 1)) tree_create_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") - Properties("x86_64", configuration, True, False) + Properties("x86_64", configuration, database, True, False) tree_create_mock.assert_not_called() -def test_create_dummy_report_client(configuration: Configuration, mocker: MockerFixture) -> None: +def test_create_dummy_report_client(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> None: """ must create dummy report client if report is disabled """ mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") load_mock = mocker.patch("ahriman.core.status.client.Client.load") - properties = Properties("x86_64", configuration, True, False) + properties = Properties("x86_64", configuration, database, True, False) load_mock.assert_not_called() assert not isinstance(properties.reporter, WebClient) -def test_create_full_report_client(configuration: Configuration, mocker: MockerFixture) -> None: +def test_create_full_report_client(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> None: """ must create load report client if report is enabled """ mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") load_mock = mocker.patch("ahriman.core.status.client.Client.load") - Properties("x86_64", configuration, False, False) + Properties("x86_64", configuration, database, False, False) load_mock.assert_called_once_with(configuration) diff --git a/tests/ahriman/core/repository/test_update_handler.py b/tests/ahriman/core/repository/test_update_handler.py index 60959228..f064ffc1 100644 --- a/tests/ahriman/core/repository/test_update_handler.py +++ b/tests/ahriman/core/repository/test_update_handler.py @@ -168,7 +168,7 @@ def test_updates_manual_clear(update_handler: UpdateHandler, mocker: MockerFixtu update_handler.updates_manual() from ahriman.core.repository.cleaner import Cleaner - Cleaner.clear_manual.assert_called_once_with() + Cleaner.clear_queue.assert_called_once_with() def test_updates_manual_status_known(update_handler: UpdateHandler, package_ahriman: Package, @@ -176,9 +176,8 @@ def test_updates_manual_status_known(update_handler: UpdateHandler, package_ahri """ must create record for known package via reporter """ - mocker.patch("pathlib.Path.iterdir", return_value=[package_ahriman.base]) + mocker.patch("ahriman.core.database.sqlite.SQLite.build_queue_get", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages", return_value=[package_ahriman]) - mocker.patch("ahriman.models.package.Package.load", return_value=package_ahriman) status_client_mock = mocker.patch("ahriman.core.status.client.Client.set_pending") update_handler.updates_manual() @@ -190,9 +189,8 @@ def test_updates_manual_status_unknown(update_handler: UpdateHandler, package_ah """ must create record for unknown package via reporter """ - mocker.patch("pathlib.Path.iterdir", return_value=[package_ahriman.base]) + mocker.patch("ahriman.core.database.sqlite.SQLite.build_queue_get", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages", return_value=[]) - mocker.patch("ahriman.models.package.Package.load", return_value=package_ahriman) status_client_mock = mocker.patch("ahriman.core.status.client.Client.set_unknown") update_handler.updates_manual() @@ -204,8 +202,6 @@ def test_updates_manual_with_failures(update_handler: UpdateHandler, package_ahr """ must process manual through the packages with failure """ - mocker.patch("pathlib.Path.iterdir", return_value=[package_ahriman.base]) - mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages", return_value=[]) - mocker.patch("ahriman.models.package.Package.load", side_effect=Exception()) - + mocker.patch("ahriman.core.database.sqlite.SQLite.build_queue_get", side_effect=Exception()) + mocker.patch("ahriman.core.repository.update_handler.UpdateHandler.packages", return_value=[package_ahriman]) assert update_handler.updates_manual() == [] diff --git a/tests/ahriman/core/status/conftest.py b/tests/ahriman/core/status/conftest.py index 7b0bddaf..615f9586 100644 --- a/tests/ahriman/core/status/conftest.py +++ b/tests/ahriman/core/status/conftest.py @@ -1,33 +1,8 @@ import pytest -from typing import Any, Dict - from ahriman.core.configuration import Configuration from ahriman.core.status.client import Client from ahriman.core.status.web_client import WebClient -from ahriman.models.build_status import BuildStatus, BuildStatusEnum -from ahriman.models.package import Package - - -# helpers -@pytest.helpers.register -def get_package_status(package: Package) -> Dict[str, Any]: - """ - helper to extract package status from package - :param package: package object - :return: simplified package status map (with only status and view) - """ - return {"status": BuildStatusEnum.Unknown.value, "package": package.view()} - - -@pytest.helpers.register -def get_package_status_extended(package: Package) -> Dict[str, Any]: - """ - helper to extract package status from package - :param package: package object - :return: full package status map (with timestamped build status and view) - """ - return {"status": BuildStatus().view(), "package": package.view()} # fixtures @@ -47,5 +22,5 @@ def web_client(configuration: Configuration) -> WebClient: :param configuration: configuration fixture :return: web client test instance """ - configuration.set("web", "port", 8080) + configuration.set("web", "port", "8080") return WebClient(configuration) diff --git a/tests/ahriman/core/status/test_client.py b/tests/ahriman/core/status/test_client.py index fb6e84eb..f22c848f 100644 --- a/tests/ahriman/core/status/test_client.py +++ b/tests/ahriman/core/status/test_client.py @@ -61,13 +61,6 @@ def test_get_self(client: Client) -> None: assert client.get_self().status == BuildStatusEnum.Unknown -def test_reload_auth(client: Client) -> None: - """ - must process auth reload without errors - """ - client.reload_auth() - - def test_remove(client: Client, package_ahriman: Package) -> None: """ must process remove without errors diff --git a/tests/ahriman/core/status/test_watcher.py b/tests/ahriman/core/status/test_watcher.py index 9703ebce..99b6cbf7 100644 --- a/tests/ahriman/core/status/test_watcher.py +++ b/tests/ahriman/core/status/test_watcher.py @@ -6,6 +6,7 @@ from pytest_mock import MockerFixture from unittest.mock import PropertyMock from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.exceptions import UnknownPackage from ahriman.core.status.watcher import Watcher from ahriman.core.status.web_client import WebClient @@ -13,7 +14,7 @@ from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.package import Package -def test_force_no_report(configuration: Configuration, mocker: MockerFixture) -> None: +def test_force_no_report(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> None: """ must force dummy report client """ @@ -21,122 +22,12 @@ def test_force_no_report(configuration: Configuration, mocker: MockerFixture) -> mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") load_mock = mocker.patch("ahriman.core.status.client.Client.load") - watcher = Watcher("x86_64", configuration) + watcher = Watcher("x86_64", configuration, database) load_mock.assert_not_called() assert not isinstance(watcher.repository.reporter, WebClient) -def test_cache_load(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: - """ - must load state from cache - """ - response = {"packages": [pytest.helpers.get_package_status_extended(package_ahriman)]} - - mocker.patch("pathlib.Path.is_file", return_value=True) - mocker.patch("pathlib.Path.open") - mocker.patch("json.load", return_value=response) - - watcher.known = {package_ahriman.base: (None, None)} - watcher._cache_load() - - package, status = watcher.known[package_ahriman.base] - assert package == package_ahriman - assert status.status == BuildStatusEnum.Unknown - - -def test_cache_load_json_error(watcher: Watcher, mocker: MockerFixture) -> None: - """ - must not fail on json errors - """ - mocker.patch("pathlib.Path.is_file", return_value=True) - mocker.patch("pathlib.Path.open") - mocker.patch("json.load", side_effect=Exception()) - - watcher._cache_load() - assert not watcher.known - - -def test_cache_load_no_file(watcher: Watcher, mocker: MockerFixture) -> None: - """ - must not fail on missing file - """ - mocker.patch("pathlib.Path.is_file", return_value=False) - - watcher._cache_load() - assert not watcher.known - - -def test_cache_load_package_load_error(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: - """ - must not fail on json errors - """ - response = {"packages": [pytest.helpers.get_package_status_extended(package_ahriman)]} - - mocker.patch("pathlib.Path.is_file", return_value=True) - mocker.patch("pathlib.Path.open") - mocker.patch("ahriman.models.package.Package.from_json", side_effect=Exception()) - mocker.patch("json.load", return_value=response) - - watcher._cache_load() - assert not watcher.known - - -def test_cache_load_unknown(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: - """ - must not load unknown package - """ - response = {"packages": [pytest.helpers.get_package_status_extended(package_ahriman)]} - - mocker.patch("pathlib.Path.is_file", return_value=True) - mocker.patch("pathlib.Path.open") - mocker.patch("json.load", return_value=response) - - watcher._cache_load() - assert not watcher.known - - -def test_cache_save(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: - """ - must save state to cache - """ - mocker.patch("pathlib.Path.open") - json_mock = mocker.patch("json.dump") - - watcher.known = {package_ahriman.base: (package_ahriman, BuildStatus())} - watcher._cache_save() - json_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int)) - - -def test_cache_save_failed(watcher: Watcher, mocker: MockerFixture) -> None: - """ - must not fail on dumping packages - """ - mocker.patch("pathlib.Path.open") - mocker.patch("json.dump", side_effect=Exception()) - - watcher._cache_save() - - -def test_cache_save_load(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: - """ - must save state to cache which can be loaded later - """ - dump_file = Path(tempfile.mktemp()) # nosec - mocker.patch("ahriman.core.status.watcher.Watcher.cache_path", - new_callable=PropertyMock, return_value=dump_file) - known_current = {package_ahriman.base: (package_ahriman, BuildStatus())} - - watcher.known = known_current - watcher._cache_save() - - watcher.known = {package_ahriman.base: (None, None)} - watcher._cache_load() - assert watcher.known == known_current - - dump_file.unlink() - - def test_get(watcher: Watcher, package_ahriman: Package) -> None: """ must return package status @@ -160,7 +51,7 @@ def test_load(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) must correctly load packages """ mocker.patch("ahriman.core.repository.repository.Repository.packages", return_value=[package_ahriman]) - cache_mock = mocker.patch("ahriman.core.status.watcher.Watcher._cache_load") + cache_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.packages_get") watcher.load() cache_mock.assert_called_once_with() @@ -173,9 +64,10 @@ def test_load_known(watcher: Watcher, package_ahriman: Package, mocker: MockerFi """ must correctly load packages with known statuses """ + status = BuildStatus(BuildStatusEnum.Success) mocker.patch("ahriman.core.repository.repository.Repository.packages", return_value=[package_ahriman]) - mocker.patch("ahriman.core.status.watcher.Watcher._cache_load") - watcher.known = {package_ahriman.base: (package_ahriman, BuildStatus(BuildStatusEnum.Success))} + mocker.patch("ahriman.core.database.sqlite.SQLite.packages_get", return_value=[(package_ahriman, status)]) + watcher.known = {package_ahriman.base: (package_ahriman, status)} watcher.load() _, status = watcher.known[package_ahriman.base] @@ -186,32 +78,32 @@ def test_remove(watcher: Watcher, package_ahriman: Package, mocker: MockerFixtur """ must remove package base """ - cache_mock = mocker.patch("ahriman.core.status.watcher.Watcher._cache_save") + cache_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.package_remove") watcher.known = {package_ahriman.base: (package_ahriman, BuildStatus())} watcher.remove(package_ahriman.base) assert not watcher.known - cache_mock.assert_called_once_with() + cache_mock.assert_called_once_with(package_ahriman.base) def test_remove_unknown(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: """ must not fail on unknown base removal """ - cache_mock = mocker.patch("ahriman.core.status.watcher.Watcher._cache_save") + cache_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.package_remove") watcher.remove(package_ahriman.base) - cache_mock.assert_called_once_with() + cache_mock.assert_called_once_with(package_ahriman.base) def test_update(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: """ must update package status """ - cache_mock = mocker.patch("ahriman.core.status.watcher.Watcher._cache_save") + cache_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.package_update") watcher.update(package_ahriman.base, BuildStatusEnum.Unknown, package_ahriman) - cache_mock.assert_called_once_with() + cache_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(int)) package, status = watcher.known[package_ahriman.base] assert package == package_ahriman assert status.status == BuildStatusEnum.Unknown @@ -221,25 +113,22 @@ def test_update_ping(watcher: Watcher, package_ahriman: Package, mocker: MockerF """ must update package status only for known package """ - cache_mock = mocker.patch("ahriman.core.status.watcher.Watcher._cache_save") + cache_mock = mocker.patch("ahriman.core.database.sqlite.SQLite.package_update") watcher.known = {package_ahriman.base: (package_ahriman, BuildStatus())} watcher.update(package_ahriman.base, BuildStatusEnum.Success, None) - cache_mock.assert_called_once_with() + cache_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(int)) package, status = watcher.known[package_ahriman.base] assert package == package_ahriman assert status.status == BuildStatusEnum.Success -def test_update_unknown(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: +def test_update_unknown(watcher: Watcher, package_ahriman: Package) -> None: """ must fail on unknown package status update only """ - cache_mock = mocker.patch("ahriman.core.status.watcher.Watcher._cache_save") - with pytest.raises(UnknownPackage): watcher.update(package_ahriman.base, BuildStatusEnum.Unknown, None) - cache_mock.assert_called_once_with() def test_update_self(watcher: Watcher) -> None: diff --git a/tests/ahriman/core/status/test_web_client.py b/tests/ahriman/core/status/test_web_client.py index c78195f1..a8ffa2ba 100644 --- a/tests/ahriman/core/status/test_web_client.py +++ b/tests/ahriman/core/status/test_web_client.py @@ -230,32 +230,6 @@ def test_get_self_failed_http_error(web_client: WebClient, mocker: MockerFixture assert web_client.get_self().status == BuildStatusEnum.Unknown -def test_reload_auth(web_client: WebClient, mocker: MockerFixture) -> None: - """ - must process auth reload - """ - requests_mock = mocker.patch("requests.Session.post") - - web_client.reload_auth() - requests_mock.assert_called_once_with(pytest.helpers.anyvar(str, True)) - - -def test_reload_auth_failed(web_client: WebClient, mocker: MockerFixture) -> None: - """ - must suppress any exception happened during auth reload - """ - mocker.patch("requests.Session.post", side_effect=Exception()) - web_client.reload_auth() - - -def test_reload_auth_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: - """ - must suppress any exception happened during removal - """ - mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError()) - web_client.reload_auth() - - def test_remove(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must process package removal diff --git a/tests/ahriman/core/test_configuration.py b/tests/ahriman/core/test_configuration.py index 2a1a95f6..0106d16f 100644 --- a/tests/ahriman/core/test_configuration.py +++ b/tests/ahriman/core/test_configuration.py @@ -8,6 +8,14 @@ from unittest import mock from ahriman.core.configuration import Configuration from ahriman.core.exceptions import InitializeException +from ahriman.models.repository_paths import RepositoryPaths + + +def test_repository_paths(configuration: Configuration, repository_paths: RepositoryPaths) -> None: + """ + must return repository paths + """ + assert configuration.repository_paths == repository_paths def test_from_path(mocker: MockerFixture) -> None: @@ -40,6 +48,33 @@ def test_from_path_file_missing(mocker: MockerFixture) -> None: read_mock.assert_called_once_with(configuration.SYSTEM_CONFIGURATION_PATH) +def test_check_loaded(configuration: Configuration) -> None: + """ + must return valid path and architecture + """ + path, architecture = configuration.check_loaded() + assert path == configuration.path + assert architecture == configuration.architecture + + +def test_check_loaded_path(configuration: Configuration) -> None: + """ + must raise exception if path is none + """ + configuration.path = None + with pytest.raises(InitializeException): + configuration.check_loaded() + + +def test_check_loaded_architecture(configuration: Configuration) -> None: + """ + must raise exception if architecture is none + """ + configuration.architecture = None + with pytest.raises(InitializeException): + configuration.check_loaded() + + def test_dump(configuration: Configuration) -> None: """ dump must not be empty diff --git a/tests/ahriman/core/test_tree.py b/tests/ahriman/core/test_tree.py index 7109bf8c..6e44855c 100644 --- a/tests/ahriman/core/test_tree.py +++ b/tests/ahriman/core/test_tree.py @@ -2,9 +2,9 @@ import pytest from pytest_mock import MockerFixture +from ahriman.core.database.sqlite import SQLite from ahriman.core.tree import Leaf, Tree from ahriman.models.package import Package -from ahriman.models.repository_paths import RepositoryPaths def test_leaf_is_root_empty(leaf_ahriman: Leaf) -> None: @@ -37,7 +37,7 @@ def test_leaf_is_root_true(leaf_ahriman: Leaf, leaf_python_schedule: Leaf) -> No assert not leaf_ahriman.is_root([leaf_python_schedule]) -def test_leaf_load(package_ahriman: Package, repository_paths: RepositoryPaths, mocker: MockerFixture) -> None: +def test_leaf_load(package_ahriman: Package, database: SQLite, mocker: MockerFixture) -> None: """ must load with dependencies """ @@ -46,12 +46,12 @@ def test_leaf_load(package_ahriman: Package, repository_paths: RepositoryPaths, dependencies_mock = mocker.patch("ahriman.models.package.Package.dependencies", return_value={"ahriman-dependency"}) rmtree_mock = mocker.patch("shutil.rmtree") - leaf = Leaf.load(package_ahriman, repository_paths) + leaf = Leaf.load(package_ahriman, database) assert leaf.package == package_ahriman assert leaf.dependencies == {"ahriman-dependency"} tempdir_mock.assert_called_once_with() load_mock.assert_called_once_with( - pytest.helpers.anyvar(int), package_ahriman.git_url, repository_paths.patches_for(package_ahriman.base)) + pytest.helpers.anyvar(int), package_ahriman.git_url, database.patches_get(package_ahriman.base)) dependencies_mock.assert_called_once_with(pytest.helpers.anyvar(int)) rmtree_mock.assert_called_once_with(pytest.helpers.anyvar(int), ignore_errors=True) @@ -69,8 +69,8 @@ def test_tree_levels(leaf_ahriman: Leaf, leaf_python_schedule: Leaf) -> None: assert second == [leaf_ahriman.package] -def test_tree_load(package_ahriman: Package, package_python_schedule: Package, - repository_paths: RepositoryPaths, mocker: MockerFixture) -> None: +def test_tree_load(package_ahriman: Package, package_python_schedule: Package, database: SQLite, + mocker: MockerFixture) -> None: """ must package list """ @@ -79,5 +79,5 @@ def test_tree_load(package_ahriman: Package, package_python_schedule: Package, mocker.patch("ahriman.models.package.Package.dependencies") mocker.patch("shutil.rmtree") - tree = Tree.load([package_ahriman, package_python_schedule], repository_paths) + tree = Tree.load([package_ahriman, package_python_schedule], database) assert len(tree.leaves) == 2 diff --git a/tests/ahriman/core/test_util.py b/tests/ahriman/core/test_util.py index 0de8c851..b38e44d2 100644 --- a/tests/ahriman/core/test_util.py +++ b/tests/ahriman/core/test_util.py @@ -7,7 +7,7 @@ from pathlib import Path from pytest_mock import MockerFixture from ahriman.core.exceptions import InvalidOption, UnsafeRun -from ahriman.core.util import check_output, check_user, filter_json, package_like, pretty_datetime, pretty_size, walk +from ahriman.core.util import check_output, check_user, filter_json, package_like, pretty_datetime, pretty_size, tmpdir, walk from ahriman.models.package import Package from ahriman.models.repository_paths import RepositoryPaths @@ -207,6 +207,25 @@ def test_pretty_size_empty() -> None: assert pretty_size(None) == "" +def test_tmpdir() -> None: + """ + must create temporary directory and remove it after + """ + with tmpdir() as directory: + assert directory.is_dir() + assert not directory.exists() + + +def test_tmpdir_failure() -> None: + """ + must create temporary directory and remove it even after exception + """ + with pytest.raises(Exception): + with tmpdir() as directory: + raise Exception() + assert not directory.exists() + + def test_walk(resource_path_root: Path) -> None: """ must traverse directory recursively diff --git a/tests/ahriman/models/test_migration.py b/tests/ahriman/models/test_migration.py new file mode 100644 index 00000000..870782bc --- /dev/null +++ b/tests/ahriman/models/test_migration.py @@ -0,0 +1,34 @@ +import pytest + +from pytest_mock import MockerFixture + +from ahriman.core.exceptions import MigrationError +from ahriman.models.migration_result import MigrationResult + + +def test_is_outdated() -> None: + """ + must return False for outdated schema + """ + assert MigrationResult(old_version=0, new_version=1).is_outdated + assert not MigrationResult(old_version=1, new_version=1).is_outdated + + +def test_is_outdated_validation(mocker: MockerFixture) -> None: + """ + must call validation before version check + """ + validate_mock = mocker.patch("ahriman.models.migration_result.MigrationResult.validate") + assert MigrationResult(old_version=0, new_version=1).is_outdated + validate_mock.assert_called_once_with() + + +def test_validate() -> None: + """ + must raise exception on invalid migration versions + """ + with pytest.raises(MigrationError): + MigrationResult(old_version=-1, new_version=0).validate() + + with pytest.raises(MigrationError): + MigrationResult(old_version=1, new_version=0).validate() diff --git a/tests/ahriman/models/test_migration_result.py b/tests/ahriman/models/test_migration_result.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ahriman/models/test_package_description.py b/tests/ahriman/models/test_package_description.py index 3229704a..6b158e56 100644 --- a/tests/ahriman/models/test_package_description.py +++ b/tests/ahriman/models/test_package_description.py @@ -44,3 +44,10 @@ def test_from_package(package_description_ahriman: PackageDescription, package_description = PackageDescription.from_package(pyalpm_package_description_ahriman, package_description_ahriman.filepath) assert package_description_ahriman == package_description + + +def test_from_json_view(package_description_ahriman: PackageDescription) -> None: + """ + must generate same description from json view + """ + assert PackageDescription.from_json(package_description_ahriman.view()) == package_description_ahriman diff --git a/tests/ahriman/models/test_repository_paths.py b/tests/ahriman/models/test_repository_paths.py index 462cfbd6..b1109e7c 100644 --- a/tests/ahriman/models/test_repository_paths.py +++ b/tests/ahriman/models/test_repository_paths.py @@ -111,33 +111,6 @@ def test_chown_invalid_path(repository_paths: RepositoryPaths) -> None: repository_paths.chown(repository_paths.root.parent) -def test_manual_for(repository_paths: RepositoryPaths, package_ahriman: Package) -> None: - """ - must return correct path for manual directory - """ - path = repository_paths.manual_for(package_ahriman.base) - assert path.name == package_ahriman.base - assert path.parent == repository_paths.manual - - -def test_patches_for(repository_paths: RepositoryPaths, package_ahriman: Package) -> None: - """ - must return correct path for patches directory - """ - path = repository_paths.patches_for(package_ahriman.base) - assert path.name == package_ahriman.base - assert path.parent == repository_paths.patches - - -def test_sources_for(repository_paths: RepositoryPaths, package_ahriman: Package) -> None: - """ - must return correct path for sources directory - """ - path = repository_paths.sources_for(package_ahriman.base) - assert path.name == package_ahriman.base - assert path.parent == repository_paths.sources - - def test_tree_clear(repository_paths: RepositoryPaths, package_ahriman: Package, mocker: MockerFixture) -> None: """ must remove any package related files diff --git a/tests/ahriman/models/test_user.py b/tests/ahriman/models/test_user.py index fbe29b9c..bc327c00 100644 --- a/tests/ahriman/models/test_user.py +++ b/tests/ahriman/models/test_user.py @@ -27,7 +27,7 @@ def test_check_credentials_hash_password(user: User) -> None: must generate and validate user password """ current_password = user.password - user.password = user.hash_password("salt") + user = user.hash_password("salt") assert user.check_credentials(current_password, "salt") assert not user.check_credentials(current_password, "salt1") assert not user.check_credentials(user.password, "salt") @@ -48,9 +48,9 @@ def test_hash_password_empty_hash(user: User) -> None: must return empty string after hash in case if password not set """ user.password = "" - assert user.hash_password("salt") == "" + assert user.hash_password("salt") == user user.password = None - assert user.hash_password("salt") == "" + assert user.hash_password("salt") == user def test_generate_password() -> None: diff --git a/tests/ahriman/web/conftest.py b/tests/ahriman/web/conftest.py index 5d45e6e8..1a94fe19 100644 --- a/tests/ahriman/web/conftest.py +++ b/tests/ahriman/web/conftest.py @@ -8,6 +8,7 @@ from typing import Any import ahriman.core.auth.helpers from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.core.spawn import Spawn from ahriman.models.user import User from ahriman.web.web import setup_service @@ -31,53 +32,60 @@ def request(app: web.Application, path: str, method: str, json: Any = None, data @pytest.fixture -def application(configuration: Configuration, spawner: Spawn, mocker: MockerFixture) -> web.Application: +def application(configuration: Configuration, spawner: Spawn, database: SQLite, + mocker: MockerFixture) -> web.Application: """ application fixture :param configuration: configuration fixture :param spawner: spawner fixture + :param database: database fixture :param mocker: mocker object :return: application test instance """ + mocker.patch("ahriman.core.database.sqlite.SQLite.load", return_value=database) mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", False) return setup_service("x86_64", configuration, spawner) @pytest.fixture -def application_with_auth(configuration: Configuration, user: User, spawner: Spawn, +def application_with_auth(configuration: Configuration, user: User, spawner: Spawn, database: SQLite, mocker: MockerFixture) -> web.Application: """ application fixture with auth enabled :param configuration: configuration fixture :param user: user descriptor fixture :param spawner: spawner fixture + :param database: database fixture :param mocker: mocker object :return: application test instance """ configuration.set_option("auth", "target", "configuration") + mocker.patch("ahriman.core.database.sqlite.SQLite.load", return_value=database) mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", True) application = setup_service("x86_64", configuration, spawner) - generated = User(user.username, user.hash_password(application["validator"].salt), user.access) - application["validator"]._users[generated.username] = generated + generated = user.hash_password(application["validator"].salt) + mocker.patch("ahriman.core.database.sqlite.SQLite.user_get", return_value=generated) return application @pytest.fixture -def application_with_debug(configuration: Configuration, user: User, spawner: Spawn, +def application_with_debug(configuration: Configuration, user: User, spawner: Spawn, database: SQLite, mocker: MockerFixture) -> web.Application: """ application fixture with debug enabled :param configuration: configuration fixture :param user: user descriptor fixture :param spawner: spawner fixture + :param database: database fixture :param mocker: mocker object :return: application test instance """ configuration.set_option("web", "debug", "yes") + mocker.patch("ahriman.core.database.sqlite.SQLite.load", return_value=database) mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", False) return setup_service("x86_64", configuration, spawner) diff --git a/tests/ahriman/web/middlewares/conftest.py b/tests/ahriman/web/middlewares/conftest.py index a6b1a2df..aa6ed604 100644 --- a/tests/ahriman/web/middlewares/conftest.py +++ b/tests/ahriman/web/middlewares/conftest.py @@ -2,18 +2,18 @@ import pytest from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration +from ahriman.core.database.sqlite import SQLite from ahriman.models.user import User from ahriman.web.middlewares.auth_handler import AuthorizationPolicy @pytest.fixture -def authorization_policy(configuration: Configuration, user: User) -> AuthorizationPolicy: +def authorization_policy(configuration: Configuration, database: SQLite, user: User) -> AuthorizationPolicy: """ fixture for authorization policy :return: authorization policy fixture """ configuration.set_option("auth", "target", "configuration") - validator = Auth.load(configuration) + validator = Auth.load(configuration, database) policy = AuthorizationPolicy(validator) - policy.validator._users = {user.username: user} return policy diff --git a/tests/ahriman/web/middlewares/test_auth_handler.py b/tests/ahriman/web/middlewares/test_auth_handler.py index e70fcdac..145b2f4f 100644 --- a/tests/ahriman/web/middlewares/test_auth_handler.py +++ b/tests/ahriman/web/middlewares/test_auth_handler.py @@ -20,11 +20,18 @@ def _identity(username: str) -> str: return f"{username} {UserIdentity.expire_when(60)}" -async def test_authorized_userid(authorization_policy: AuthorizationPolicy, user: User) -> None: +async def test_authorized_userid(authorization_policy: AuthorizationPolicy, user: User, mocker: MockerFixture) -> None: """ must return authorized user id """ + mocker.patch("ahriman.core.database.sqlite.SQLite.user_get", return_value=user) assert await authorization_policy.authorized_userid(_identity(user.username)) == user.username + + +async def test_authorized_userid_unknown(authorization_policy: AuthorizationPolicy, user: User) -> None: + """ + must not allow unknown user id for authorization + """ assert await authorization_policy.authorized_userid(_identity("somerandomname")) is None assert await authorization_policy.authorized_userid("somerandomname") is None diff --git a/tests/ahriman/web/views/conftest.py b/tests/ahriman/web/views/conftest.py index 2bc0b191..befa19ad 100644 --- a/tests/ahriman/web/views/conftest.py +++ b/tests/ahriman/web/views/conftest.py @@ -2,11 +2,12 @@ import pytest from aiohttp import web from asyncio import BaseEventLoop - from aiohttp.test_utils import TestClient from pytest_mock import MockerFixture from typing import Any +from unittest.mock import MagicMock +from ahriman.core.auth.oauth import OAuth from ahriman.web.views.base import BaseView @@ -21,30 +22,46 @@ def base(application: web.Application) -> BaseView: @pytest.fixture -def client(application: web.Application, loop: BaseEventLoop, +def client(application: web.Application, event_loop: BaseEventLoop, aiohttp_client: Any, mocker: MockerFixture) -> TestClient: """ web client fixture :param application: application fixture - :param loop: context event loop + :param event_loop: context event loop :param aiohttp_client: aiohttp client fixture :param mocker: mocker object :return: web client test instance """ mocker.patch("pathlib.Path.iterdir", return_value=[]) - return loop.run_until_complete(aiohttp_client(application)) + return event_loop.run_until_complete(aiohttp_client(application)) @pytest.fixture -def client_with_auth(application_with_auth: web.Application, loop: BaseEventLoop, +def client_with_auth(application_with_auth: web.Application, event_loop: BaseEventLoop, aiohttp_client: Any, mocker: MockerFixture) -> TestClient: """ web client fixture with full authorization functions :param application_with_auth: application fixture - :param loop: context event loop + :param event_loop: context event loop :param aiohttp_client: aiohttp client fixture :param mocker: mocker object :return: web client test instance """ mocker.patch("pathlib.Path.iterdir", return_value=[]) - return loop.run_until_complete(aiohttp_client(application_with_auth)) + return event_loop.run_until_complete(aiohttp_client(application_with_auth)) + + +@pytest.fixture +def client_with_oauth_auth(application_with_auth: web.Application, event_loop: BaseEventLoop, + aiohttp_client: Any, mocker: MockerFixture) -> TestClient: + """ + web client fixture with full authorization functions + :param application_with_auth: application fixture + :param event_loop: context event loop + :param aiohttp_client: aiohttp client fixture + :param mocker: mocker object + :return: web client test instance + """ + mocker.patch("pathlib.Path.iterdir", return_value=[]) + application_with_auth["validator"] = MagicMock(spec=OAuth) + return event_loop.run_until_complete(aiohttp_client(application_with_auth)) diff --git a/tests/ahriman/web/views/service/test_views_service_reload_auth.py b/tests/ahriman/web/views/service/test_views_service_reload_auth.py deleted file mode 100644 index 77daf8ae..00000000 --- a/tests/ahriman/web/views/service/test_views_service_reload_auth.py +++ /dev/null @@ -1,41 +0,0 @@ -import pytest - -from aiohttp.test_utils import TestClient -from pytest_mock import MockerFixture - -from ahriman.models.user_access import UserAccess -from ahriman.web.views.service.reload_auth import ReloadAuthView - - -async def test_get_permission() -> None: - """ - must return correct permission for the request - """ - for method in ("POST",): - request = pytest.helpers.request("", "", method) - assert await ReloadAuthView.get_permission(request) == UserAccess.Write - - -async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None: - """ - must call post request correctly - """ - mocker.patch("aiohttp_security.check_permission", return_value=True) - reload_mock = mocker.patch("ahriman.core.configuration.Configuration.reload") - load_mock = mocker.patch("ahriman.core.auth.auth.Auth.load") - response = await client_with_auth.post("/service-api/v1/reload-auth") - - assert response.ok - reload_mock.assert_called_once_with() - load_mock.assert_called_once_with(client_with_auth.app["configuration"]) - - -async def test_post_no_auth(client: TestClient, mocker: MockerFixture) -> None: - """ - must call return 500 if no authorization module loaded - """ - reload_mock = mocker.patch("ahriman.core.configuration.Configuration.reload") - response = await client.post("/service-api/v1/reload-auth") - - assert response.status == 500 - reload_mock.assert_called_once_with() diff --git a/tests/ahriman/web/views/test_views_base.py b/tests/ahriman/web/views/test_views_base.py index 78eb56b9..9f8b9d0b 100644 --- a/tests/ahriman/web/views/test_views_base.py +++ b/tests/ahriman/web/views/test_views_base.py @@ -12,6 +12,13 @@ def test_configuration(base: BaseView) -> None: assert base.configuration +def test_database(base: BaseView) -> None: + """ + must return database + """ + assert base.database + + def test_service(base: BaseView) -> None: """ must return service diff --git a/tests/ahriman/web/views/test_views_index.py b/tests/ahriman/web/views/test_views_index.py index 1cdc4558..3b51adfd 100644 --- a/tests/ahriman/web/views/test_views_index.py +++ b/tests/ahriman/web/views/test_views_index.py @@ -52,7 +52,7 @@ async def test_get_static(client: TestClient) -> None: async def test_get_static_with_auth(client_with_auth: TestClient) -> None: """ - must return static files + must return static files with authorization enabled """ response = await client_with_auth.get("/static/favicon.ico") assert response.ok diff --git a/tests/ahriman/web/views/user/test_views_user_login.py b/tests/ahriman/web/views/user/test_views_user_login.py index ba330cac..ae069b6d 100644 --- a/tests/ahriman/web/views/user/test_views_user_login.py +++ b/tests/ahriman/web/views/user/test_views_user_login.py @@ -27,42 +27,42 @@ async def test_get_default_validator(client_with_auth: TestClient) -> None: assert get_response.status == 405 -async def test_get_redirect_to_oauth(client_with_auth: TestClient) -> None: +async def test_get_redirect_to_oauth(client_with_oauth_auth: TestClient) -> None: """ must redirect to OAuth service provider in case if no code is supplied """ - oauth = client_with_auth.app["validator"] = MagicMock(spec=OAuth) + oauth = client_with_oauth_auth.app["validator"] oauth.get_oauth_url.return_value = "https://httpbin.org" - get_response = await client_with_auth.get("/user-api/v1/login") + get_response = await client_with_oauth_auth.get("/user-api/v1/login") assert get_response.ok oauth.get_oauth_url.assert_called_once_with() -async def test_get_redirect_to_oauth_empty_code(client_with_auth: TestClient) -> None: +async def test_get_redirect_to_oauth_empty_code(client_with_oauth_auth: TestClient) -> None: """ must redirect to OAuth service provider in case if empty code is supplied """ - oauth = client_with_auth.app["validator"] = MagicMock(spec=OAuth) + oauth = client_with_oauth_auth.app["validator"] oauth.get_oauth_url.return_value = "https://httpbin.org" - get_response = await client_with_auth.get("/user-api/v1/login", params={"code": ""}) + get_response = await client_with_oauth_auth.get("/user-api/v1/login", params={"code": ""}) assert get_response.ok oauth.get_oauth_url.assert_called_once_with() -async def test_get(client_with_auth: TestClient, mocker: MockerFixture) -> None: +async def test_get(client_with_oauth_auth: TestClient, mocker: MockerFixture) -> None: """ must login user correctly from OAuth """ - oauth = client_with_auth.app["validator"] = MagicMock(spec=OAuth) + oauth = client_with_oauth_auth.app["validator"] oauth.get_oauth_username.return_value = "user" oauth.known_username.return_value = True oauth.enabled = False # lol oauth.max_age = 60 remember_mock = mocker.patch("aiohttp_security.remember") - get_response = await client_with_auth.get("/user-api/v1/login", params={"code": "code"}) + get_response = await client_with_oauth_auth.get("/user-api/v1/login", params={"code": "code"}) assert get_response.ok oauth.get_oauth_username.assert_called_once_with("code") @@ -71,16 +71,16 @@ async def test_get(client_with_auth: TestClient, mocker: MockerFixture) -> None: pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), pytest.helpers.anyvar(int)) -async def test_get_unauthorized(client_with_auth: TestClient, mocker: MockerFixture) -> None: +async def test_get_unauthorized(client_with_oauth_auth: TestClient, mocker: MockerFixture) -> None: """ must return unauthorized from OAuth """ - oauth = client_with_auth.app["validator"] = MagicMock(spec=OAuth) + oauth = client_with_oauth_auth.app["validator"] oauth.known_username.return_value = False oauth.max_age = 60 remember_mock = mocker.patch("aiohttp_security.remember") - get_response = await client_with_auth.get("/user-api/v1/login", params={"code": "code"}) + get_response = await client_with_oauth_auth.get("/user-api/v1/login", params={"code": "code"}) assert get_response.status == 401 remember_mock.assert_not_called() diff --git a/tests/testresources/core/ahriman.ini b/tests/testresources/core/ahriman.ini index eed9a221..60f304c1 100644 --- a/tests/testresources/core/ahriman.ini +++ b/tests/testresources/core/ahriman.ini @@ -1,6 +1,7 @@ [settings] include = . logging = logging.ini +database = ../../../ahriman-test.db [alpm] aur_url = https://aur.archlinux.org diff --git a/tox.ini b/tox.ini index 4fd08414..1bae71d2 100644 --- a/tox.ini +++ b/tox.ini @@ -4,8 +4,8 @@ dependencies = -e .[s3,web] project_name = ahriman [pytest] -addopts = --cov=ahriman --cov-report=term-missing:skip-covered --spec -asyncio_mode = legacy +addopts = --cov=ahriman --cov-report=term-missing:skip-covered --no-cov-on-fail --cov-fail-under=100 --spec +asyncio_mode = auto spec_test_format = {result} {docstring_summary} [testenv]