Compare commits

...

13 Commits
1.3.0 ... 1.4.1

Author SHA1 Message Date
db195391e4 Release 1.4.1 2021-10-03 01:02:41 +03:00
59f2992559 do not use set_defaults for architecture arguments
according to the source code defaults always updates the values
dictionary. This in this specific case it is impossible to override the
value it will be always empty list.

In order to handle it we are adding another property to the Handler
class which allows to run with None architecture list.

This particular set_defaults behaviour is still useful for other cases
when we have to run command without any specific architecture
2021-10-03 00:59:24 +03:00
4f06647193 Release 1.4.0 2021-10-01 09:25:35 +03:00
73a4cee257 add package request endpoint 2021-10-01 08:58:50 +03:00
13d00c6f66 docs update 2021-09-26 14:39:35 +03:00
3e032c3515 add index url 2021-09-26 14:29:42 +03:00
d73d5daad3 add debugtoolbar support 2021-09-26 12:31:12 +03:00
f55b44b391 set both value and innerText during search
current implementation just adds package name with the description which
is incorrect
2021-09-26 10:34:21 +03:00
51b28baf40 add ability to specify package source explicitly during the addition 2021-09-26 09:55:14 +03:00
24326f9753 define permissions in views directly 2021-09-25 17:03:46 +03:00
36c763069d only set file rights if requested 2021-09-23 20:36:22 +03:00
c9a155bbc4 raise httpexception instead of returning it from a function 2021-09-19 14:28:24 +03:00
182bde5e09 add manpage 2021-09-19 13:56:11 +03:00
80 changed files with 3211 additions and 1921 deletions

2
.gitignore vendored
View File

@ -94,5 +94,3 @@ ENV/
.venv/ .venv/
*.tar.xz *.tar.xz
man/

View File

@ -1,4 +1,4 @@
.PHONY: architecture archive archive_directory archlinux check clean directory push tests version .PHONY: architecture archive archive_directory archlinux check clean directory man push tests version
.DEFAULT_GOAL := archlinux .DEFAULT_GOAL := archlinux
PROJECT := ahriman PROJECT := ahriman
@ -39,12 +39,15 @@ clean:
directory: clean directory: clean
mkdir "$(PROJECT)" mkdir "$(PROJECT)"
man:
cd src && PYTHONPATH=. argparse-manpage --module ahriman.application.ahriman --function _parser --author "ahriman team" --project-name ahriman --author-email "" --url https://github.com/arcan1s/ahriman --output ../docs/ahriman.1
mypy: mypy:
cd src && mypy --implicit-reexport --strict -p "$(PROJECT)" --install-types --non-interactive || true cd src && mypy --implicit-reexport --strict -p "$(PROJECT)" --install-types --non-interactive || true
cd src && mypy --implicit-reexport --strict -p "$(PROJECT)" cd src && mypy --implicit-reexport --strict -p "$(PROJECT)"
push: archlinux push: architecture man archlinux
git add package/archlinux/PKGBUILD src/ahriman/version.py git add package/archlinux/PKGBUILD src/ahriman/version.py docs/ahriman-architecture.svg docs/ahriman.1
git commit -m "Release $(VERSION)" git commit -m "Release $(VERSION)"
git tag "$(VERSION)" git tag "$(VERSION)"
git push git push

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 326 KiB

401
docs/ahriman.1 Normal file
View File

@ -0,0 +1,401 @@
.TH ahriman "1" Manual
.SH NAME
ahriman
.SH SYNOPSIS
.B ahriman
[-h] [-a ARCHITECTURE] [-c CONFIGURATION] [--force] [-l LOCK] [--no-log] [--no-report] [--unsafe] [-v] {add,check,clean,config,init,key-import,rebuild,remove,remove-unknown,report,search,setup,sign,status,status-update,sync,update,user,web} ...
.SH DESCRIPTION
ArcH Linux ReposItory MANager
.SH OPTIONS
.TP
\fB\-a\fR \fI\,ARCHITECTURE\/\fR, \fB\-\-architecture\fR \fI\,ARCHITECTURE\/\fR
target architectures (can be used multiple times)
.TP
\fB\-c\fR \fI\,CONFIGURATION\/\fR, \fB\-\-configuration\fR \fI\,CONFIGURATION\/\fR
configuration path
.TP
\fB\-\-force\fR
force run, remove file lock
.TP
\fB\-l\fR \fI\,LOCK\/\fR, \fB\-\-lock\fR \fI\,LOCK\/\fR
lock file
.TP
\fB\-\-no\-log\fR
redirect all log messages to stderr
.TP
\fB\-\-no\-report\fR
force disable reporting to web service
.TP
\fB\-\-unsafe\fR
allow to run ahriman as non\-ahriman user
.TP
\fB\-v\fR, \fB\-\-version\fR
show program's version number and exit
.SS
\fBSub-commands\fR
.TP
\fBahriman\fR \fI\,add\/\fR
add package
.TP
\fBahriman\fR \fI\,check\/\fR
check for updates
.TP
\fBahriman\fR \fI\,clean\/\fR
clean local caches
.TP
\fBahriman\fR \fI\,config\/\fR
dump configuration
.TP
\fBahriman\fR \fI\,init\/\fR
create repository tree
.TP
\fBahriman\fR \fI\,key-import\/\fR
import PGP key
.TP
\fBahriman\fR \fI\,rebuild\/\fR
rebuild repository
.TP
\fBahriman\fR \fI\,remove\/\fR
remove package
.TP
\fBahriman\fR \fI\,remove-unknown\/\fR
remove unknown packages
.TP
\fBahriman\fR \fI\,report\/\fR
generate report
.TP
\fBahriman\fR \fI\,search\/\fR
search for package
.TP
\fBahriman\fR \fI\,setup\/\fR
initial service configuration
.TP
\fBahriman\fR \fI\,sign\/\fR
sign packages
.TP
\fBahriman\fR \fI\,status\/\fR
get package status
.TP
\fBahriman\fR \fI\,status-update\/\fR
update package status
.TP
\fBahriman\fR \fI\,sync\/\fR
sync repository
.TP
\fBahriman\fR \fI\,update\/\fR
update packages
.TP
\fBahriman\fR \fI\,user\/\fR
manage users for web services
.TP
\fBahriman\fR \fI\,web\/\fR
start web server
.SH OPTIONS 'ahriman add'
usage: ahriman add [-h] [--now] [--source {PackageSource.Auto,PackageSource.Archive,PackageSource.Directory,PackageSource.AUR}] [--without-dependencies] package [package ...]
add package
.TP
\fBpackage\fR
package base/name or archive path
.TP
\fB\-\-now\fR
run update function after
.TP
\fB\-\-source\fR {PackageSource.Auto,PackageSource.Archive,PackageSource.Directory,PackageSource.AUR}
package source
.TP
\fB\-\-without\-dependencies\fR
do not add dependencies
.SH OPTIONS 'ahriman check'
usage: ahriman check [-h] [--no-vcs] [package ...]
check for updates. Same as update \-\-dry\-run \-\-no\-manual
.TP
\fBpackage\fR
filter check by package base
.TP
\fB\-\-no\-vcs\fR
do not check VCS packages
.SH OPTIONS 'ahriman clean'
usage: ahriman clean [-h] [--no-build] [--no-cache] [--no-chroot] [--no-manual] [--no-packages]
clear local caches
.TP
\fB\-\-no\-build\fR
do not clear directory with package sources
.TP
\fB\-\-no\-cache\fR
do not clear directory with package caches
.TP
\fB\-\-no\-chroot\fR
do not clear build chroot
.TP
\fB\-\-no\-manual\fR
do not clear directory with manually added packages
.TP
\fB\-\-no\-packages\fR
do not clear directory with built packages
.SH OPTIONS 'ahriman config'
usage: ahriman config [-h]
dump configuration for specified architecture
.SH OPTIONS 'ahriman init'
usage: ahriman init [-h]
create empty repository tree. Optional command for auto architecture support
.SH OPTIONS 'ahriman key-import'
usage: ahriman key-import [-h] [--key-server KEY_SERVER] key
import PGP key from public sources to repository user
.TP
\fBkey\fR
PGP key to import from public server
.TP
\fB\-\-key\-server\fR \fI\,KEY_SERVER\/\fR
key server for key import
.SH OPTIONS 'ahriman rebuild'
usage: ahriman rebuild [-h] [--depends-on DEPENDS_ON]
rebuild whole repository
.TP
\fB\-\-depends\-on\fR \fI\,DEPENDS_ON\/\fR
only rebuild packages that depend on specified package
.SH OPTIONS 'ahriman remove'
usage: ahriman remove [-h] package [package ...]
remove package
.TP
\fBpackage\fR
package name or base
.SH OPTIONS 'ahriman remove-unknown'
usage: ahriman remove-unknown [-h] [--dry-run]
remove packages which are missing in AUR
.TP
\fB\-\-dry\-run\fR
just perform check for packages without removal
.SH OPTIONS 'ahriman report'
usage: ahriman report [-h] [target ...]
generate report
.TP
\fBtarget\fR
target to generate report
.SH OPTIONS 'ahriman search'
usage: ahriman search [-h] search [search ...]
search for package in AUR using API
.TP
\fBsearch\fR
search terms, can be specified multiple times
.SH OPTIONS 'ahriman setup'
usage: ahriman setup [-h] [--build-command BUILD_COMMAND] [--from-configuration FROM_CONFIGURATION] [--no-multilib] --packager PACKAGER --repository REPOSITORY [--sign-key SIGN_KEY]
[--sign-target {SignSettings.Packages,SignSettings.Repository}] [--web-port WEB_PORT]
create initial service configuration, requires root
.TP
\fB\-\-build\-command\fR \fI\,BUILD_COMMAND\/\fR
build command prefix
.TP
\fB\-\-from\-configuration\fR \fI\,FROM_CONFIGURATION\/\fR
path to default devtools pacman configuration
.TP
\fB\-\-no\-multilib\fR
do not add multilib repository
.TP
\fB\-\-packager\fR \fI\,PACKAGER\/\fR
packager name and email
.TP
\fB\-\-repository\fR \fI\,REPOSITORY\/\fR
repository name
.TP
\fB\-\-sign\-key\fR \fI\,SIGN_KEY\/\fR
sign key id
.TP
\fB\-\-sign\-target\fR {SignSettings.Packages,SignSettings.Repository}
sign options
.TP
\fB\-\-web\-port\fR \fI\,WEB_PORT\/\fR
port of the web service
.SH OPTIONS 'ahriman sign'
usage: ahriman sign [-h] [package ...]
(re\-)sign packages and repository database
.TP
\fBpackage\fR
sign only specified packages
.SH OPTIONS 'ahriman status'
usage: ahriman status [-h] [--ahriman] [--status {BuildStatusEnum.Unknown,BuildStatusEnum.Pending,BuildStatusEnum.Building,BuildStatusEnum.Failed,BuildStatusEnum.Success}] [package ...]
request status of the package
.TP
\fBpackage\fR
filter status by package base
.TP
\fB\-\-ahriman\fR
get service status itself
.TP
\fB\-\-status\fR {BuildStatusEnum.Unknown,BuildStatusEnum.Pending,BuildStatusEnum.Building,BuildStatusEnum.Failed,BuildStatusEnum.Success}
filter packages by status
.SH OPTIONS 'ahriman status-update'
usage: ahriman status-update [-h] [--status {BuildStatusEnum.Unknown,BuildStatusEnum.Pending,BuildStatusEnum.Building,BuildStatusEnum.Failed,BuildStatusEnum.Success}] [--remove] [package ...]
request status of the package
.TP
\fBpackage\fR
set status for specified packages. If no packages supplied, service status will be updated
.TP
\fB\-\-status\fR {BuildStatusEnum.Unknown,BuildStatusEnum.Pending,BuildStatusEnum.Building,BuildStatusEnum.Failed,BuildStatusEnum.Success}
new status
.TP
\fB\-\-remove\fR
remove package status page
.SH OPTIONS 'ahriman sync'
usage: ahriman sync [-h] [target ...]
sync packages to remote server
.TP
\fBtarget\fR
target to sync
.SH OPTIONS 'ahriman update'
usage: ahriman update [-h] [--dry-run] [--no-aur] [--no-manual] [--no-vcs] [package ...]
run updates
.TP
\fBpackage\fR
filter check by package base
.TP
\fB\-\-dry\-run\fR
just perform check for updates, same as check command
.TP
\fB\-\-no\-aur\fR
do not check for AUR updates. Implies \-\-no\-vcs
.TP
\fB\-\-no\-manual\fR
do not include manual updates
.TP
\fB\-\-no\-vcs\fR
do not check VCS packages
.SH OPTIONS 'ahriman user'
usage: ahriman user [-h] [--as-service] [-a {UserAccess.Safe,UserAccess.Read,UserAccess.Write}] [--no-reload] [-p PASSWORD] [-r] [--secure] username
manage users for web services with password and role. In case if password was not entered it will be asked interactively
.TP
\fBusername\fR
username for web service
.TP
\fB\-\-as\-service\fR
add user as service user
.TP
\fB\-a\fR {UserAccess.Safe,UserAccess.Read,UserAccess.Write}, \fB\-\-access\fR {UserAccess.Safe,UserAccess.Read,UserAccess.Write}
user access level
.TP
\fB\-\-no\-reload\fR
do not reload authentication module
.TP
\fB\-p\fR \fI\,PASSWORD\/\fR, \fB\-\-password\fR \fI\,PASSWORD\/\fR
user password
.TP
\fB\-r\fR, \fB\-\-remove\fR
remove user from configuration
.TP
\fB\-\-secure\fR
set file permissions to user\-only
.SH OPTIONS 'ahriman web'
usage: ahriman web [-h]
start web server
.SH AUTHORS
.B ahriman
was written by ahriman team <>.
.SH DISTRIBUTION
The latest version of ahriman may be downloaded from
.UR https://github.com/arcan1s/ahriman
.UE

View File

@ -148,6 +148,7 @@ Some features require optional dependencies to be installed:
Web application requires the following python packages to be installed: Web application requires the following python packages to be installed:
* Core part requires `aiohttp` (application itself), `aiohttp_jinja2` and `Jinja2` (HTML generation from templates). * Core part requires `aiohttp` (application itself), `aiohttp_jinja2` and `Jinja2` (HTML generation from templates).
* In addition, `aiohttp_debugtoolbar` is required for debug panel. Please note that this option does not work together with authorization and basically must not be used in production.
* In addition, authorization feature requires `aiohttp_security`, `aiohttp_session` and `cryptography`. * In addition, authorization feature requires `aiohttp_security`, `aiohttp_session` and `cryptography`.
* In addition to base authorization dependencies, OAuth2 also requires `aioauth-client` library. * In addition to base authorization dependencies, OAuth2 also requires `aioauth-client` library.
@ -173,9 +174,9 @@ Package provides base jinja templates which can be overridden by settings. Vanil
## Requests and scopes ## Requests and scopes
Service provides optional authorization which can be turned on in settings. In order to control user access there are two levels of authorization - read-only (only GET-like requests) and write (anything). Service provides optional authorization which can be turned on in settings. In order to control user access there are two levels of authorization - read-only (only GET-like requests) and write (anything) which are provided by each web view directly.
If this feature is configured any request except for whitelisted will be prohibited without authentication. In addition, configuration flag `auth.allow_read_only` can be used in order to allow seeing main page without authorization (this page is in default white list). If this feature is configured any request will be prohibited without authentication. In addition, configuration flag `auth.safe_build_status` can be used in order to allow seeing main page without authorization.
For authenticated users it uses encrypted session cookies to store tokens; encryption key is generated each time at the start of the application. It also stores expiration time of the session inside. For authenticated users it uses encrypted session cookies to store tokens; encryption key is generated each time at the start of the application. It also stores expiration time of the session inside.

View File

@ -23,14 +23,12 @@ libalpm and AUR related configuration.
Base authorization settings. `OAuth` provider requires `aioauth-client` library to be installed. Base authorization settings. `OAuth` provider requires `aioauth-client` library to be installed.
* `target` - specifies authorization provider, string, optional, default `disabled`. Allowed values are `disabled`, `configuration`, `oauth`. * `target` - specifies authorization provider, string, optional, default `disabled`. Allowed values are `disabled`, `configuration`, `oauth`.
* `allow_read_only` - allow requesting read only pages without authorization, boolean, required.
* `allowed_paths` - URI paths (exact match) which can be accessed without authorization, space separated list of strings, optional.
* `allowed_paths_groups` - URI paths prefixes which can be accessed without authorization, space separated list of strings, optional.
* `client_id` - OAuth2 application client ID, string, required in case if `oauth` is used. * `client_id` - OAuth2 application client ID, string, required in case if `oauth` is used.
* `client_secret` - OAuth2 application client secret key, string, required in case if `oauth` is used. * `client_secret` - OAuth2 application client secret key, string, required in case if `oauth` is used.
* `max_age` - parameter which controls both cookie expiration and token expiration inside the service, integer, optional, default is 7 days. * `max_age` - parameter which controls both cookie expiration and token expiration inside the service, integer, optional, default is 7 days.
* `oauth_provider` - OAuth2 provider class name as is in `aioauth-client` (e.g. `GoogleClient`, `GithubClient` etc), string, required in case if `oauth` is used. * `oauth_provider` - OAuth2 provider class name as is in `aioauth-client` (e.g. `GoogleClient`, `GithubClient` etc), string, required in case if `oauth` is used.
* `oauth_scopes` - scopes list for OAuth2 provider, which will allow retrieving user email (which is used for checking user permissions), e.g. `https://www.googleapis.com/auth/userinfo.email` for `GoogleClient` or `user:email` for `GithubClient`, space separated list of strings, required in case if `oauth` is used. * `oauth_scopes` - scopes list for OAuth2 provider, which will allow retrieving user email (which is used for checking user permissions), e.g. `https://www.googleapis.com/auth/userinfo.email` for `GoogleClient` or `user:email` for `GithubClient`, space separated list of strings, required in case if `oauth` is used.
* `safe_build_status` - allow requesting status page without authorization, boolean, required.
* `salt` - password hash salt, string, required in case if authorization enabled (automatically generated by `create-user` subcommand). * `salt` - password hash salt, string, required in case if authorization enabled (automatically generated by `create-user` subcommand).
## `auth:*` groups ## `auth:*` groups
@ -127,7 +125,11 @@ Group name must refer to architecture, e.g. it should be `s3:x86_64` for x86_64
Web server settings. If any of `host`/`port` is not set, web integration will be disabled. Group name must refer to architecture, e.g. it should be `web:x86_64` for x86_64 architecture. Web server settings. If any of `host`/`port` is not set, web integration will be disabled. Group name must refer to architecture, e.g. it should be `web:x86_64` for x86_64 architecture.
* `address` - optional address in form `proto://host:port` (`port` can be omitted in case of default `proto` ports), will be used instead of `http://{host}:{port}` in case if set, string, optional. This option is required in case if `OAuth` provider is used. * `address` - optional address in form `proto://host:port` (`port` can be omitted in case of default `proto` ports), will be used instead of `http://{host}:{port}` in case if set, string, optional. This option is required in case if `OAuth` provider is used.
* `debug` - enable debug toolbar, boolean, optional, default `no`.
* `debug_check_host` - check hosts to access debug toolbar, boolean, optional, default `no`.
* `debug_allowed_hosts` - allowed hosts to get access to debug toolbar, space separated list of string, optional.
* `host` - host to bind, string, optional. * `host` - host to bind, string, optional.
* `index_url` - full url of the repository index page, string, optional.
* `password` - password to authorize in web service in order to update service status, string, required in case if authorization enabled. * `password` - password to authorize in web service in order to update service status, string, required in case if authorization enabled.
* `port` - port to bind, int, optional. * `port` - port to bind, int, optional.
* `static_path` - path to directory with static files, string, required. * `static_path` - path to directory with static files, string, required.

View File

@ -1,7 +1,7 @@
# Maintainer: Evgeniy Alekseev # Maintainer: Evgeniy Alekseev
pkgname='ahriman' pkgname='ahriman'
pkgver=1.3.0 pkgver=1.4.1
pkgrel=1 pkgrel=1
pkgdesc="ArcH Linux ReposItory MANager" pkgdesc="ArcH Linux ReposItory MANager"
arch=('any') arch=('any')
@ -15,6 +15,7 @@ optdepends=('breezy: -bzr packages support'
'mercurial: -hg packages support' 'mercurial: -hg packages support'
'python-aioauth-client: web server with OAuth2 authorization' 'python-aioauth-client: web server with OAuth2 authorization'
'python-aiohttp: web server' 'python-aiohttp: web server'
'python-aiohttp-debugtoolbar: web server with enabled debug panel'
'python-aiohttp-jinja2: web server' 'python-aiohttp-jinja2: web server'
'python-aiohttp-security: web server with authorization' 'python-aiohttp-security: web server with authorization'
'python-aiohttp-session: web server with authorization' 'python-aiohttp-session: web server with authorization'

View File

@ -10,10 +10,10 @@ root = /
[auth] [auth]
target = disabled target = disabled
allow_read_only = yes
max_age = 604800 max_age = 604800
oauth_provider = GoogleClient oauth_provider = GoogleClient
oauth_scopes = https://www.googleapis.com/auth/userinfo.email oauth_scopes = https://www.googleapis.com/auth/userinfo.email
safe_build_status = yes
[build] [build]
archbuild_flags = archbuild_flags =
@ -51,6 +51,9 @@ command = rsync --archive --compress --partial --delete
chunk_size = 8388608 chunk_size = 8388608
[web] [web]
debug = no
debug_check_host = no
debug_allowed_hosts =
host = 127.0.0.1 host = 127.0.0.1
static_path = /usr/share/ahriman/static static_path = /usr/share/ahriman/static
templates = /usr/share/ahriman templates = /usr/share/ahriman

View File

@ -100,6 +100,12 @@
<li><a class="nav-link" href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li> <li><a class="nav-link" href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
</ul> </ul>
{% if index_url is not none %}
<ul class="nav">
<li><a class="nav-link" href="{{ index_url }}" title="repo index">repo index</a></li>
</ul>
{% endif %}
{% if auth.enabled %} {% if auth.enabled %}
{% if auth.username is none %} {% if auth.username is none %}
{{ auth.control|safe }} {{ auth.control|safe }}

View File

@ -16,6 +16,7 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal" onclick="requestPackages()">Request</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPackages()">Add</button> <button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPackages()">Add</button>
</div> </div>
</div> </div>

View File

@ -34,7 +34,8 @@
success: function (resp) { success: function (resp) {
const $options = resp.map(function (pkg) { const $options = resp.map(function (pkg) {
const $option = document.createElement("option"); const $option = document.createElement("option");
$option.value = `${pkg.package} (${pkg.description})`; $option.value = pkg.package;
$option.innerText = `${pkg.package} (${pkg.description})`;
return $option; return $option;
}); });
$knownPackages.empty().append($options); $knownPackages.empty().append($options);
@ -79,6 +80,11 @@
doPackageAction("/service-api/v1/add", $packages); doPackageAction("/service-api/v1/add", $packages);
} }
function requestPackages() {
const $packages = [$package.val()]
doPackageAction("/service-api/v1/request", $packages);
}
function removePackages() { doPackageAction("/service-api/v1/remove", getSelection()); } function removePackages() { doPackageAction("/service-api/v1/remove", getSelection()); }
function updatePackages() { doPackageAction("/service-api/v1/add", getSelection()); } function updatePackages() { doPackageAction("/service-api/v1/add", getSelection()); }

View File

@ -81,6 +81,9 @@ setup(
"package/share/ahriman/utils/bootstrap-scripts.jinja2", "package/share/ahriman/utils/bootstrap-scripts.jinja2",
"package/share/ahriman/utils/style.jinja2", "package/share/ahriman/utils/style.jinja2",
]), ]),
("share/man/man1", [
"docs/ahriman.1",
])
], ],
extras_require={ extras_require={
@ -107,6 +110,7 @@ setup(
"aiohttp", "aiohttp",
"aiohttp_jinja2", "aiohttp_jinja2",
"aioauth-client", "aioauth-client",
"aiohttp_debugtoolbar",
"aiohttp_session", "aiohttp_session",
"aiohttp_security", "aiohttp_security",
"cryptography", "cryptography",

View File

@ -26,6 +26,7 @@ from pathlib import Path
from ahriman import version from ahriman import version
from ahriman.application import handlers from ahriman.application import handlers
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package_source import PackageSource
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -91,8 +92,10 @@ def _set_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="package base/name or archive path", nargs="+") parser.add_argument("package", help="package base/name or archive path", nargs="+")
parser.add_argument("--now", help="run update function after", action="store_true") parser.add_argument("--now", help="run update function after", action="store_true")
parser.add_argument("--source", help="package source", choices=PackageSource, type=PackageSource,
default=PackageSource.Auto)
parser.add_argument("--without-dependencies", help="do not add dependencies", action="store_true") parser.add_argument("--without-dependencies", help="do not add dependencies", action="store_true")
parser.set_defaults(handler=handlers.Add, architecture=[]) parser.set_defaults(handler=handlers.Add)
return parser return parser
@ -107,7 +110,7 @@ def _set_check_parser(root: SubParserAction) -> argparse.ArgumentParser:
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="filter check by package base", nargs="*") parser.add_argument("package", help="filter check by package base", nargs="*")
parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true") parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true")
parser.set_defaults(handler=handlers.Update, architecture=[], no_aur=False, no_manual=True, dry_run=True) parser.set_defaults(handler=handlers.Update, no_aur=False, no_manual=True, dry_run=True)
return parser return parser
@ -124,7 +127,7 @@ def _set_clean_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("--no-chroot", help="do not clear build chroot", action="store_true") parser.add_argument("--no-chroot", help="do not clear build chroot", action="store_true")
parser.add_argument("--no-manual", help="do not clear directory with manually added packages", action="store_true") parser.add_argument("--no-manual", help="do not clear directory with manually added packages", action="store_true")
parser.add_argument("--no-packages", help="do not clear directory with built packages", action="store_true") parser.add_argument("--no-packages", help="do not clear directory with built packages", action="store_true")
parser.set_defaults(handler=handlers.Clean, architecture=[], no_log=True, unsafe=True) parser.set_defaults(handler=handlers.Clean, no_log=True, unsafe=True)
return parser return parser
@ -178,7 +181,7 @@ def _set_rebuild_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("rebuild", help="rebuild repository", description="rebuild whole repository", parser = root.add_parser("rebuild", help="rebuild repository", description="rebuild whole repository",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--depends-on", help="only rebuild packages that depend on specified package", action="append") parser.add_argument("--depends-on", help="only rebuild packages that depend on specified package", action="append")
parser.set_defaults(handler=handlers.Rebuild, architecture=[]) parser.set_defaults(handler=handlers.Rebuild)
return parser return parser
@ -191,7 +194,7 @@ def _set_remove_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("remove", help="remove package", description="remove package", parser = root.add_parser("remove", help="remove package", description="remove package",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="package name or base", nargs="+") parser.add_argument("package", help="package name or base", nargs="+")
parser.set_defaults(handler=handlers.Remove, architecture=[]) parser.set_defaults(handler=handlers.Remove)
return parser return parser
@ -205,7 +208,7 @@ def _set_remove_unknown_parser(root: SubParserAction) -> argparse.ArgumentParser
description="remove packages which are missing in AUR", description="remove packages which are missing in AUR",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--dry-run", help="just perform check for packages without removal", action="store_true") parser.add_argument("--dry-run", help="just perform check for packages without removal", action="store_true")
parser.set_defaults(handler=handlers.RemoveUnknown, architecture=[]) parser.set_defaults(handler=handlers.RemoveUnknown)
return parser return parser
@ -218,7 +221,7 @@ def _set_report_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("report", help="generate report", description="generate report", parser = root.add_parser("report", help="generate report", description="generate report",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("target", help="target to generate report", nargs="*") parser.add_argument("target", help="target to generate report", nargs="*")
parser.set_defaults(handler=handlers.Report, architecture=[]) parser.set_defaults(handler=handlers.Report)
return parser return parser
@ -266,7 +269,7 @@ def _set_sign_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("sign", help="sign packages", description="(re-)sign packages and repository database", parser = root.add_parser("sign", help="sign packages", description="(re-)sign packages and repository database",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="sign only specified packages", nargs="*") parser.add_argument("package", help="sign only specified packages", nargs="*")
parser.set_defaults(handler=handlers.Sign, architecture=[]) parser.set_defaults(handler=handlers.Sign)
return parser return parser
@ -313,7 +316,7 @@ def _set_sync_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("sync", help="sync repository", description="sync packages to remote server", parser = root.add_parser("sync", help="sync repository", description="sync packages to remote server",
formatter_class=argparse.ArgumentDefaultsHelpFormatter) formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("target", help="target to sync", nargs="*") parser.add_argument("target", help="target to sync", nargs="*")
parser.set_defaults(handler=handlers.Sync, architecture=[]) parser.set_defaults(handler=handlers.Sync)
return parser return parser
@ -330,7 +333,7 @@ def _set_update_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("--no-aur", help="do not check for AUR updates. Implies --no-vcs", action="store_true") parser.add_argument("--no-aur", help="do not check for AUR updates. Implies --no-vcs", action="store_true")
parser.add_argument("--no-manual", help="do not include manual updates", action="store_true") parser.add_argument("--no-manual", help="do not include manual updates", action="store_true")
parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true") parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true")
parser.set_defaults(handler=handlers.Update, architecture=[]) parser.set_defaults(handler=handlers.Update)
return parser return parser
@ -357,6 +360,7 @@ def _set_user_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("--no-reload", help="do not reload authentication module", 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") parser.add_argument("-p", "--password", help="user password")
parser.add_argument("-r", "--remove", help="remove user from configuration", action="store_true") parser.add_argument("-r", "--remove", help="remove user from configuration", action="store_true")
parser.add_argument("--secure", help="set file permissions to user-only", action="store_true")
parser.set_defaults(handler=handlers.User, architecture=[""], lock=None, no_log=True, no_report=True, unsafe=True) parser.set_defaults(handler=handlers.User, architecture=[""], lock=None, no_log=True, no_report=True, unsafe=True)
return parser return parser

View File

@ -29,6 +29,7 @@ from ahriman.core.repository.repository import Repository
from ahriman.core.tree import Tree from ahriman.core.tree import Tree
from ahriman.core.util import package_like from ahriman.core.util import package_like
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource
class Application: class Application:
@ -96,10 +97,11 @@ class Application:
return updates return updates
def add(self, names: Iterable[str], without_dependencies: bool) -> None: def add(self, names: Iterable[str], source: PackageSource, without_dependencies: bool) -> None:
""" """
add packages for the next build add packages for the next build
:param names: list of package bases to add :param names: list of package bases to add
:param source: package source to add
:param without_dependencies: if set, dependency check will be disabled :param without_dependencies: if set, dependency check will be disabled
""" """
known_packages = self._known_packages() known_packages = self._known_packages()
@ -122,14 +124,14 @@ class Application:
if without_dependencies: if without_dependencies:
return return
dependencies = Package.dependencies(path) dependencies = Package.dependencies(path)
self.add(dependencies.difference(known_packages), without_dependencies) self.add(dependencies.difference(known_packages), PackageSource.AUR, without_dependencies)
def process_single(src: str) -> None: def process_single(src: str) -> None:
maybe_path = Path(src) resolved_source = source.resolve(src)
if maybe_path.is_dir(): if resolved_source == PackageSource.Directory:
add_directory(maybe_path) add_directory(Path(src))
elif maybe_path.is_file(): elif resolved_source == PackageSource.Archive:
add_archive(maybe_path) add_archive(Path(src))
else: else:
path = add_manual(src) path = add_manual(src)
process_dependencies(path) process_dependencies(path)

View File

@ -42,7 +42,7 @@ class Add(Handler):
:param no_report: force disable reporting :param no_report: force disable reporting
""" """
application = Application(architecture, configuration, no_report) application = Application(architecture, configuration, no_report)
application.add(args.package, args.without_dependencies) application.add(args.package, args.source, args.without_dependencies)
if not args.now: if not args.now:
return return

View File

@ -30,6 +30,8 @@ class Dump(Handler):
dump configuration handler dump configuration handler
""" """
ALLOW_AUTO_ARCHITECTURE_RUN = False
_print = print _print = print
@classmethod @classmethod

View File

@ -34,9 +34,11 @@ from ahriman.models.repository_paths import RepositoryPaths
class Handler: class Handler:
""" """
base handler class for command callbacks base handler class for command callbacks
:cvar ALLOW_AUTO_ARCHITECTURE_RUN: allow to define architecture from existing repositories
:cvar ALLOW_MULTI_ARCHITECTURE_RUN: allow to run with multiple architectures :cvar ALLOW_MULTI_ARCHITECTURE_RUN: allow to run with multiple architectures
""" """
ALLOW_AUTO_ARCHITECTURE_RUN = True
ALLOW_MULTI_ARCHITECTURE_RUN = True ALLOW_MULTI_ARCHITECTURE_RUN = True
@classmethod @classmethod
@ -85,9 +87,11 @@ class Handler:
:param args: command line args :param args: command line args
:return: list of architectures for which tree is created :return: list of architectures for which tree is created
""" """
if args.architecture is None: if not cls.ALLOW_AUTO_ARCHITECTURE_RUN and args.architecture is None:
# for some parsers (e.g. config) we need to run with specific architecture
# for those cases architecture must be set explicitly
raise MissingArchitecture(args.command) raise MissingArchitecture(args.command)
if args.architecture: if args.architecture: # architecture is specified explicitly
return set(args.architecture) return set(args.architecture)
config = Configuration() config = Configuration()
@ -96,7 +100,7 @@ class Handler:
root = config.getpath("repository", "root") # pylint: disable=assignment-from-no-return root = config.getpath("repository", "root") # pylint: disable=assignment-from-no-return
architectures = RepositoryPaths.known_architectures(root) architectures = RepositoryPaths.known_architectures(root)
if not architectures: if not architectures: # well we did not find anything
raise MissingArchitecture(args.command) raise MissingArchitecture(args.command)
return architectures return architectures

View File

@ -31,6 +31,8 @@ class Init(Handler):
repository init handler repository init handler
""" """
ALLOW_AUTO_ARCHITECTURE_RUN = False
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None: configuration: Configuration, no_report: bool) -> None:

View File

@ -31,6 +31,8 @@ class KeyImport(Handler):
key import packages handler key import packages handler
""" """
ALLOW_AUTO_ARCHITECTURE_RUN = False # it should be called only as "no-architecture"
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None: configuration: Configuration, no_report: bool) -> None:

View File

@ -31,6 +31,8 @@ class Search(Handler):
packages search handler packages search handler
""" """
ALLOW_AUTO_ARCHITECTURE_RUN = False # it should be called only as "no-architecture"
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None: configuration: Configuration, no_report: bool) -> None:

View File

@ -37,6 +37,8 @@ class Setup(Handler):
:cvar SUDOERS_PATH: path to sudoers.d include configuration :cvar SUDOERS_PATH: path to sudoers.d include configuration
""" """
ALLOW_AUTO_ARCHITECTURE_RUN = False
ARCHBUILD_COMMAND_PATH = Path("/usr/bin/archbuild") ARCHBUILD_COMMAND_PATH = Path("/usr/bin/archbuild")
BIN_DIR_PATH = Path("/usr/local/bin") BIN_DIR_PATH = Path("/usr/local/bin")
MIRRORLIST_PATH = Path("/etc/pacman.d/mirrorlist") MIRRORLIST_PATH = Path("/etc/pacman.d/mirrorlist")

View File

@ -33,6 +33,8 @@ class Status(Handler):
package status handler package status handler
""" """
ALLOW_AUTO_ARCHITECTURE_RUN = False
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None: configuration: Configuration, no_report: bool) -> None:

View File

@ -32,6 +32,8 @@ class StatusUpdate(Handler):
status update handler status update handler
""" """
ALLOW_AUTO_ARCHITECTURE_RUN = False
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None: configuration: Configuration, no_report: bool) -> None:

View File

@ -35,6 +35,8 @@ class User(Handler):
user management handler user management handler
""" """
ALLOW_AUTO_ARCHITECTURE_RUN = False # it should be called only as "no-architecture"
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None: configuration: Configuration, no_report: bool) -> None:
@ -52,7 +54,7 @@ class User(Handler):
User.clear_user(auth_configuration, user) User.clear_user(auth_configuration, user)
if not args.remove: if not args.remove:
User.create_configuration(auth_configuration, user, salt, args.as_service) User.create_configuration(auth_configuration, user, salt, args.as_service)
User.write_configuration(auth_configuration) User.write_configuration(auth_configuration, args.secure)
if not args.no_reload: if not args.no_reload:
client = Application(architecture, configuration, no_report=False).repository.reporter client = Application(architecture, configuration, no_report=False).repository.reporter
@ -127,13 +129,15 @@ class User(Handler):
return MUser.generate_password(salt_length) return MUser.generate_password(salt_length)
@staticmethod @staticmethod
def write_configuration(configuration: Configuration) -> None: def write_configuration(configuration: Configuration, secure: bool) -> None:
""" """
write configuration file write configuration file
:param configuration: configuration instance :param configuration: configuration instance
:param secure: if true then set file permissions to 0o600
""" """
if configuration.path is None: if configuration.path is None:
return # should never happen actually return # should never happen actually
with configuration.path.open("w") as ahriman_configuration: with configuration.path.open("w") as ahriman_configuration:
configuration.write(ahriman_configuration) configuration.write(ahriman_configuration)
configuration.path.chmod(0o600) if secure:
configuration.path.chmod(0o600)

View File

@ -31,6 +31,7 @@ class Web(Handler):
web server handler web server handler
""" """
ALLOW_AUTO_ARCHITECTURE_RUN = False
ALLOW_MULTI_ARCHITECTURE_RUN = False # required to be able to spawn external processes ALLOW_MULTI_ARCHITECTURE_RUN = False # required to be able to spawn external processes
@classmethod @classmethod

View File

@ -33,16 +33,11 @@ from ahriman.models.user_access import UserAccess
class Auth: class Auth:
""" """
helper to deal with user authorization helper to deal with user authorization
:ivar allowed_paths: URI paths which can be accessed without authorization
:ivar allowed_paths_groups: URI paths prefixes which can be accessed without authorization
:ivar enabled: indicates if authorization is enabled :ivar enabled: indicates if authorization is enabled
:cvar ALLOWED_PATHS: URI paths which can be accessed without authorization, predefined :ivar max_age: session age in seconds. It will be used for both client side and server side checks
:cvar ALLOWED_PATHS_GROUPS: URI paths prefixes which can be accessed without authorization, predefined :ivar safe_build_status: allow read only access to the index page
""" """
ALLOWED_PATHS = {"/", "/index.html"}
ALLOWED_PATHS_GROUPS = {"/static", "/user-api"}
def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None: def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None:
""" """
default constructor default constructor
@ -51,11 +46,8 @@ class Auth:
""" """
self.logger = logging.getLogger("http") self.logger = logging.getLogger("http")
self.allow_read_only = configuration.getboolean("auth", "allow_read_only") self.safe_build_status = configuration.getboolean("auth", "safe_build_status")
self.allowed_paths = set(configuration.getlist("auth", "allowed_paths", fallback=[]))
self.allowed_paths.update(self.ALLOWED_PATHS)
self.allowed_paths_groups = set(configuration.getlist("auth", "allowed_paths_groups", fallback=[]))
self.allowed_paths_groups.update(self.ALLOWED_PATHS_GROUPS)
self.enabled = provider.is_enabled self.enabled = provider.is_enabled
self.max_age = configuration.getint("auth", "max_age", fallback=7 * 24 * 3600) self.max_age = configuration.getint("auth", "max_age", fallback=7 * 24 * 3600)
@ -115,19 +107,6 @@ class Auth:
del username, password del username, password
return True return True
async def is_safe_request(self, uri: Optional[str], required: UserAccess) -> bool:
"""
check if requested path are allowed without authorization
:param uri: request uri
:param required: required access level
:return: True in case if this URI can be requested without authorization and False otherwise
"""
if required == UserAccess.Read and self.allow_read_only:
return True # in case if read right requested and allowed in options
if not uri:
return False # request without context is not allowed
return uri in self.allowed_paths or any(uri.startswith(path) for path in self.allowed_paths_groups)
async def known_username(self, username: Optional[str]) -> bool: # pylint: disable=no-self-use async def known_username(self, username: Optional[str]) -> bool: # pylint: disable=no-self-use
""" """
check if user is known check if user is known

View File

@ -28,6 +28,7 @@ from threading import Lock, Thread
from typing import Callable, Dict, Iterable, Tuple from typing import Callable, Dict, Iterable, Tuple
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.package_source import PackageSource
class Spawn(Thread): class Spawn(Thread):
@ -79,7 +80,9 @@ class Spawn(Thread):
:param packages: packages list to add :param packages: packages list to add
:param now: build packages now :param now: build packages now
""" """
kwargs = {"now": ""} if now else {} kwargs = {"source": PackageSource.AUR.value} # avoid abusing by building non-aur packages
if now:
kwargs["now"] = ""
self.spawn_process("add", *packages, **kwargs) self.spawn_process("add", *packages, **kwargs)
def packages_remove(self, packages: Iterable[str]) -> None: def packages_remove(self, packages: Iterable[str]) -> None:

View File

@ -0,0 +1,55 @@
#
# 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 <http://www.gnu.org/licenses/>.
#
from __future__ import annotations
from enum import Enum
from pathlib import Path
from ahriman.core.util import package_like
class PackageSource(Enum):
"""
package source for addition enumeration
:cvar Auto: automatically determine type of the source
:cvar Archive: source is a package archive
:cvar Directory: source is a directory which contains packages
:cvar AUR: source is an AUR package for which it should search
"""
Auto = "auto"
Archive = "archive"
Directory = "directory"
AUR = "aur"
def resolve(self, source: str) -> PackageSource:
"""
resolve auto into the correct type
:param source: source of the package
:return: non-auto type of the package source
"""
if self != PackageSource.Auto:
return self
maybe_path = Path(source)
if maybe_path.is_dir():
return PackageSource.Directory
if maybe_path.is_file() and package_like(maybe_path):
return PackageSource.Archive
return PackageSource.AUR

View File

@ -23,9 +23,11 @@ from enum import Enum
class UserAccess(Enum): class UserAccess(Enum):
""" """
web user access enumeration web user access enumeration
:cvar Read: user can read status page :cvar Safe: user can access the page without authorization, should not be user for user configuration
:cvar Read: user can read the page
:cvar Write: user can modify task and package list :cvar Write: user can modify task and package list
""" """
Safe = "safe"
Read = "read" Read = "read"
Write = "write" Write = "write"

View File

@ -17,4 +17,4 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
__version__ = "1.3.0" __version__ = "1.4.1"

View File

@ -19,10 +19,12 @@
# #
import aiohttp_security # type: ignore import aiohttp_security # type: ignore
import base64 import base64
import types
from aiohttp import web from aiohttp import web
from aiohttp.web import middleware, Request from aiohttp.web import middleware, Request
from aiohttp.web_response import StreamResponse from aiohttp.web_response import StreamResponse
from aiohttp.web_urldispatcher import StaticResource
from aiohttp_session import setup as setup_session # type: ignore from aiohttp_session import setup as setup_session # type: ignore
from aiohttp_session.cookie_storage import EncryptedCookieStorage # type: ignore from aiohttp_session.cookie_storage import EncryptedCookieStorage # type: ignore
from cryptography import fernet from cryptography import fernet
@ -72,20 +74,22 @@ class AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy): # type
return await self.validator.verify_access(user.username, permission, context) return await self.validator.verify_access(user.username, permission, context)
def auth_handler(validator: Auth) -> MiddlewareType: def auth_handler() -> MiddlewareType:
""" """
authorization and authentication middleware authorization and authentication middleware
:param validator: authorization module instance
:return: built middleware :return: built middleware
""" """
@middleware @middleware
async def handle(request: Request, handler: HandlerType) -> StreamResponse: async def handle(request: Request, handler: HandlerType) -> StreamResponse:
if request.method in ("GET", "HEAD", "OPTIONS"): permission_method = getattr(handler, "get_permission", None)
permission = UserAccess.Read if permission_method is not None:
permission = await permission_method(request)
elif isinstance(handler, types.MethodType): # additional wrapper for static resources
handler_instance = getattr(handler, "__self__", None)
permission = UserAccess.Safe if isinstance(handler_instance, StaticResource) else UserAccess.Write
else: else:
permission = UserAccess.Write permission = UserAccess.Write
if permission != UserAccess.Safe:
if not await validator.is_safe_request(request.path, permission):
await aiohttp_security.check_permission(request, permission, request.path) await aiohttp_security.check_permission(request, permission, request.path)
return await handler(request) return await handler(request)
@ -109,6 +113,6 @@ def setup_auth(application: web.Application, validator: Auth) -> web.Application
identity_policy = aiohttp_security.SessionIdentityPolicy() identity_policy = aiohttp_security.SessionIdentityPolicy()
aiohttp_security.setup(application, identity_policy, authorization_policy) aiohttp_security.setup(application, identity_policy, authorization_policy)
application.middlewares.append(auth_handler(validator)) application.middlewares.append(auth_handler())
return application return application

View File

@ -24,6 +24,7 @@ from ahriman.web.views.index import IndexView
from ahriman.web.views.service.add import AddView from ahriman.web.views.service.add import AddView
from ahriman.web.views.service.reload_auth import ReloadAuthView from ahriman.web.views.service.reload_auth import ReloadAuthView
from ahriman.web.views.service.remove import RemoveView from ahriman.web.views.service.remove import RemoveView
from ahriman.web.views.service.request import RequestView
from ahriman.web.views.service.search import SearchView from ahriman.web.views.service.search import SearchView
from ahriman.web.views.status.ahriman import AhrimanView from ahriman.web.views.status.ahriman import AhrimanView
from ahriman.web.views.status.package import PackageView from ahriman.web.views.status.package import PackageView
@ -48,6 +49,8 @@ def setup_routes(application: Application, static_path: Path) -> None:
POST /service-api/v1/remove remove existing package from repository POST /service-api/v1/remove remove existing package from repository
POST /service-api/v1/request request to add new packages to repository
GET /service-api/v1/search search for substring in AUR GET /service-api/v1/search search for substring in AUR
POST /service-api/v1/update update packages in repository, actually it is just alias for add POST /service-api/v1/update update packages in repository, actually it is just alias for add
@ -82,6 +85,8 @@ def setup_routes(application: Application, static_path: Path) -> None:
application.router.add_post("/service-api/v1/remove", RemoveView) application.router.add_post("/service-api/v1/remove", RemoveView)
application.router.add_post("/service-api/v1/request", RequestView)
application.router.add_get("/service-api/v1/search", SearchView, allow_head=False) application.router.add_get("/service-api/v1/search", SearchView, allow_head=False)
application.router.add_post("/service-api/v1/update", AddView) application.router.add_post("/service-api/v1/update", AddView)

View File

@ -17,13 +17,16 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import View from __future__ import annotations
from typing import Any, Dict, List, Optional
from aiohttp.web import Request, View
from typing import Any, Dict, List, Optional, Type
from ahriman.core.auth.auth import Auth from ahriman.core.auth.auth import Auth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
from ahriman.models.user_access import UserAccess
class BaseView(View): class BaseView(View):
@ -63,6 +66,16 @@ class BaseView(View):
validator: Auth = self.request.app["validator"] validator: Auth = self.request.app["validator"]
return validator return validator
@classmethod
async def get_permission(cls: Type[BaseView], request: Request) -> UserAccess:
"""
retrieve user permission from the request
:param request: request object
:return: extracted permission
"""
permission: UserAccess = getattr(cls, f"{request.method.upper()}_PERMISSION", UserAccess.Write)
return permission
async def extract_data(self, list_keys: Optional[List[str]] = None) -> Dict[str, Any]: async def extract_data(self, list_keys: Optional[List[str]] = None) -> Dict[str, Any]:
""" """
extract json data from either json or form data extract json data from either json or form data

View File

@ -24,12 +24,15 @@ from typing import Any, Dict
from ahriman import version from ahriman import version
from ahriman.core.auth.helpers import authorized_userid from ahriman.core.auth.helpers import authorized_userid
from ahriman.core.util import pretty_datetime from ahriman.core.util import pretty_datetime
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
class IndexView(BaseView): class IndexView(BaseView):
""" """
root view root view
:cvar GET_PERMISSION: get permissions of self
:cvar HEAD_PERMISSION: head permissions of self
It uses jinja2 templates for report generation, the following variables are allowed: It uses jinja2 templates for report generation, the following variables are allowed:
@ -39,6 +42,7 @@ class IndexView(BaseView):
* control - HTML to insert for login control, HTML string, required * control - HTML to insert for login control, HTML string, required
* enabled - whether authorization is enabled by configuration or not, boolean, required * enabled - whether authorization is enabled by configuration or not, boolean, required
* username - authenticated username if any, string, null means not authenticated * username - authenticated username if any, string, null means not authenticated
index_url - url to the repository index, string, optional
packages - sorted list of packages properties, required packages - sorted list of packages properties, required
* base, string * base, string
* depends, sorted list of strings * depends, sorted list of strings
@ -58,6 +62,8 @@ class IndexView(BaseView):
version - ahriman version, string, required version - ahriman version, string, required
""" """
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Safe
@aiohttp_jinja2.template("build-status.jinja2") @aiohttp_jinja2.template("build-status.jinja2")
async def get(self) -> Dict[str, Any]: async def get(self) -> Dict[str, Any]:
""" """
@ -87,8 +93,9 @@ class IndexView(BaseView):
# auth block # auth block
auth_username = await authorized_userid(self.request) auth_username = await authorized_userid(self.request)
authenticated = not self.validator.enabled or self.validator.safe_build_status or auth_username is not None
auth = { auth = {
"authenticated": not self.validator.enabled or self.validator.allow_read_only or auth_username is not None, "authenticated": authenticated,
"control": self.validator.auth_control, "control": self.validator.auth_control,
"enabled": self.validator.enabled, "enabled": self.validator.enabled,
"username": auth_username, "username": auth_username,
@ -97,6 +104,7 @@ class IndexView(BaseView):
return { return {
"architecture": self.service.architecture, "architecture": self.service.architecture,
"auth": auth, "auth": auth,
"index_url": self.configuration.get("web", "index_url", fallback=None),
"packages": packages, "packages": packages,
"repository": self.service.repository.name, "repository": self.service.repository.name,
"service": service, "service": service,

View File

@ -19,22 +19,25 @@
# #
from aiohttp.web import HTTPFound, Response, json_response from aiohttp.web import HTTPFound, Response, json_response
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
class AddView(BaseView): class AddView(BaseView):
""" """
add package web view add package web view
:cvar POST_PERMISSION: post permissions of self
""" """
POST_PERMISSION = UserAccess.Write
async def post(self) -> Response: async def post(self) -> Response:
""" """
add new package add new package
JSON body must be supplied, the following model is used: JSON body must be supplied, the following model is used:
{ {
"packages": "ahriman", # either list of packages or package name as in AUR "packages": "ahriman" # either list of packages or package name as in AUR
"build_now": true # optional flag which runs build
} }
:return: redirect to main page on success :return: redirect to main page on success
@ -42,11 +45,10 @@ class AddView(BaseView):
data = await self.extract_data(["packages"]) data = await self.extract_data(["packages"])
try: try:
now = data.get("build_now", True)
packages = data["packages"] packages = data["packages"]
except Exception as e: except Exception as e:
return json_response(data=str(e), status=400) return json_response(data=str(e), status=400)
self.spawner.packages_add(packages, now) self.spawner.packages_add(packages, now=True)
return HTTPFound("/") raise HTTPFound("/")

View File

@ -17,18 +17,21 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from aiohttp.web import Response from aiohttp.web import HTTPNoContent, Response
from aiohttp.web_exceptions import HTTPNoContent
from ahriman.core.auth.auth import Auth from ahriman.core.auth.auth import Auth
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
class ReloadAuthView(BaseView): class ReloadAuthView(BaseView):
""" """
reload authentication module web view reload authentication module web view
:cvar POST_PERMISSION: post permissions of self
""" """
POST_PERMISSION = UserAccess.Write
async def post(self) -> Response: async def post(self) -> Response:
""" """
reload authentication module. No parameters supported here reload authentication module. No parameters supported here
@ -45,4 +48,4 @@ class ReloadAuthView(BaseView):
self.request.app.logger.warning("could not update authentication module validator", exc_info=True) self.request.app.logger.warning("could not update authentication module validator", exc_info=True)
raise raise
return HTTPNoContent() raise HTTPNoContent()

View File

@ -19,14 +19,18 @@
# #
from aiohttp.web import HTTPFound, Response, json_response from aiohttp.web import HTTPFound, Response, json_response
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
class RemoveView(BaseView): class RemoveView(BaseView):
""" """
remove package web view remove package web view
:cvar POST_PERMISSION: post permissions of self
""" """
POST_PERMISSION = UserAccess.Write
async def post(self) -> Response: async def post(self) -> Response:
""" """
remove existing packages remove existing packages
@ -47,4 +51,4 @@ class RemoveView(BaseView):
self.spawner.packages_remove(packages) self.spawner.packages_remove(packages)
return HTTPFound("/") raise HTTPFound("/")

View File

@ -0,0 +1,54 @@
#
# 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 <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPFound, Response, json_response
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
class RequestView(BaseView):
"""
request package web view. It is actually the same as AddView, but without now
:cvar POST_PERMISSION: post permissions of self
"""
POST_PERMISSION = UserAccess.Read
async def post(self) -> Response:
"""
request to add new package
JSON body must be supplied, the following model is used:
{
"packages": "ahriman" # either list of packages or package name as in AUR
}
:return: redirect to main page on success
"""
data = await self.extract_data(["packages"])
try:
packages = data["packages"]
except Exception as e:
return json_response(data=str(e), status=400)
self.spawner.packages_add(packages, now=False)
raise HTTPFound("/")

View File

@ -22,14 +22,19 @@ import aur # type: ignore
from aiohttp.web import Response, json_response from aiohttp.web import Response, json_response
from typing import Callable, Iterator from typing import Callable, Iterator
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
class SearchView(BaseView): class SearchView(BaseView):
""" """
AUR search web view AUR search web view
:cvar GET_PERMISSION: get permissions of self
:cvar HEAD_PERMISSION: head permissions of self
""" """
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
async def get(self) -> Response: async def get(self) -> Response:
""" """
search packages in AUR search packages in AUR

View File

@ -20,14 +20,21 @@
from aiohttp.web import HTTPNoContent, Response, json_response from aiohttp.web import HTTPNoContent, Response, json_response
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
class AhrimanView(BaseView): class AhrimanView(BaseView):
""" """
service status web view service status web view
:cvar GET_PERMISSION: get permissions of self
:cvar HEAD_PERMISSION: head permissions of self
:cvar POST_PERMISSION: post permissions of self
""" """
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
POST_PERMISSION = UserAccess.Write
async def get(self) -> Response: async def get(self) -> Response:
""" """
get current service status get current service status
@ -55,4 +62,4 @@ class AhrimanView(BaseView):
self.service.update_self(status) self.service.update_self(status)
return HTTPNoContent() raise HTTPNoContent()

View File

@ -22,14 +22,22 @@ from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response
from ahriman.core.exceptions import UnknownPackage from ahriman.core.exceptions import UnknownPackage
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
class PackageView(BaseView): class PackageView(BaseView):
""" """
package base specific web view package base specific web view
:cvar DELETE_PERMISSION: delete permissions of self
:cvar GET_PERMISSION: get permissions of self
:cvar HEAD_PERMISSION: head permissions of self
:cvar POST_PERMISSION: post permissions of self
""" """
DELETE_PERMISSION = POST_PERMISSION = UserAccess.Write
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
async def get(self) -> Response: async def get(self) -> Response:
""" """
get current package base status get current package base status
@ -58,7 +66,7 @@ class PackageView(BaseView):
base = self.request.match_info["package"] base = self.request.match_info["package"]
self.service.remove(base) self.service.remove(base)
return HTTPNoContent() raise HTTPNoContent()
async def post(self) -> Response: async def post(self) -> Response:
""" """
@ -87,4 +95,4 @@ class PackageView(BaseView):
except UnknownPackage: except UnknownPackage:
return json_response(data=f"Package {base} is unknown, but no package body set", status=400) return json_response(data=f"Package {base} is unknown, but no package body set", status=400)
return HTTPNoContent() raise HTTPNoContent()

View File

@ -19,14 +19,21 @@
# #
from aiohttp.web import HTTPNoContent, Response, json_response from aiohttp.web import HTTPNoContent, Response, json_response
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
class PackagesView(BaseView): class PackagesView(BaseView):
""" """
global watcher view global watcher view
:cvar GET_PERMISSION: get permissions of self
:cvar HEAD_PERMISSION: head permissions of self
:cvar POST_PERMISSION: post permissions of self
""" """
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
POST_PERMISSION = UserAccess.Write
async def get(self) -> Response: async def get(self) -> Response:
""" """
get current packages status get current packages status
@ -47,4 +54,4 @@ class PackagesView(BaseView):
""" """
self.service.load() self.service.load()
return HTTPNoContent() raise HTTPNoContent()

View File

@ -22,14 +22,19 @@ from aiohttp.web import Response, json_response
from ahriman import version from ahriman import version
from ahriman.models.counters import Counters from ahriman.models.counters import Counters
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
class StatusView(BaseView): class StatusView(BaseView):
""" """
web service status web view web service status web view
:cvar GET_PERMISSION: get permissions of self
:cvar HEAD_PERMISSION: head permissions of self
""" """
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
async def get(self) -> Response: async def get(self) -> Response:
""" """
get current service status get current service status

View File

@ -20,6 +20,7 @@
from aiohttp.web import HTTPFound, HTTPMethodNotAllowed, HTTPUnauthorized, Response from aiohttp.web import HTTPFound, HTTPMethodNotAllowed, HTTPUnauthorized, Response
from ahriman.core.auth.helpers import remember from ahriman.core.auth.helpers import remember
from ahriman.models.user_access import UserAccess
from ahriman.models.user_identity import UserIdentity from ahriman.models.user_identity import UserIdentity
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@ -27,8 +28,12 @@ from ahriman.web.views.base import BaseView
class LoginView(BaseView): class LoginView(BaseView):
""" """
login endpoint view login endpoint view
:cvar GET_PERMISSION: get permissions of self
:cvar POST_PERMISSION: post permissions of self
""" """
GET_PERMISSION = POST_PERMISSION = UserAccess.Safe
async def get(self) -> Response: async def get(self) -> Response:
""" """
OAuth2 response handler OAuth2 response handler
@ -46,7 +51,7 @@ class LoginView(BaseView):
raise HTTPMethodNotAllowed(self.request.method, ["POST"]) raise HTTPMethodNotAllowed(self.request.method, ["POST"])
if not code: if not code:
return HTTPFound(oauth_provider.get_oauth_url()) raise HTTPFound(oauth_provider.get_oauth_url())
response = HTTPFound("/") response = HTTPFound("/")
username = await oauth_provider.get_oauth_username(code) username = await oauth_provider.get_oauth_username(code)

View File

@ -20,14 +20,18 @@
from aiohttp.web import HTTPFound, Response from aiohttp.web import HTTPFound, Response
from ahriman.core.auth.helpers import check_authorized, forget from ahriman.core.auth.helpers import check_authorized, forget
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
class LogoutView(BaseView): class LogoutView(BaseView):
""" """
logout endpoint view logout endpoint view
:cvar POST_PERMISSION: post permissions of self
""" """
POST_PERMISSION = UserAccess.Safe
async def post(self) -> Response: async def post(self) -> Response:
""" """
logout user from the service. No parameters supported here logout user from the service. No parameters supported here

View File

@ -99,6 +99,14 @@ def setup_service(architecture: str, configuration: Configuration, spawner: Spaw
application.logger.info("setup process spawner") application.logger.info("setup process spawner")
application["spawn"] = spawner application["spawn"] = spawner
application.logger.info("setup debug panel")
debug_enabled = configuration.getboolean("web", "debug", fallback=False)
if debug_enabled:
import aiohttp_debugtoolbar # type: ignore
aiohttp_debugtoolbar.setup(application,
hosts=configuration.getlist("web", "debug_allowed_hosts", fallback=[]),
check_host=configuration.getboolean("web", "debug_check_host", fallback=False))
application.logger.info("setup authorization") application.logger.info("setup authorization")
validator = application["validator"] = Auth.load(configuration) validator = application["validator"] = Auth.load(configuration)
if validator.enabled: if validator.enabled:

View File

@ -27,7 +27,7 @@ def args() -> argparse.Namespace:
fixture for command line arguments fixture for command line arguments
:return: command line arguments test instance :return: command line arguments test instance
""" """
return argparse.Namespace(lock=None, force=False, unsafe=False, no_report=True) return argparse.Namespace(architecture=None, lock=None, force=False, unsafe=False, no_report=True)
@pytest.fixture @pytest.fixture

View File

@ -71,7 +71,6 @@ def test_extract_architectures(args: argparse.Namespace, configuration: Configur
""" """
must generate list of available architectures must generate list of available architectures
""" """
args.architecture = []
args.configuration = configuration.path args.configuration = configuration.path
known_architectures_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.known_architectures") known_architectures_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.known_architectures")
@ -84,7 +83,6 @@ def test_extract_architectures_empty(args: argparse.Namespace, configuration: Co
""" """
must raise exception if no available architectures found must raise exception if no available architectures found
""" """
args.architecture = []
args.command = "config" args.command = "config"
args.configuration = configuration.path args.configuration = configuration.path
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.known_architectures", return_value=set()) mocker.patch("ahriman.models.repository_paths.RepositoryPaths.known_architectures", return_value=set())
@ -93,12 +91,12 @@ def test_extract_architectures_empty(args: argparse.Namespace, configuration: Co
Handler.extract_architectures(args) Handler.extract_architectures(args)
def test_extract_architectures_exception(args: argparse.Namespace) -> None: def test_extract_architectures_exception(args: argparse.Namespace, mocker: MockerFixture) -> None:
""" """
must raise exception on missing architectures must raise exception on missing architectures
""" """
args.command = "config" args.command = "config"
args.architecture = None mocker.patch.object(Handler, "ALLOW_AUTO_ARCHITECTURE_RUN", False)
with pytest.raises(MissingArchitecture): with pytest.raises(MissingArchitecture):
Handler.extract_architectures(args) Handler.extract_architectures(args)

View File

@ -4,6 +4,7 @@ from pytest_mock import MockerFixture
from ahriman.application.handlers import Add from ahriman.application.handlers import Add
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.package_source import PackageSource
def _default_args(args: argparse.Namespace) -> argparse.Namespace: def _default_args(args: argparse.Namespace) -> argparse.Namespace:
@ -14,6 +15,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
""" """
args.package = [] args.package = []
args.now = False args.now = False
args.source = PackageSource.Auto
args.without_dependencies = False args.without_dependencies = False
return args return args

View File

@ -18,3 +18,10 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
Dump.run(args, "x86_64", configuration, True) Dump.run(args, "x86_64", configuration, True)
application_mock.assert_called_once() application_mock.assert_called_once()
print_mock.assert_called() print_mock.assert_called()
def test_disallow_auto_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Dump.ALLOW_AUTO_ARCHITECTURE_RUN

View File

@ -16,3 +16,10 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
Init.run(args, "x86_64", configuration, True) Init.run(args, "x86_64", configuration, True)
create_tree_mock.assert_called_once() create_tree_mock.assert_called_once()
init_mock.assert_called_once() init_mock.assert_called_once()
def test_disallow_auto_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Init.ALLOW_AUTO_ARCHITECTURE_RUN

View File

@ -27,3 +27,10 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
KeyImport.run(args, "x86_64", configuration, True) KeyImport.run(args, "x86_64", configuration, True)
application_mock.assert_called_once() application_mock.assert_called_once()
def test_disallow_auto_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not KeyImport.ALLOW_AUTO_ARCHITECTURE_RUN

View File

@ -53,3 +53,10 @@ def test_log_fn(args: argparse.Namespace, configuration: Configuration, aur_pack
Search.run(args, "x86_64", configuration, True) Search.run(args, "x86_64", configuration, True)
print_mock.assert_called() # we don't really care about call details tbh print_mock.assert_called() # we don't really care about call details tbh
def test_disallow_auto_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Search.ALLOW_AUTO_ARCHITECTURE_RUN

View File

@ -149,3 +149,10 @@ def test_create_executable(args: argparse.Namespace, mocker: MockerFixture) -> N
Setup.create_executable(args.build_command, "x86_64") Setup.create_executable(args.build_command, "x86_64")
symlink_text_mock.assert_called_once() symlink_text_mock.assert_called_once()
unlink_text_mock.assert_called_once() unlink_text_mock.assert_called_once()
def test_disallow_auto_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Setup.ALLOW_AUTO_ARCHITECTURE_RUN

View File

@ -81,3 +81,10 @@ def test_imply_with_report(args: argparse.Namespace, configuration: Configuratio
Status.run(args, "x86_64", configuration, True) Status.run(args, "x86_64", configuration, True)
load_mock.assert_called_once() load_mock.assert_called_once()
def test_disallow_auto_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Status.ALLOW_AUTO_ARCHITECTURE_RUN

View File

@ -86,3 +86,10 @@ def test_imply_with_report(args: argparse.Namespace, configuration: Configuratio
StatusUpdate.run(args, "x86_64", configuration, True) StatusUpdate.run(args, "x86_64", configuration, True)
load_mock.assert_called_once() load_mock.assert_called_once()
def test_disallow_auto_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not StatusUpdate.ALLOW_AUTO_ARCHITECTURE_RUN

View File

@ -22,6 +22,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
args.access = UserAccess.Read args.access = UserAccess.Read
args.as_service = False args.as_service = False
args.no_reload = False args.no_reload = False
args.secure = False
args.remove = False args.remove = False
return args return args
@ -227,11 +228,23 @@ def test_write_configuration(configuration: Configuration, mocker: MockerFixture
write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") write_mock = mocker.patch("ahriman.core.configuration.Configuration.write")
chmod_mock = mocker.patch("pathlib.Path.chmod") chmod_mock = mocker.patch("pathlib.Path.chmod")
User.write_configuration(configuration) User.write_configuration(configuration, secure=True)
write_mock.assert_called_once() write_mock.assert_called_once()
chmod_mock.assert_called_once() chmod_mock.assert_called_once()
def test_write_configuration_insecure(configuration: Configuration, mocker: MockerFixture) -> None:
"""
must write configuration without setting file permissions
"""
mocker.patch("pathlib.Path.open")
mocker.patch("ahriman.core.configuration.Configuration.write")
chmod_mock = mocker.patch("pathlib.Path.chmod")
User.write_configuration(configuration, secure=False)
chmod_mock.assert_not_called()
def test_write_configuration_not_loaded(configuration: Configuration, mocker: MockerFixture) -> None: def test_write_configuration_not_loaded(configuration: Configuration, mocker: MockerFixture) -> None:
""" """
must do nothing in case if configuration is not loaded must do nothing in case if configuration is not loaded
@ -241,6 +254,13 @@ def test_write_configuration_not_loaded(configuration: Configuration, mocker: Mo
write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") write_mock = mocker.patch("ahriman.core.configuration.Configuration.write")
chmod_mock = mocker.patch("pathlib.Path.chmod") chmod_mock = mocker.patch("pathlib.Path.chmod")
User.write_configuration(configuration) User.write_configuration(configuration, secure=True)
write_mock.assert_not_called() write_mock.assert_not_called()
chmod_mock.assert_not_called() chmod_mock.assert_not_called()
def test_disallow_auto_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not User.ALLOW_AUTO_ARCHITECTURE_RUN

View File

@ -31,6 +31,13 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
run_mock.assert_called_once() run_mock.assert_called_once()
def test_disallow_auto_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Web.ALLOW_AUTO_ARCHITECTURE_RUN
def test_disallow_multi_architecture_run() -> None: def test_disallow_multi_architecture_run() -> None:
""" """
must not allow multi architecture run must not allow multi architecture run

View File

@ -41,43 +41,64 @@ def test_multiple_architectures(parser: argparse.ArgumentParser) -> None:
must accept multiple architectures must accept multiple architectures
""" """
args = parser.parse_args(["-a", "x86_64", "-a", "i686", "config"]) args = parser.parse_args(["-a", "x86_64", "-a", "i686", "config"])
assert len(args.architecture) == 2 assert args.architecture == ["x86_64", "i686"]
def test_subparsers_add(parser: argparse.ArgumentParser) -> None: def test_subparsers_add_architecture(parser: argparse.ArgumentParser) -> None:
""" """
add command must imply empty architectures list add command must correctly parse architecture list
""" """
args = parser.parse_args(["add", "ahriman"]) args = parser.parse_args(["add", "ahriman"])
assert args.architecture == [] assert args.architecture is None
args = parser.parse_args(["-a", "x86_64", "add", "ahriman"])
assert args.architecture == ["x86_64"]
def test_subparsers_check(parser: argparse.ArgumentParser) -> None: def test_subparsers_check(parser: argparse.ArgumentParser) -> None:
""" """
check command must imply empty architecture list, no-aur, no-manual and dry-run check command must imply no-aur, no-manual and dry-run
""" """
args = parser.parse_args(["check"]) args = parser.parse_args(["check"])
assert args.architecture == []
assert not args.no_aur assert not args.no_aur
assert args.no_manual assert args.no_manual
assert args.dry_run assert args.dry_run
def test_subparsers_check_architecture(parser: argparse.ArgumentParser) -> None:
"""
check command must correctly parse architecture list
"""
args = parser.parse_args(["check"])
assert args.architecture is None
args = parser.parse_args(["-a", "x86_64", "check"])
assert args.architecture == ["x86_64"]
def test_subparsers_clean(parser: argparse.ArgumentParser) -> None: def test_subparsers_clean(parser: argparse.ArgumentParser) -> None:
""" """
clean command must imply empty architectures list, unsafe and no-log clean command must imply unsafe and no-log
""" """
args = parser.parse_args(["clean"]) args = parser.parse_args(["clean"])
assert args.architecture == []
assert args.no_log assert args.no_log
assert args.unsafe assert args.unsafe
def test_subparsers_clean_architecture(parser: argparse.ArgumentParser) -> None:
"""
clean command must correctly parse architecture list
"""
args = parser.parse_args(["clean"])
assert args.architecture is None
args = parser.parse_args(["-a", "x86_64", "clean"])
assert args.architecture == ["x86_64"]
def test_subparsers_config(parser: argparse.ArgumentParser) -> None: def test_subparsers_config(parser: argparse.ArgumentParser) -> None:
""" """
config command must imply lock, no-log, no-report and unsafe config command must imply lock, no-log, no-report and unsafe
""" """
args = parser.parse_args(["config"]) args = parser.parse_args(["-a", "x86_64", "config"])
assert args.architecture == ["x86_64"]
assert args.lock is None assert args.lock is None
assert args.no_log assert args.no_log
assert args.no_report assert args.no_report
@ -88,7 +109,8 @@ def test_subparsers_init(parser: argparse.ArgumentParser) -> None:
""" """
init command must imply no_report init command must imply no_report
""" """
args = parser.parse_args(["init"]) args = parser.parse_args(["-a", "x86_64", "init"])
assert args.architecture == ["x86_64"]
assert args.no_report assert args.no_report
@ -102,36 +124,42 @@ def test_subparsers_key_import(parser: argparse.ArgumentParser) -> None:
assert args.no_report assert args.no_report
def test_subparsers_rebuild(parser: argparse.ArgumentParser) -> None: def test_subparsers_key_import_architecture(parser: argparse.ArgumentParser) -> None:
""" """
rebuild command must imply empty architectures list check command must correctly parse architecture list
"""
args = parser.parse_args(["-a", "x86_64", "key-import", "key"])
assert args.architecture == [""]
def test_subparsers_rebuild_architecture(parser: argparse.ArgumentParser) -> None:
"""
rebuild command must correctly parse architecture list
""" """
args = parser.parse_args(["rebuild"]) args = parser.parse_args(["rebuild"])
assert args.architecture == [] assert args.architecture is None
args = parser.parse_args(["-a", "x86_64", "rebuild"])
assert args.architecture == ["x86_64"]
def test_subparsers_remove(parser: argparse.ArgumentParser) -> None: def test_subparsers_remove_architecture(parser: argparse.ArgumentParser) -> None:
""" """
remove command must imply empty architectures list remove command must correctly parse architecture list
""" """
args = parser.parse_args(["remove", "ahriman"]) args = parser.parse_args(["remove", "ahriman"])
assert args.architecture == [] assert args.architecture is None
args = parser.parse_args(["-a", "x86_64", "remove", "ahriman"])
assert args.architecture == ["x86_64"]
def test_subparsers_remove_unknown(parser: argparse.ArgumentParser) -> None: def test_subparsers_report_architecture(parser: argparse.ArgumentParser) -> None:
""" """
remove-unknown command must imply empty architectures list report command must correctly parse architecture list
"""
args = parser.parse_args(["remove-unknown"])
assert args.architecture == []
def test_subparsers_report(parser: argparse.ArgumentParser) -> None:
"""
report command must imply empty architectures list
""" """
args = parser.parse_args(["report"]) args = parser.parse_args(["report"])
assert args.architecture == [] assert args.architecture is None
args = parser.parse_args(["-a", "x86_64", "report"])
assert args.architecture == ["x86_64"]
def test_subparsers_search(parser: argparse.ArgumentParser) -> None: def test_subparsers_search(parser: argparse.ArgumentParser) -> None:
@ -146,12 +174,21 @@ def test_subparsers_search(parser: argparse.ArgumentParser) -> None:
assert args.unsafe assert args.unsafe
def test_subparsers_search_architecture(parser: argparse.ArgumentParser) -> None:
"""
search command must correctly parse architecture list
"""
args = parser.parse_args(["-a", "x86_64", "search", "ahriman"])
assert args.architecture == [""]
def test_subparsers_setup(parser: argparse.ArgumentParser) -> None: def test_subparsers_setup(parser: argparse.ArgumentParser) -> None:
""" """
setup command must imply lock, no-log, no-report and unsafe setup command must imply lock, no-log, no-report and unsafe
""" """
args = parser.parse_args(["-a", "x86_64", "setup", "--packager", "John Doe <john@doe.com>", args = parser.parse_args(["-a", "x86_64", "setup", "--packager", "John Doe <john@doe.com>",
"--repository", "aur-clone"]) "--repository", "aur-clone"])
assert args.architecture == ["x86_64"]
assert args.lock is None assert args.lock is None
assert args.no_log assert args.no_log
assert args.no_report assert args.no_report
@ -180,12 +217,14 @@ def test_subparsers_setup_option_sign_target(parser: argparse.ArgumentParser) ->
assert all(isinstance(target, SignSettings) for target in args.sign_target) assert all(isinstance(target, SignSettings) for target in args.sign_target)
def test_subparsers_sign(parser: argparse.ArgumentParser) -> None: def test_subparsers_sign_architecture(parser: argparse.ArgumentParser) -> None:
""" """
sign command must imply empty architectures list sign command must correctly parse architecture list
""" """
args = parser.parse_args(["sign"]) args = parser.parse_args(["sign"])
assert args.architecture == [] assert args.architecture is None
args = parser.parse_args(["-a", "x86_64", "sign"])
assert args.architecture == ["x86_64"]
def test_subparsers_status(parser: argparse.ArgumentParser) -> None: def test_subparsers_status(parser: argparse.ArgumentParser) -> None:
@ -193,6 +232,7 @@ def test_subparsers_status(parser: argparse.ArgumentParser) -> None:
status command must imply lock, no-log, no-report and unsafe status command must imply lock, no-log, no-report and unsafe
""" """
args = parser.parse_args(["-a", "x86_64", "status"]) args = parser.parse_args(["-a", "x86_64", "status"])
assert args.architecture == ["x86_64"]
assert args.lock is None assert args.lock is None
assert args.no_log assert args.no_log
assert args.no_report assert args.no_report
@ -204,6 +244,7 @@ def test_subparsers_status_update(parser: argparse.ArgumentParser) -> None:
status-update command must imply lock, no-log, no-report and unsafe status-update command must imply lock, no-log, no-report and unsafe
""" """
args = parser.parse_args(["-a", "x86_64", "status-update"]) args = parser.parse_args(["-a", "x86_64", "status-update"])
assert args.architecture == ["x86_64"]
assert args.lock is None assert args.lock is None
assert args.no_log assert args.no_log
assert args.no_report assert args.no_report
@ -220,20 +261,24 @@ def test_subparsers_status_update_option_status(parser: argparse.ArgumentParser)
assert isinstance(args.status, BuildStatusEnum) assert isinstance(args.status, BuildStatusEnum)
def test_subparsers_sync(parser: argparse.ArgumentParser) -> None: def test_subparsers_sync_architecture(parser: argparse.ArgumentParser) -> None:
""" """
sync command must imply empty architectures list sync command must correctly parse architecture list
""" """
args = parser.parse_args(["sync"]) args = parser.parse_args(["sync"])
assert args.architecture == [] assert args.architecture is None
args = parser.parse_args(["-a", "x86_64", "sync"])
assert args.architecture == ["x86_64"]
def test_subparsers_update(parser: argparse.ArgumentParser) -> None: def test_subparsers_update_architecture(parser: argparse.ArgumentParser) -> None:
""" """
update command must imply empty architectures list update command must correctly parse architecture list
""" """
args = parser.parse_args(["update"]) args = parser.parse_args(["update"])
assert args.architecture == [] assert args.architecture is None
args = parser.parse_args(["-a", "x86_64", "update"])
assert args.architecture == ["x86_64"]
def test_subparsers_user(parser: argparse.ArgumentParser) -> None: def test_subparsers_user(parser: argparse.ArgumentParser) -> None:
@ -248,6 +293,14 @@ def test_subparsers_user(parser: argparse.ArgumentParser) -> None:
assert args.unsafe assert args.unsafe
def test_subparsers_user_architecture(parser: argparse.ArgumentParser) -> None:
"""
user command must correctly parse architecture list
"""
args = parser.parse_args(["-a", "x86_64", "user", "username"])
assert args.architecture == [""]
def test_subparsers_user_option_role(parser: argparse.ArgumentParser) -> None: def test_subparsers_user_option_role(parser: argparse.ArgumentParser) -> None:
""" """
user command must convert role option to useraccess instance user command must convert role option to useraccess instance
@ -263,6 +316,7 @@ def test_subparsers_web(parser: argparse.ArgumentParser) -> None:
web command must imply lock, no_report and parser web command must imply lock, no_report and parser
""" """
args = parser.parse_args(["-a", "x86_64", "web"]) args = parser.parse_args(["-a", "x86_64", "web"])
assert args.architecture == ["x86_64"]
assert args.lock is None assert args.lock is None
assert args.no_report assert args.no_report
assert args.parser is not None and args.parser() assert args.parser is not None and args.parser()

View File

@ -6,6 +6,7 @@ from unittest import mock
from ahriman.application.application import Application from ahriman.application.application import Application
from ahriman.core.tree import Leaf, Tree from ahriman.core.tree import Leaf, Tree
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource
def test_finalize(application: Application, mocker: MockerFixture) -> None: def test_finalize(application: Application, mocker: MockerFixture) -> None:
@ -108,12 +109,11 @@ def test_add_directory(application: Application, package_ahriman: Package, mocke
must add packages from directory must add packages from directory
""" """
mocker.patch("ahriman.application.application.Application._known_packages", return_value=set()) mocker.patch("ahriman.application.application.Application._known_packages", return_value=set())
mocker.patch("pathlib.Path.is_dir", return_value=True)
iterdir_mock = mocker.patch("pathlib.Path.iterdir", iterdir_mock = mocker.patch("pathlib.Path.iterdir",
return_value=[package.filepath for package in package_ahriman.packages.values()]) return_value=[package.filepath for package in package_ahriman.packages.values()])
move_mock = mocker.patch("shutil.move") move_mock = mocker.patch("shutil.move")
application.add([package_ahriman.base], False) application.add([package_ahriman.base], PackageSource.Directory, False)
iterdir_mock.assert_called_once() iterdir_mock.assert_called_once()
move_mock.assert_called_once() move_mock.assert_called_once()
@ -126,7 +126,7 @@ def test_add_manual(application: Application, package_ahriman: Package, mocker:
mocker.patch("ahriman.models.package.Package.load", return_value=package_ahriman) mocker.patch("ahriman.models.package.Package.load", return_value=package_ahriman)
fetch_mock = mocker.patch("ahriman.core.build_tools.task.Task.fetch") fetch_mock = mocker.patch("ahriman.core.build_tools.task.Task.fetch")
application.add([package_ahriman.base], True) application.add([package_ahriman.base], PackageSource.AUR, True)
fetch_mock.assert_called_once() fetch_mock.assert_called_once()
@ -140,7 +140,7 @@ def test_add_manual_with_dependencies(application: Application, package_ahriman:
mocker.patch("ahriman.core.build_tools.task.Task.fetch") mocker.patch("ahriman.core.build_tools.task.Task.fetch")
dependencies_mock = mocker.patch("ahriman.models.package.Package.dependencies") dependencies_mock = mocker.patch("ahriman.models.package.Package.dependencies")
application.add([package_ahriman.base], False) application.add([package_ahriman.base], PackageSource.AUR, False)
dependencies_mock.assert_called_once() dependencies_mock.assert_called_once()
@ -149,10 +149,9 @@ def test_add_package(application: Application, package_ahriman: Package, mocker:
must add package from archive must add package from archive
""" """
mocker.patch("ahriman.application.application.Application._known_packages", return_value=set()) mocker.patch("ahriman.application.application.Application._known_packages", return_value=set())
mocker.patch("pathlib.Path.is_file", return_value=True)
move_mock = mocker.patch("shutil.move") move_mock = mocker.patch("shutil.move")
application.add([package_ahriman.base], False) application.add([package_ahriman.base], PackageSource.Archive, False)
move_mock.assert_called_once() move_mock.assert_called_once()

View File

@ -109,40 +109,6 @@ async def test_check_credentials(auth: Auth, user: User) -> None:
assert await auth.check_credentials(None, None) assert await auth.check_credentials(None, None)
async def test_is_safe_request(auth: Auth) -> None:
"""
must validate safe request
"""
# login and logout are always safe
assert await auth.is_safe_request("/user-api/v1/login", UserAccess.Write)
assert await auth.is_safe_request("/user-api/v1/logout", UserAccess.Write)
auth.allowed_paths.add("/safe")
auth.allowed_paths_groups.add("/unsafe/safe")
assert await auth.is_safe_request("/safe", UserAccess.Write)
assert not await auth.is_safe_request("/unsafe", UserAccess.Write)
assert await auth.is_safe_request("/unsafe/safe", UserAccess.Write)
assert await auth.is_safe_request("/unsafe/safe/suffix", UserAccess.Write)
async def test_is_safe_request_empty(auth: Auth) -> None:
"""
must not allow requests without path
"""
assert not await auth.is_safe_request(None, UserAccess.Read)
assert not await auth.is_safe_request("", UserAccess.Read)
async def test_is_safe_request_read_only(auth: Auth) -> None:
"""
must allow read-only requests if it is set in settings
"""
assert await auth.is_safe_request("/", UserAccess.Read)
auth.allow_read_only = True
assert await auth.is_safe_request("/unsafe", UserAccess.Read)
async def test_known_username(auth: Auth, user: User) -> None: async def test_known_username(auth: Auth, user: User) -> None:
""" """
must allow any username must allow any username

View File

@ -42,7 +42,7 @@ def test_packages_add(spawner: Spawn, mocker: MockerFixture) -> None:
""" """
spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process") spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process")
spawner.packages_add(["ahriman", "linux"], now=False) spawner.packages_add(["ahriman", "linux"], now=False)
spawn_mock.assert_called_with("add", "ahriman", "linux") spawn_mock.assert_called_with("add", "ahriman", "linux", source="aur")
def test_packages_add_with_build(spawner: Spawn, mocker: MockerFixture) -> None: def test_packages_add_with_build(spawner: Spawn, mocker: MockerFixture) -> None:
@ -51,7 +51,7 @@ def test_packages_add_with_build(spawner: Spawn, mocker: MockerFixture) -> None:
""" """
spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process") spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process")
spawner.packages_add(["ahriman", "linux"], now=True) spawner.packages_add(["ahriman", "linux"], now=True)
spawn_mock.assert_called_with("add", "ahriman", "linux", now="") spawn_mock.assert_called_with("add", "ahriman", "linux", source="aur", now="")
def test_packages_remove(spawner: Spawn, mocker: MockerFixture) -> None: def test_packages_remove(spawner: Spawn, mocker: MockerFixture) -> None:

View File

@ -0,0 +1,47 @@
from pytest_mock import MockerFixture
from ahriman.models.package_source import PackageSource
def test_resolve_non_auto() -> None:
"""
must resolve non auto type to itself
"""
for source in filter(lambda src: src != PackageSource.Auto, PackageSource):
assert source.resolve("") == source
def test_resolve_archive(mocker: MockerFixture) -> None:
"""
must resolve auto type into the archive
"""
mocker.patch("pathlib.Path.is_dir", return_value=False)
mocker.patch("pathlib.Path.is_file", return_value=True)
assert PackageSource.Auto.resolve("linux-5.14.2.arch1-2-x86_64.pkg.tar.zst") == PackageSource.Archive
def test_resolve_directory(mocker: MockerFixture) -> None:
"""
must resolve auto type into the directory
"""
mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("pathlib.Path.is_file", return_value=False)
assert PackageSource.Auto.resolve("path") == PackageSource.Directory
def test_resolve_aur(mocker: MockerFixture) -> None:
"""
must resolve auto type into the AUR package
"""
mocker.patch("pathlib.Path.is_dir", return_value=False)
mocker.patch("pathlib.Path.is_file", return_value=False)
assert PackageSource.Auto.resolve("package") == PackageSource.AUR
def test_resolve_aur_not_package_like(mocker: MockerFixture) -> None:
"""
must resolve auto type into the AUR package if it is file, but does not look like a package archive
"""
mocker.patch("pathlib.Path.is_dir", return_value=False)
mocker.patch("pathlib.Path.is_file", return_value=True)
assert PackageSource.Auto.resolve("package") == PackageSource.AUR

View File

@ -64,3 +64,20 @@ def application_with_auth(configuration: Configuration, user: User, spawner: Spa
application["validator"]._users[generated.username] = generated application["validator"]._users[generated.username] = generated
return application return application
@pytest.fixture
def application_with_debug(configuration: Configuration, user: User, spawner: Spawn,
mocker: MockerFixture) -> web.Application:
"""
application fixture with debug enabled
:param configuration: configuration fixture
:param user: user descriptor fixture
:param spawner: spawner fixture
:param mocker: mocker object
:return: application test instance
"""
configuration.set_option("web", "debug", "yes")
mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", False)
mocker.patch("pathlib.Path.mkdir")
return setup_service("x86_64", configuration, spawner)

View File

@ -43,60 +43,74 @@ async def test_permits(authorization_policy: AuthorizationPolicy, user: User) ->
assert not await authorization_policy.permits(user.username, user.access, "/endpoint") assert not await authorization_policy.permits(user.username, user.access, "/endpoint")
async def test_auth_handler_api(auth: Auth, mocker: MockerFixture) -> None: async def test_auth_handler_api(mocker: MockerFixture) -> None:
""" """
must ask for status permission for api calls must ask for status permission for api calls
""" """
aiohttp_request = pytest.helpers.request("", "/status-api", "GET") aiohttp_request = pytest.helpers.request("", "/status-api", "GET")
request_handler = AsyncMock() request_handler = AsyncMock()
mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) request_handler.get_permission.return_value = UserAccess.Read
check_permission_mock = mocker.patch("aiohttp_security.check_permission") check_permission_mock = mocker.patch("aiohttp_security.check_permission")
handler = auth_handler(auth) handler = auth_handler()
await handler(aiohttp_request, request_handler) await handler(aiohttp_request, request_handler)
check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Read, aiohttp_request.path) check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Read, aiohttp_request.path)
async def test_auth_handler_api_post(auth: Auth, mocker: MockerFixture) -> None: async def test_auth_handler_api_no_method(mocker: MockerFixture) -> None:
"""
must ask for write permission if handler does not have get_permission method
"""
aiohttp_request = pytest.helpers.request("", "/status-api", "GET")
request_handler = AsyncMock()
request_handler.get_permission = None
check_permission_mock = mocker.patch("aiohttp_security.check_permission")
handler = auth_handler()
await handler(aiohttp_request, request_handler)
check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Write, aiohttp_request.path)
async def test_auth_handler_api_post(mocker: MockerFixture) -> None:
""" """
must ask for status permission for api calls with POST must ask for status permission for api calls with POST
""" """
aiohttp_request = pytest.helpers.request("", "/status-api", "POST") aiohttp_request = pytest.helpers.request("", "/status-api", "POST")
request_handler = AsyncMock() request_handler = AsyncMock()
mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) request_handler.get_permission.return_value = UserAccess.Write
check_permission_mock = mocker.patch("aiohttp_security.check_permission") check_permission_mock = mocker.patch("aiohttp_security.check_permission")
handler = auth_handler(auth) handler = auth_handler()
await handler(aiohttp_request, request_handler) await handler(aiohttp_request, request_handler)
check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Write, aiohttp_request.path) check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Write, aiohttp_request.path)
async def test_auth_handler_read(auth: Auth, mocker: MockerFixture) -> None: async def test_auth_handler_read(mocker: MockerFixture) -> None:
""" """
must ask for read permission for api calls with GET must ask for read permission for api calls with GET
""" """
for method in ("GET", "HEAD", "OPTIONS"): for method in ("GET", "HEAD", "OPTIONS"):
aiohttp_request = pytest.helpers.request("", "", method) aiohttp_request = pytest.helpers.request("", "", method)
request_handler = AsyncMock() request_handler = AsyncMock()
mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) request_handler.get_permission.return_value = UserAccess.Read
check_permission_mock = mocker.patch("aiohttp_security.check_permission") check_permission_mock = mocker.patch("aiohttp_security.check_permission")
handler = auth_handler(auth) handler = auth_handler()
await handler(aiohttp_request, request_handler) await handler(aiohttp_request, request_handler)
check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Read, aiohttp_request.path) check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Read, aiohttp_request.path)
async def test_auth_handler_write(auth: Auth, mocker: MockerFixture) -> None: async def test_auth_handler_write(mocker: MockerFixture) -> None:
""" """
must ask for read permission for api calls with POST must ask for read permission for api calls with POST
""" """
for method in ("CONNECT", "DELETE", "PATCH", "POST", "PUT", "TRACE"): for method in ("CONNECT", "DELETE", "PATCH", "POST", "PUT", "TRACE"):
aiohttp_request = pytest.helpers.request("", "", method) aiohttp_request = pytest.helpers.request("", "", method)
request_handler = AsyncMock() request_handler = AsyncMock()
mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) request_handler.get_permission.return_value = UserAccess.Write
check_permission_mock = mocker.patch("aiohttp_security.check_permission") check_permission_mock = mocker.patch("aiohttp_security.check_permission")
handler = auth_handler(auth) handler = auth_handler()
await handler(aiohttp_request, request_handler) await handler(aiohttp_request, request_handler)
check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Write, aiohttp_request.path) check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Write, aiohttp_request.path)

View File

@ -45,7 +45,7 @@ def test_run(application: web.Application, mocker: MockerFixture) -> None:
def test_run_with_auth(application_with_auth: web.Application, mocker: MockerFixture) -> None: def test_run_with_auth(application_with_auth: web.Application, mocker: MockerFixture) -> None:
""" """
must run application must run application with enabled authorization
""" """
port = 8080 port = 8080
application_with_auth["configuration"].set_option("web", "port", str(port)) application_with_auth["configuration"].set_option("web", "port", str(port))
@ -54,3 +54,16 @@ def test_run_with_auth(application_with_auth: web.Application, mocker: MockerFix
run_server(application_with_auth) run_server(application_with_auth)
run_application_mock.assert_called_with(application_with_auth, host="127.0.0.1", port=port, run_application_mock.assert_called_with(application_with_auth, host="127.0.0.1", port=port,
handle_signals=False, access_log=pytest.helpers.anyvar(int)) handle_signals=False, access_log=pytest.helpers.anyvar(int))
def test_run_with_debug(application_with_debug: web.Application, mocker: MockerFixture) -> None:
"""
must run application with enabled debug panel
"""
port = 8080
application_with_debug["configuration"].set_option("web", "port", str(port))
run_application_mock = mocker.patch("aiohttp.web.run_app")
run_server(application_with_debug)
run_application_mock.assert_called_with(application_with_debug, host="127.0.0.1", port=port,
handle_signals=False, access_log=pytest.helpers.anyvar(int))

View File

@ -1,6 +1,20 @@
import pytest
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.models.user_access import UserAccess
from ahriman.web.views.service.add import AddView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await AddView.get_permission(request) == UserAccess.Write
async def test_post(client: TestClient, mocker: MockerFixture) -> None: async def test_post(client: TestClient, mocker: MockerFixture) -> None:
""" """
@ -10,18 +24,7 @@ async def test_post(client: TestClient, mocker: MockerFixture) -> None:
response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"]}) response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"]})
assert response.ok assert response.ok
add_mock.assert_called_with(["ahriman"], True) add_mock.assert_called_with(["ahriman"], now=True)
async def test_post_now(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post and run build
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"], "build_now": False})
assert response.ok
add_mock.assert_called_with(["ahriman"], False)
async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None: async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None:
@ -43,4 +46,4 @@ async def test_post_update(client: TestClient, mocker: MockerFixture) -> None:
response = await client.post("/service-api/v1/update", json={"packages": ["ahriman"]}) response = await client.post("/service-api/v1/update", json={"packages": ["ahriman"]})
assert response.ok assert response.ok
add_mock.assert_called_with(["ahriman"], True) add_mock.assert_called_with(["ahriman"], now=True)

View File

@ -1,6 +1,20 @@
import pytest
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture 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: async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None:
""" """

View File

@ -1,6 +1,20 @@
import pytest
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.models.user_access import UserAccess
from ahriman.web.views.service.remove import RemoveView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await RemoveView.get_permission(request) == UserAccess.Write
async def test_post(client: TestClient, mocker: MockerFixture) -> None: async def test_post(client: TestClient, mocker: MockerFixture) -> None:
""" """

View File

@ -0,0 +1,38 @@
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.request import RequestView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await RequestView.get_permission(request) == UserAccess.Read
async def test_post(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post request correctly
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/service-api/v1/request", json={"packages": ["ahriman"]})
assert response.ok
add_mock.assert_called_with(["ahriman"], now=False)
async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None:
"""
must raise exception on missing packages payload
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/service-api/v1/request")
assert response.status == 400
add_mock.assert_not_called()

View File

@ -1,8 +1,21 @@
import aur import aur
import pytest
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.models.user_access import UserAccess
from ahriman.web.views.service.search import SearchView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET", "HEAD"):
request = pytest.helpers.request("", "", method)
assert await SearchView.get_permission(request) == UserAccess.Read
async def test_get(client: TestClient, aur_package_ahriman: aur.Package, mocker: MockerFixture) -> None: async def test_get(client: TestClient, aur_package_ahriman: aur.Package, mocker: MockerFixture) -> None:
""" """

View File

@ -1,7 +1,23 @@
import pytest
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.user_access import UserAccess
from ahriman.web.views.status.ahriman import AhrimanView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET", "HEAD"):
request = pytest.helpers.request("", "", method)
assert await AhrimanView.get_permission(request) == UserAccess.Read
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await AhrimanView.get_permission(request) == UserAccess.Write
async def test_get(client: TestClient) -> None: async def test_get(client: TestClient) -> None:

View File

@ -1,7 +1,23 @@
import pytest
from pytest_aiohttp import TestClient from pytest_aiohttp import TestClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess
from ahriman.web.views.status.package import PackageView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET", "HEAD"):
request = pytest.helpers.request("", "", method)
assert await PackageView.get_permission(request) == UserAccess.Read
for method in ("DELETE", "POST"):
request = pytest.helpers.request("", "", method)
assert await PackageView.get_permission(request) == UserAccess.Write
async def test_get(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None: async def test_get(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None:

View File

@ -1,8 +1,24 @@
import pytest
from pytest_aiohttp import TestClient from pytest_aiohttp import TestClient
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess
from ahriman.web.views.status.packages import PackagesView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET", "HEAD"):
request = pytest.helpers.request("", "", method)
assert await PackagesView.get_permission(request) == UserAccess.Read
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await PackagesView.get_permission(request) == UserAccess.Write
async def test_get(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None: async def test_get(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None:

View File

@ -1,9 +1,22 @@
import pytest
from pytest_aiohttp import TestClient from pytest_aiohttp import TestClient
import ahriman.version as version import ahriman.version as version
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess
from ahriman.web.views.status.status import StatusView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET", "HEAD"):
request = pytest.helpers.request("", "", method)
assert await StatusView.get_permission(request) == UserAccess.Read
async def test_get(client: TestClient, package_ahriman: Package) -> None: async def test_get(client: TestClient, package_ahriman: Package) -> None:

View File

@ -33,6 +33,16 @@ def test_validator(base: BaseView) -> None:
assert base.validator assert base.validator
async def test_get_permission(base: BaseView) -> None:
"""
must search for permission attribute in class
"""
for method in ("DELETE", "GET", "HEAD", "POST"):
request = pytest.helpers.request(base.request.app, "", method)
setattr(BaseView, f"{method.upper()}_PERMISSION", "permission")
assert await base.get_permission(request) == "permission"
async def test_extract_data_json(base: BaseView) -> None: async def test_extract_data_json(base: BaseView) -> None:
""" """
must parse and return json must parse and return json

View File

@ -1,5 +1,19 @@
import pytest
from pytest_aiohttp import TestClient from pytest_aiohttp import TestClient
from ahriman.models.user_access import UserAccess
from ahriman.web.views.index import IndexView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET", "HEAD"):
request = pytest.helpers.request("", "", method)
assert await IndexView.get_permission(request) == UserAccess.Safe
async def test_get(client_with_auth: TestClient) -> None: async def test_get(client_with_auth: TestClient) -> None:
""" """
@ -34,3 +48,11 @@ async def test_get_static(client: TestClient) -> None:
""" """
response = await client.get("/static/favicon.ico") response = await client.get("/static/favicon.ico")
assert response.ok assert response.ok
async def test_get_static_with_auth(client_with_auth: TestClient) -> None:
"""
must return static files
"""
response = await client_with_auth.get("/static/favicon.ico")
assert response.ok

View File

@ -1,9 +1,21 @@
import pytest
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest.mock import MagicMock from unittest.mock import MagicMock
from ahriman.core.auth.oauth import OAuth from ahriman.core.auth.oauth import OAuth
from ahriman.models.user import User from ahriman.models.user import User
from ahriman.models.user_access import UserAccess
from ahriman.web.views.user.login import LoginView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET", "POST"):
request = pytest.helpers.request("", "", method)
assert await LoginView.get_permission(request) == UserAccess.Safe
async def test_get_default_validator(client_with_auth: TestClient) -> None: async def test_get_default_validator(client_with_auth: TestClient) -> None:

View File

@ -1,7 +1,21 @@
import pytest
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from aiohttp.web import HTTPUnauthorized from aiohttp.web import HTTPUnauthorized
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.models.user_access import UserAccess
from ahriman.web.views.user.logout import LogoutView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await LogoutView.get_permission(request) == UserAccess.Safe
async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None: async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None:
""" """

View File

@ -9,12 +9,12 @@ repositories = core extra community multilib
root = / root = /
[auth] [auth]
allow_read_only = no
client_id = client_id client_id = client_id
client_secret = client_secret client_secret = client_secret
oauth_provider = GoogleClient oauth_provider = GoogleClient
oauth_scopes = https://www.googleapis.com/auth/userinfo.email oauth_scopes = https://www.googleapis.com/auth/userinfo.email
salt = salt salt = salt
safe_build_status = no
[build] [build]
archbuild_flags = archbuild_flags =
@ -62,6 +62,9 @@ region = eu-central-1
secret_key = secret_key =
[web] [web]
debug = no
debug_check_host = no
debug_allowed_hosts =
host = 127.0.0.1 host = 127.0.0.1
static_path = ../web/templates/static static_path = ../web/templates/static
templates = ../web/templates templates = ../web/templates