mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-04-24 07:17:17 +00:00
Auth support (#25)
* initial auth implementation * add create user parser * add tests * update dependencies list * add login annd logout to index also improve auth * realworld fixes * add method set_option to Configuration and also use it everywhere * split CreateUser handler to additional read method * check user duplicate on auth mapping read * generate salt by using passlib instead of random.choice * case-insensetive usernames * update dependencies * update configuration reference * improve tests * fix codefactor errors * hide fields if authorization is enabled, but no auth supplied * add settings object for auth provider * readme update
This commit is contained in:
parent
60b94b9ac0
commit
b7cff0a19d
@ -18,6 +18,23 @@ libalpm and AUR related configuration.
|
|||||||
* `repositories` - list of pacman repositories, space separated list of strings, required.
|
* `repositories` - list of pacman repositories, space separated list of strings, required.
|
||||||
* `root` - root for alpm library, string, required.
|
* `root` - root for alpm library, string, required.
|
||||||
|
|
||||||
|
## `auth` group
|
||||||
|
|
||||||
|
Base authorization settings.
|
||||||
|
|
||||||
|
* `target` - specifies authorization provider, string, optional, default `disabled`. Allowed values are `disabled`, `configuration`.
|
||||||
|
* `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.
|
||||||
|
* `salt` - password hash salt, string, required in case if authorization enabled (automatically generated by `create-user` subcommand).
|
||||||
|
|
||||||
|
## `auth:*` groups
|
||||||
|
|
||||||
|
Authorization mapping. Group name must refer to user access level, i.e. it should be one of `auth:status` (internal API usage only), `auth:read` (read hidden pages), `auth:write` (everything is allowed).
|
||||||
|
|
||||||
|
Key is always username (case-insensitive), option value depends on authorization provider:
|
||||||
|
|
||||||
|
* `MappingAuth` (default) - reads salted password hashes from values, uses SHA512 in order to hash passwords. Password can be set by using `create-user` subcommand.
|
||||||
|
|
||||||
## `build:*` groups
|
## `build:*` groups
|
||||||
|
|
||||||
Build related configuration. Group name must refer to architecture, e.g. it should be `build:x86_64` for x86_64 architecture.
|
Build related configuration. Group name must refer to architecture, e.g. it should be `build:x86_64` for x86_64 architecture.
|
||||||
@ -101,6 +118,9 @@ 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.
|
||||||
* `host` - host to bind, string, optional.
|
* `host` - host to bind, string, optional.
|
||||||
|
* `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.
|
||||||
* `templates` - path to templates directory, string, required.
|
* `templates` - path to templates directory, string, required.
|
||||||
|
* `username` - username to authorize in web service in order to update service status, string, required in case if authorization enabled.
|
||||||
|
14
README.md
14
README.md
@ -7,13 +7,13 @@ Wrapper for managing custom repository inspired by [repo-scripts](https://github
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Install-configure-forget manager for own repository
|
* Install-configure-forget manager for own repository.
|
||||||
* Multi-architecture support
|
* Multi-architecture support.
|
||||||
* VCS packages support
|
* VCS packages support.
|
||||||
* Sign support with gpg (repository, package, per package settings)
|
* Sign support with gpg (repository, package, per package settings).
|
||||||
* Synchronization to remote services (rsync, s3) and report generation (html)
|
* Synchronization to remote services (rsync, s3) and report generation (html).
|
||||||
* Dependency manager
|
* Dependency manager.
|
||||||
* Repository status interface
|
* Repository status interface with optional authorization.
|
||||||
|
|
||||||
## Installation and run
|
## Installation and run
|
||||||
|
|
||||||
|
@ -7,15 +7,18 @@ pkgdesc="ArcHlinux ReposItory MANager"
|
|||||||
arch=('any')
|
arch=('any')
|
||||||
url="https://github.com/arcan1s/ahriman"
|
url="https://github.com/arcan1s/ahriman"
|
||||||
license=('GPL3')
|
license=('GPL3')
|
||||||
depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-srcinfo')
|
depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-passlib' 'python-srcinfo')
|
||||||
makedepends=('python-argparse-manpage' 'python-pip')
|
makedepends=('python-pip')
|
||||||
optdepends=('breezy: -bzr packages support'
|
optdepends=('breezy: -bzr packages support'
|
||||||
'darcs: -darcs packages support'
|
'darcs: -darcs packages support'
|
||||||
'gnupg: package and repository sign'
|
'gnupg: package and repository sign'
|
||||||
'mercurial: -hg packages support'
|
'mercurial: -hg packages support'
|
||||||
'python-aiohttp: web server'
|
'python-aiohttp: web server'
|
||||||
'python-aiohttp-jinja2: web server'
|
'python-aiohttp-jinja2: web server'
|
||||||
|
'python-aiohttp-security: web server with authorization'
|
||||||
|
'python-aiohttp-session: web server with authorization'
|
||||||
'python-boto3: sync to s3'
|
'python-boto3: sync to s3'
|
||||||
|
'python-cryptography: web server with authorization'
|
||||||
'python-jinja: html report generation'
|
'python-jinja: html report generation'
|
||||||
'rsync: sync by using rsync'
|
'rsync: sync by using rsync'
|
||||||
'subversion: -svn packages support')
|
'subversion: -svn packages support')
|
||||||
|
@ -8,6 +8,9 @@ database = /var/lib/pacman
|
|||||||
repositories = core extra community multilib
|
repositories = core extra community multilib
|
||||||
root = /
|
root = /
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
target = disabled
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
archbuild_flags =
|
archbuild_flags =
|
||||||
build_command = extra-x86_64-build
|
build_command = extra-x86_64-build
|
||||||
|
@ -12,11 +12,15 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<h1>ahriman
|
<h1>ahriman
|
||||||
<img src="https://img.shields.io/badge/version-{{ version }}-informational" alt="{{ version }}">
|
{% if authorized %}
|
||||||
<img src="https://img.shields.io/badge/architecture-{{ architecture }}-informational" alt="{{ architecture }}">
|
<img src="https://img.shields.io/badge/version-{{ version }}-informational" alt="{{ version }}">
|
||||||
<img src="https://img.shields.io/badge/service%20status-{{ service.status }}-{{ service.status_color }}" alt="{{ service.status }}" title="{{ service.timestamp }}">
|
<img src="https://img.shields.io/badge/architecture-{{ architecture }}-informational" alt="{{ architecture }}">
|
||||||
|
<img src="https://img.shields.io/badge/service%20status-{{ service.status }}-{{ service.status_color }}" alt="{{ service.status }}" title="{{ service.timestamp }}">
|
||||||
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{% include "login-form.jinja2" %}
|
||||||
|
{% include "login-form-hide.jinja2" %}
|
||||||
{% include "search-line.jinja2" %}
|
{% include "search-line.jinja2" %}
|
||||||
|
|
||||||
<section class="element">
|
<section class="element">
|
||||||
@ -29,15 +33,21 @@
|
|||||||
<th>status</th>
|
<th>status</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{% for package in packages %}
|
{% if authorized %}
|
||||||
|
{% for package in packages %}
|
||||||
|
<tr class="package">
|
||||||
|
<td class="include-search"><a href="{{ package.web_url }}" title="{{ package.base }}">{{ package.base }}</a></td>
|
||||||
|
<td class="include-search">{{ package.packages|join("<br>"|safe) }}</td>
|
||||||
|
<td>{{ package.version }}</td>
|
||||||
|
<td>{{ package.timestamp }}</td>
|
||||||
|
<td class="status package-{{ package.status }}">{{ package.status }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
<tr class="package">
|
<tr class="package">
|
||||||
<td class="include-search"><a href="{{ package.web_url }}" title="{{ package.base }}">{{ package.base }}</a></td>
|
<td colspan="100%">In order to see statuses you must login first</td>
|
||||||
<td class="include-search">{{ package.packages|join("<br>"|safe) }}</td>
|
|
||||||
<td>{{ package.version }}</td>
|
|
||||||
<td>{{ package.timestamp }}</td>
|
|
||||||
<td class="status package-{{ package.status }}">{{ package.status }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -46,6 +56,17 @@
|
|||||||
<li><a href="https://github.com/arcan1s/ahriman" title="sources">ahriman</a></li>
|
<li><a href="https://github.com/arcan1s/ahriman" title="sources">ahriman</a></li>
|
||||||
<li><a href="https://github.com/arcan1s/ahriman/releases" title="releases list">releases</a></li>
|
<li><a href="https://github.com/arcan1s/ahriman/releases" title="releases list">releases</a></li>
|
||||||
<li><a href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
|
<li><a href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
|
||||||
|
{% if auth_enabled %}
|
||||||
|
<li class="right">
|
||||||
|
{% if auth_username is not none %}
|
||||||
|
<form action="/logout" method="post">
|
||||||
|
<button class="login" type="submit">logout ({{ auth_username }})</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button class="login" onclick="document.getElementById('login-form').style.display='block'">login</button>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
9
package/share/ahriman/login-form-hide.jinja2
Normal file
9
package/share/ahriman/login-form-hide.jinja2
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script>
|
||||||
|
const modal = document.getElementById('login-form');
|
||||||
|
|
||||||
|
window.onclick = function(event) {
|
||||||
|
if (event.target === modal) {
|
||||||
|
modal.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
18
package/share/ahriman/login-form.jinja2
Normal file
18
package/share/ahriman/login-form.jinja2
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{#idea is from here https://www.w3schools.com/howto/howto_css_login_form.asp#}
|
||||||
|
<div id="login-form" class="modal-login-form">
|
||||||
|
<form class="modal-login-form-content animate" action="/login" method="post">
|
||||||
|
<div class="login-container">
|
||||||
|
<label for="username"><b>username</b></label>
|
||||||
|
<input type="text" placeholder="enter username" name="username" required>
|
||||||
|
|
||||||
|
<label for="password"><b>password</b></label>
|
||||||
|
<input type="password" placeholder="enter password" name="password" required>
|
||||||
|
|
||||||
|
<button class="login" type="submit">login</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<button class="cancel" onclick="document.getElementById('login-form').style.display='none'">cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
@ -40,6 +40,7 @@
|
|||||||
width: inherit;
|
width: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* table description */
|
||||||
th, td {
|
th, td {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
@ -103,6 +104,7 @@
|
|||||||
background-color: rgba(var(--color-success), 1.0);
|
background-color: rgba(var(--color-success), 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* navigation footer description */
|
||||||
ul.navigation {
|
ul.navigation {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -115,11 +117,8 @@
|
|||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.navigation li.status {
|
ul.navigation li.right {
|
||||||
display: block;
|
float: right;
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.navigation li a {
|
ul.navigation li a {
|
||||||
@ -131,6 +130,86 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ul.navigation li a:hover {
|
ul.navigation li a:hover {
|
||||||
background-color: rgba(var(--color-hover), 1.0);
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* login button in footer and modal page */
|
||||||
|
button.login {
|
||||||
|
background-color: rgba(var(--color-header), 1.0);
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.login:hover {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.cancel {
|
||||||
|
background-color: rgba(var(--color-failed), 1.0);
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.cancel:hover {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* modal page inputs and containers */
|
||||||
|
input[type=text], input[type=password] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 20px;
|
||||||
|
margin: 8px 0;
|
||||||
|
display: inline-block;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.password {
|
||||||
|
float: right;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-login-form {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgb(0, 0, 0);
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-login-form-content {
|
||||||
|
background-color: #fefefe;
|
||||||
|
margin: 5% auto 15% auto;
|
||||||
|
border: 1px solid #888;
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* modal page animation */
|
||||||
|
.animate {
|
||||||
|
-webkit-animation: animatezoom 0.6s;
|
||||||
|
animation: animatezoom 0.6s
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes animatezoom {
|
||||||
|
from {-webkit-transform: scale(0)}
|
||||||
|
to {-webkit-transform: scale(1)}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animatezoom {
|
||||||
|
from {transform: scale(0)}
|
||||||
|
to {transform: scale(1)}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -3,6 +3,3 @@ test = pytest
|
|||||||
|
|
||||||
[tool:pytest]
|
[tool:pytest]
|
||||||
addopts = --cov=ahriman --cov-report term-missing:skip-covered --pspec
|
addopts = --cov=ahriman --cov-report term-missing:skip-covered --pspec
|
||||||
|
|
||||||
[build_manpages]
|
|
||||||
manpages = man/ahriman.1:module=ahriman.application.ahriman:function=_parser
|
|
||||||
|
12
setup.py
12
setup.py
@ -1,11 +1,10 @@
|
|||||||
from build_manpages import build_manpages
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from setuptools import setup, find_packages
|
from setuptools import setup, find_packages
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
metadata_path = Path(__file__).resolve().parent / "src/ahriman/version.py"
|
metadata_path = Path(__file__).resolve().parent / "src/ahriman/version.py"
|
||||||
metadata: Dict[str, Any] = dict()
|
metadata: Dict[str, Any] = {}
|
||||||
with metadata_path.open() as metadata_file:
|
with metadata_path.open() as metadata_file:
|
||||||
exec(metadata_file.read(), metadata) # pylint: disable=exec-used
|
exec(metadata_file.read(), metadata) # pylint: disable=exec-used
|
||||||
|
|
||||||
@ -31,6 +30,7 @@ setup(
|
|||||||
],
|
],
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"aur",
|
"aur",
|
||||||
|
"passlib",
|
||||||
"pyalpm",
|
"pyalpm",
|
||||||
"requests",
|
"requests",
|
||||||
"srcinfo",
|
"srcinfo",
|
||||||
@ -97,10 +97,10 @@ setup(
|
|||||||
"Jinja2",
|
"Jinja2",
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
"aiohttp_jinja2",
|
"aiohttp_jinja2",
|
||||||
|
"aiohttp_session",
|
||||||
|
"aiohttp_security",
|
||||||
|
"cryptography",
|
||||||
|
"passlib",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
cmdclass={
|
|
||||||
"build_manpages": build_manpages.build_manpages,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
@ -30,6 +30,8 @@ from ahriman.models.sign_settings import SignSettings
|
|||||||
|
|
||||||
|
|
||||||
# pylint thinks it is bad idea, but get the fuck off
|
# pylint thinks it is bad idea, but get the fuck off
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
|
||||||
SubParserAction = argparse._SubParsersAction # pylint: disable=protected-access
|
SubParserAction = argparse._SubParsersAction # pylint: disable=protected-access
|
||||||
|
|
||||||
|
|
||||||
@ -61,6 +63,7 @@ def _parser() -> argparse.ArgumentParser:
|
|||||||
_set_check_parser(subparsers)
|
_set_check_parser(subparsers)
|
||||||
_set_clean_parser(subparsers)
|
_set_clean_parser(subparsers)
|
||||||
_set_config_parser(subparsers)
|
_set_config_parser(subparsers)
|
||||||
|
_set_create_user_parser(subparsers)
|
||||||
_set_init_parser(subparsers)
|
_set_init_parser(subparsers)
|
||||||
_set_key_import_parser(subparsers)
|
_set_key_import_parser(subparsers)
|
||||||
_set_rebuild_parser(subparsers)
|
_set_rebuild_parser(subparsers)
|
||||||
@ -138,6 +141,30 @@ def _set_config_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _set_create_user_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
||||||
|
"""
|
||||||
|
add parser for create user subcommand
|
||||||
|
:param root: subparsers for the commands
|
||||||
|
:return: created argument parser
|
||||||
|
"""
|
||||||
|
parser = root.add_parser(
|
||||||
|
"create-user",
|
||||||
|
help="create user for web services",
|
||||||
|
description="create user for web services with password and role. In case if password was not entered it will be asked interactively",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||||
|
parser.add_argument("username", help="username for web service")
|
||||||
|
parser.add_argument("-r", "--role", help="user role", type=UserAccess, choices=UserAccess, default=UserAccess.Read)
|
||||||
|
parser.add_argument("-p", "--password", help="user password")
|
||||||
|
parser.set_defaults(
|
||||||
|
handler=handlers.CreateUser,
|
||||||
|
architecture=[""],
|
||||||
|
lock=None,
|
||||||
|
no_log=True,
|
||||||
|
no_report=True,
|
||||||
|
unsafe=True)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def _set_init_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
def _set_init_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
||||||
"""
|
"""
|
||||||
add parser for init subcommand
|
add parser for init subcommand
|
||||||
|
@ -21,6 +21,7 @@ from ahriman.application.handlers.handler import Handler
|
|||||||
|
|
||||||
from ahriman.application.handlers.add import Add
|
from ahriman.application.handlers.add import Add
|
||||||
from ahriman.application.handlers.clean import Clean
|
from ahriman.application.handlers.clean import Clean
|
||||||
|
from ahriman.application.handlers.create_user import CreateUser
|
||||||
from ahriman.application.handlers.dump import Dump
|
from ahriman.application.handlers.dump import Dump
|
||||||
from ahriman.application.handlers.init import Init
|
from ahriman.application.handlers.init import Init
|
||||||
from ahriman.application.handlers.key_import import KeyImport
|
from ahriman.application.handlers.key_import import KeyImport
|
||||||
|
105
src/ahriman/application/handlers/create_user.py
Normal file
105
src/ahriman/application/handlers/create_user.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
#
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
|
from ahriman.application.handlers.handler import Handler
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
from ahriman.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUser(Handler):
|
||||||
|
"""
|
||||||
|
create user handler
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
callback for command line
|
||||||
|
:param args: command line args
|
||||||
|
:param architecture: repository architecture
|
||||||
|
:param configuration: configuration instance
|
||||||
|
"""
|
||||||
|
salt = CreateUser.get_salt(configuration)
|
||||||
|
user = CreateUser.create_user(args, salt)
|
||||||
|
auth_configuration = CreateUser.get_auth_configuration(configuration.include)
|
||||||
|
CreateUser.create_configuration(auth_configuration, user, salt)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_configuration(configuration: Configuration, user: User, salt: str) -> None:
|
||||||
|
"""
|
||||||
|
put new user to configuration
|
||||||
|
:param configuration: configuration instance
|
||||||
|
:param user: user descriptor
|
||||||
|
:param salt: password hash salt
|
||||||
|
"""
|
||||||
|
section = Configuration.section_name("auth", user.access.value)
|
||||||
|
configuration.set_option("auth", "salt", salt)
|
||||||
|
configuration.set_option(section, user.username, user.password)
|
||||||
|
|
||||||
|
if configuration.path is None:
|
||||||
|
return
|
||||||
|
with configuration.path.open("w") as ahriman_configuration:
|
||||||
|
configuration.write(ahriman_configuration)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_user(args: argparse.Namespace, salt: str) -> User:
|
||||||
|
"""
|
||||||
|
create user descriptor from arguments
|
||||||
|
:param args: command line args
|
||||||
|
:param salt: password hash salt
|
||||||
|
:return: built user descriptor
|
||||||
|
"""
|
||||||
|
user = User(args.username, args.password, args.role)
|
||||||
|
if user.password is None:
|
||||||
|
user.password = getpass.getpass()
|
||||||
|
user.password = user.hash_password(user.password, salt)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_auth_configuration(include_path: Path) -> Configuration:
|
||||||
|
"""
|
||||||
|
create configuration instance
|
||||||
|
:param include_path: path to directory with configuration includes
|
||||||
|
:return: configuration instance. In case if there are local settings they will be loaded
|
||||||
|
"""
|
||||||
|
target = include_path / "auth.ini"
|
||||||
|
configuration = Configuration()
|
||||||
|
if target.is_file(): # load current configuration in case if it exists
|
||||||
|
configuration.load(target)
|
||||||
|
|
||||||
|
return configuration
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_salt(configuration: Configuration, salt_length: int = 20) -> str:
|
||||||
|
"""
|
||||||
|
get salt from configuration or create new string
|
||||||
|
:param configuration: configuration instance
|
||||||
|
:param salt_length: salt length
|
||||||
|
:return: current salt
|
||||||
|
"""
|
||||||
|
salt = configuration.get("auth", "salt", fallback=None)
|
||||||
|
if salt:
|
||||||
|
return salt
|
||||||
|
return User.generate_password(salt_length)
|
@ -18,7 +18,6 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
import argparse
|
import argparse
|
||||||
import configparser
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Type
|
from typing import Type
|
||||||
@ -79,25 +78,20 @@ class Setup(Handler):
|
|||||||
:param repository: repository name
|
:param repository: repository name
|
||||||
:param include_path: path to directory with configuration includes
|
:param include_path: path to directory with configuration includes
|
||||||
"""
|
"""
|
||||||
configuration = configparser.ConfigParser()
|
configuration = Configuration()
|
||||||
|
|
||||||
section = Configuration.section_name("build", architecture)
|
section = Configuration.section_name("build", architecture)
|
||||||
configuration.add_section(section)
|
configuration.set_option(section, "build_command", str(Setup.build_command(args.build_command, architecture)))
|
||||||
configuration.set(section, "build_command", str(Setup.build_command(args.build_command, architecture)))
|
configuration.set_option("repository", "name", repository)
|
||||||
|
|
||||||
configuration.add_section("repository")
|
|
||||||
configuration.set("repository", "name", repository)
|
|
||||||
|
|
||||||
if args.sign_key is not None:
|
if args.sign_key is not None:
|
||||||
section = Configuration.section_name("sign", architecture)
|
section = Configuration.section_name("sign", architecture)
|
||||||
configuration.add_section(section)
|
configuration.set_option(section, "target", " ".join([target.name.lower() for target in args.sign_target]))
|
||||||
configuration.set(section, "target", " ".join([target.name.lower() for target in args.sign_target]))
|
configuration.set_option(section, "key", args.sign_key)
|
||||||
configuration.set(section, "key", args.sign_key)
|
|
||||||
|
|
||||||
if args.web_port is not None:
|
if args.web_port is not None:
|
||||||
section = Configuration.section_name("web", architecture)
|
section = Configuration.section_name("web", architecture)
|
||||||
configuration.add_section(section)
|
configuration.set_option(section, "port", str(args.web_port))
|
||||||
configuration.set(section, "port", str(args.web_port))
|
|
||||||
|
|
||||||
target = include_path / "setup-overrides.ini"
|
target = include_path / "setup-overrides.ini"
|
||||||
with target.open("w") as ahriman_configuration:
|
with target.open("w") as ahriman_configuration:
|
||||||
@ -115,7 +109,7 @@ class Setup(Handler):
|
|||||||
:param repository: repository name
|
:param repository: repository name
|
||||||
:param paths: repository paths instance
|
:param paths: repository paths instance
|
||||||
"""
|
"""
|
||||||
configuration = configparser.ConfigParser()
|
configuration = Configuration()
|
||||||
# preserve case
|
# preserve case
|
||||||
# stupid mypy thinks that it is impossible
|
# stupid mypy thinks that it is impossible
|
||||||
configuration.optionxform = lambda key: key # type: ignore
|
configuration.optionxform = lambda key: key # type: ignore
|
||||||
@ -125,17 +119,15 @@ class Setup(Handler):
|
|||||||
configuration.read(source)
|
configuration.read(source)
|
||||||
|
|
||||||
# set our architecture now
|
# set our architecture now
|
||||||
configuration.set("options", "Architecture", architecture)
|
configuration.set_option("options", "Architecture", architecture)
|
||||||
|
|
||||||
# add multilib
|
# add multilib
|
||||||
if not no_multilib:
|
if not no_multilib:
|
||||||
configuration.add_section("multilib")
|
configuration.set_option("multilib", "Include", str(Setup.MIRRORLIST_PATH))
|
||||||
configuration.set("multilib", "Include", str(Setup.MIRRORLIST_PATH))
|
|
||||||
|
|
||||||
# add repository itself
|
# add repository itself
|
||||||
configuration.add_section(repository)
|
configuration.set_option(repository, "SigLevel", "Optional TrustAll") # we don't care
|
||||||
configuration.set(repository, "SigLevel", "Optional TrustAll") # we don't care
|
configuration.set_option(repository, "Server", f"file://{paths.repository}")
|
||||||
configuration.set(repository, "Server", f"file://{paths.repository}")
|
|
||||||
|
|
||||||
target = source.parent / f"pacman-{prefix}-{architecture}.conf"
|
target = source.parent / f"pacman-{prefix}-{architecture}.conf"
|
||||||
with target.open("w") as devtools_configuration:
|
with target.open("w") as devtools_configuration:
|
||||||
|
0
src/ahriman/core/auth/__init__.py
Normal file
0
src/ahriman/core/auth/__init__.py
Normal file
105
src/ahriman/core/auth/auth.py
Normal file
105
src/ahriman/core/auth/auth.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
#
|
||||||
|
# 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 typing import Optional, Set, Type
|
||||||
|
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
from ahriman.models.auth_settings import AuthSettings
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
|
||||||
|
|
||||||
|
class Auth:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
:cvar ALLOWED_PATHS: URI paths which can be accessed without authorization, predefined
|
||||||
|
:cvar ALLOWED_PATHS_GROUPS: URI paths prefixes which can be accessed without authorization, predefined
|
||||||
|
"""
|
||||||
|
|
||||||
|
ALLOWED_PATHS = {"/", "/favicon.ico", "/index.html", "/login", "/logout"}
|
||||||
|
ALLOWED_PATHS_GROUPS: Set[str] = set()
|
||||||
|
|
||||||
|
def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None:
|
||||||
|
"""
|
||||||
|
default constructor
|
||||||
|
:param configuration: configuration instance
|
||||||
|
:param provider: authorization type definition
|
||||||
|
"""
|
||||||
|
self.allowed_paths = set(configuration.getlist("auth", "allowed_paths"))
|
||||||
|
self.allowed_paths.update(self.ALLOWED_PATHS)
|
||||||
|
self.allowed_paths_groups = set(configuration.getlist("auth", "allowed_paths_groups"))
|
||||||
|
self.allowed_paths_groups.update(self.ALLOWED_PATHS_GROUPS)
|
||||||
|
self.enabled = provider.is_enabled
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls: Type[Auth], configuration: Configuration) -> Auth:
|
||||||
|
"""
|
||||||
|
load authorization module from settings
|
||||||
|
:param configuration: configuration instance
|
||||||
|
:return: authorization module according to current settings
|
||||||
|
"""
|
||||||
|
provider = AuthSettings.from_option(configuration.get("auth", "target", fallback="disabled"))
|
||||||
|
if provider == AuthSettings.Configuration:
|
||||||
|
from ahriman.core.auth.mapping_auth import MappingAuth
|
||||||
|
return MappingAuth(configuration)
|
||||||
|
return cls(configuration)
|
||||||
|
|
||||||
|
def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool: # pylint: disable=no-self-use
|
||||||
|
"""
|
||||||
|
validate user password
|
||||||
|
:param username: username
|
||||||
|
:param password: entered password
|
||||||
|
:return: True in case if password matches, False otherwise
|
||||||
|
"""
|
||||||
|
del username, password
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_safe_request(self, uri: Optional[str]) -> bool:
|
||||||
|
"""
|
||||||
|
check if requested path are allowed without authorization
|
||||||
|
:param uri: request uri
|
||||||
|
:return: True in case if this URI can be requested without authorization and False otherwise
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
def known_username(self, username: str) -> bool: # pylint: disable=no-self-use
|
||||||
|
"""
|
||||||
|
check if user is known
|
||||||
|
:param username: username
|
||||||
|
:return: True in case if user is known and can be authorized and False otherwise
|
||||||
|
"""
|
||||||
|
del username
|
||||||
|
return True
|
||||||
|
|
||||||
|
def verify_access(self, username: str, required: UserAccess, context: Optional[str]) -> bool: # pylint: disable=no-self-use
|
||||||
|
"""
|
||||||
|
validate if user has access to requested resource
|
||||||
|
:param username: username
|
||||||
|
:param required: required access level
|
||||||
|
:param context: URI request path
|
||||||
|
:return: True in case if user is allowed to do this request and False otherwise
|
||||||
|
"""
|
||||||
|
del username, required, context
|
||||||
|
return True
|
70
src/ahriman/core/auth/helpers.py
Normal file
70
src/ahriman/core/auth/helpers.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2021 ahriman team.
|
||||||
|
#
|
||||||
|
# This file is part of ahriman
|
||||||
|
# (see https://github.com/arcan1s/ahriman).
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiohttp_security # type: ignore
|
||||||
|
_has_aiohttp_security = True
|
||||||
|
except ImportError:
|
||||||
|
_has_aiohttp_security = False
|
||||||
|
|
||||||
|
|
||||||
|
async def authorized_userid(*args: Any) -> Any:
|
||||||
|
"""
|
||||||
|
handle aiohttp security methods
|
||||||
|
:param args: argument list as provided by authorized_userid function
|
||||||
|
:return: None in case if no aiohttp_security module found and function call otherwise
|
||||||
|
"""
|
||||||
|
if _has_aiohttp_security:
|
||||||
|
return await aiohttp_security.authorized_userid(*args) # pylint: disable=no-value-for-parameter
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_authorized(*args: Any) -> Any:
|
||||||
|
"""
|
||||||
|
handle aiohttp security methods
|
||||||
|
:param args: argument list as provided by check_authorized function
|
||||||
|
:return: None in case if no aiohttp_security module found and function call otherwise
|
||||||
|
"""
|
||||||
|
if _has_aiohttp_security:
|
||||||
|
return await aiohttp_security.check_authorized(*args) # pylint: disable=no-value-for-parameter
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def forget(*args: Any) -> Any:
|
||||||
|
"""
|
||||||
|
handle aiohttp security methods
|
||||||
|
:param args: argument list as provided by forget function
|
||||||
|
:return: None in case if no aiohttp_security module found and function call otherwise
|
||||||
|
"""
|
||||||
|
if _has_aiohttp_security:
|
||||||
|
return await aiohttp_security.forget(*args) # pylint: disable=no-value-for-parameter
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def remember(*args: Any) -> Any:
|
||||||
|
"""
|
||||||
|
handle disabled auth
|
||||||
|
:param args: argument list as provided by remember function
|
||||||
|
:return: None in case if no aiohttp_security module found and function call otherwise
|
||||||
|
"""
|
||||||
|
if _has_aiohttp_security:
|
||||||
|
return await aiohttp_security.remember(*args) # pylint: disable=no-value-for-parameter
|
||||||
|
return None
|
105
src/ahriman/core/auth/mapping_auth.py
Normal file
105
src/ahriman/core/auth/mapping_auth.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
#
|
||||||
|
# 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 typing import Dict, Optional
|
||||||
|
|
||||||
|
from ahriman.core.auth.auth import Auth
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
from ahriman.core.exceptions import DuplicateUser
|
||||||
|
from ahriman.models.auth_settings import AuthSettings
|
||||||
|
from ahriman.models.user import User
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
|
||||||
|
|
||||||
|
class MappingAuth(Auth):
|
||||||
|
"""
|
||||||
|
user authorization based on mapping from configuration file
|
||||||
|
:ivar salt: random generated string to salt passwords
|
||||||
|
:ivar _users: map of username to its descriptor
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Configuration) -> None:
|
||||||
|
"""
|
||||||
|
default constructor
|
||||||
|
:param configuration: configuration instance
|
||||||
|
:param provider: authorization type definition
|
||||||
|
"""
|
||||||
|
Auth.__init__(self, configuration, provider)
|
||||||
|
self.salt = configuration.get("auth", "salt")
|
||||||
|
self._users = self.get_users(configuration)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_users(configuration: Configuration) -> Dict[str, User]:
|
||||||
|
"""
|
||||||
|
load users from settings
|
||||||
|
:param configuration: configuration instance
|
||||||
|
:return: map of username to its descriptor
|
||||||
|
"""
|
||||||
|
users: Dict[str, User] = {}
|
||||||
|
for role in UserAccess:
|
||||||
|
section = configuration.section_name("auth", role.value)
|
||||||
|
if not configuration.has_section(section):
|
||||||
|
continue
|
||||||
|
for user, password in configuration[section].items():
|
||||||
|
normalized_user = user.lower()
|
||||||
|
if normalized_user in users:
|
||||||
|
raise DuplicateUser(normalized_user)
|
||||||
|
users[normalized_user] = User(normalized_user, password, role)
|
||||||
|
return users
|
||||||
|
|
||||||
|
def check_credentials(self, username: Optional[str], password: Optional[str]) -> bool:
|
||||||
|
"""
|
||||||
|
validate user password
|
||||||
|
:param username: username
|
||||||
|
:param password: entered password
|
||||||
|
:return: True in case if password matches, False otherwise
|
||||||
|
"""
|
||||||
|
if username is None or password is None:
|
||||||
|
return False # invalid data supplied
|
||||||
|
user = self.get_user(username)
|
||||||
|
return user is not None and user.check_credentials(password, self.salt)
|
||||||
|
|
||||||
|
def get_user(self, username: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
retrieve user from in-memory mapping
|
||||||
|
:param username: username
|
||||||
|
:return: user descriptor if username is known and None otherwise
|
||||||
|
"""
|
||||||
|
normalized_user = username.lower()
|
||||||
|
return self._users.get(normalized_user)
|
||||||
|
|
||||||
|
def known_username(self, username: str) -> bool:
|
||||||
|
"""
|
||||||
|
check if user is known
|
||||||
|
:param username: username
|
||||||
|
:return: True in case if user is known and can be authorized and False otherwise
|
||||||
|
"""
|
||||||
|
return self.get_user(username) is not None
|
||||||
|
|
||||||
|
def verify_access(self, username: str, required: UserAccess, context: Optional[str]) -> bool:
|
||||||
|
"""
|
||||||
|
validate if user has access to requested resource
|
||||||
|
:param username: username
|
||||||
|
:param required: required access level
|
||||||
|
:param context: URI request path
|
||||||
|
:return: True in case if user is allowed to do this request and False otherwise
|
||||||
|
"""
|
||||||
|
del context
|
||||||
|
user = self.get_user(username)
|
||||||
|
return user is not None and user.verify_access(required)
|
@ -78,14 +78,14 @@ class Configuration(configparser.RawConfigParser):
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def section_name(section: str, architecture: str) -> str:
|
def section_name(section: str, suffix: str) -> str:
|
||||||
"""
|
"""
|
||||||
generate section name for architecture specific sections
|
generate section name for sections which depends on context
|
||||||
:param section: section name
|
:param section: section name
|
||||||
:param architecture: repository architecture
|
:param suffix: session suffix, e.g. repository architecture
|
||||||
:return: correct section name for repository specific section
|
:return: correct section name for repository specific section
|
||||||
"""
|
"""
|
||||||
return f"{section}:{architecture}"
|
return f"{section}:{suffix}"
|
||||||
|
|
||||||
def dump(self) -> Dict[str, Dict[str, str]]:
|
def dump(self) -> Dict[str, Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
@ -170,18 +170,27 @@ class Configuration(configparser.RawConfigParser):
|
|||||||
:param architecture: repository architecture
|
:param architecture: repository architecture
|
||||||
"""
|
"""
|
||||||
for section in self.ARCHITECTURE_SPECIFIC_SECTIONS:
|
for section in self.ARCHITECTURE_SPECIFIC_SECTIONS:
|
||||||
if not self.has_section(section):
|
|
||||||
self.add_section(section) # add section if not exists
|
|
||||||
# get overrides
|
# get overrides
|
||||||
specific = self.section_name(section, architecture)
|
specific = self.section_name(section, architecture)
|
||||||
if self.has_section(specific):
|
if self.has_section(specific):
|
||||||
# if there is no such section it means that there is no overrides for this arch
|
# if there is no such section it means that there is no overrides for this arch
|
||||||
# but we anyway will have to delete sections for others archs
|
# but we anyway will have to delete sections for others archs
|
||||||
for key, value in self[specific].items():
|
for key, value in self[specific].items():
|
||||||
self.set(section, key, value)
|
self.set_option(section, key, value)
|
||||||
# remove any arch specific section
|
# remove any arch specific section
|
||||||
for foreign in self.sections():
|
for foreign in self.sections():
|
||||||
# we would like to use lambda filter here, but pylint is too dumb
|
# we would like to use lambda filter here, but pylint is too dumb
|
||||||
if not foreign.startswith(f"{section}:"):
|
if not foreign.startswith(f"{section}:"):
|
||||||
continue
|
continue
|
||||||
self.remove_section(foreign)
|
self.remove_section(foreign)
|
||||||
|
|
||||||
|
def set_option(self, section: str, option: str, value: Optional[str]) -> None:
|
||||||
|
"""
|
||||||
|
set option. Unlike default `configparser.RawConfigParser.set` it also creates section if it does not exist
|
||||||
|
:param section: section name
|
||||||
|
:param option: option name
|
||||||
|
:param value: option value as string in parsable format
|
||||||
|
"""
|
||||||
|
if not self.has_section(section):
|
||||||
|
self.add_section(section)
|
||||||
|
self.set(section, option, value)
|
||||||
|
@ -45,6 +45,19 @@ class DuplicateRun(Exception):
|
|||||||
Exception.__init__(self, "Another application instance is run")
|
Exception.__init__(self, "Another application instance is run")
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateUser(Exception):
|
||||||
|
"""
|
||||||
|
exception which will be thrown in case if there are two users with different settings
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, username: str) -> None:
|
||||||
|
"""
|
||||||
|
default constructor
|
||||||
|
:param username: username with duplicates
|
||||||
|
"""
|
||||||
|
Exception.__init__(self, f"Found duplicate user with username {username}")
|
||||||
|
|
||||||
|
|
||||||
class InitializeException(Exception):
|
class InitializeException(Exception):
|
||||||
"""
|
"""
|
||||||
base service initialization exception
|
base service initialization exception
|
||||||
|
@ -39,11 +39,12 @@ class Client:
|
|||||||
:param configuration: configuration instance
|
:param configuration: configuration instance
|
||||||
:return: client according to current settings
|
:return: client according to current settings
|
||||||
"""
|
"""
|
||||||
|
address = configuration.get("web", "address", fallback=None)
|
||||||
host = configuration.get("web", "host", fallback=None)
|
host = configuration.get("web", "host", fallback=None)
|
||||||
port = configuration.getint("web", "port", fallback=None)
|
port = configuration.getint("web", "port", fallback=None)
|
||||||
if host is not None and port is not None:
|
if address or (host and port):
|
||||||
from ahriman.core.status.web_client import WebClient
|
from ahriman.core.status.web_client import WebClient
|
||||||
return WebClient(host, port)
|
return WebClient(configuration)
|
||||||
return cls()
|
return cls()
|
||||||
|
|
||||||
def add(self, package: Package, status: BuildStatusEnum) -> None:
|
def add(self, package: Package, status: BuildStatusEnum) -> None:
|
||||||
|
@ -22,37 +22,92 @@ import requests
|
|||||||
|
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
from ahriman.core.status.client import Client
|
from ahriman.core.status.client import Client
|
||||||
from ahriman.core.util import exception_response_text
|
from ahriman.core.util import exception_response_text
|
||||||
from ahriman.models.build_status import BuildStatusEnum, BuildStatus
|
from ahriman.models.build_status import BuildStatusEnum, BuildStatus
|
||||||
from ahriman.models.internal_status import InternalStatus
|
from ahriman.models.internal_status import InternalStatus
|
||||||
from ahriman.models.package import Package
|
from ahriman.models.package import Package
|
||||||
|
from ahriman.models.user import User
|
||||||
|
|
||||||
|
|
||||||
class WebClient(Client):
|
class WebClient(Client):
|
||||||
"""
|
"""
|
||||||
build status reporter web client
|
build status reporter web client
|
||||||
:ivar host: host of web service
|
:ivar address: address of the web service
|
||||||
:ivar logger: class logger
|
:ivar logger: class logger
|
||||||
:ivar port: port of web service
|
:ivar user: web service user descriptor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, host: str, port: int) -> None:
|
def __init__(self, configuration: Configuration) -> None:
|
||||||
"""
|
"""
|
||||||
default constructor
|
default constructor
|
||||||
:param host: host of web service
|
:param configuration: configuration instance
|
||||||
:param port: port of web service
|
|
||||||
"""
|
"""
|
||||||
self.logger = logging.getLogger("http")
|
self.logger = logging.getLogger("http")
|
||||||
self.host = host
|
self.address = self.parse_address(configuration)
|
||||||
self.port = port
|
self.user = User.from_option(
|
||||||
|
configuration.get("web", "username", fallback=None),
|
||||||
|
configuration.get("web", "password", fallback=None))
|
||||||
|
|
||||||
|
self.__session = requests.session()
|
||||||
|
self._login()
|
||||||
|
|
||||||
|
@property
|
||||||
def _ahriman_url(self) -> str:
|
def _ahriman_url(self) -> str:
|
||||||
"""
|
"""
|
||||||
url generator
|
|
||||||
:return: full url for web service for ahriman service itself
|
:return: full url for web service for ahriman service itself
|
||||||
"""
|
"""
|
||||||
return f"http://{self.host}:{self.port}/api/v1/ahriman"
|
return f"{self.address}/api/v1/ahriman"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _login_url(self) -> str:
|
||||||
|
"""
|
||||||
|
:return: full url for web service to login
|
||||||
|
"""
|
||||||
|
return f"{self.address}/login"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _status_url(self) -> str:
|
||||||
|
"""
|
||||||
|
:return: full url for web service for status
|
||||||
|
"""
|
||||||
|
return f"{self.address}/api/v1/status"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_address(configuration: Configuration) -> str:
|
||||||
|
"""
|
||||||
|
parse address from configuration
|
||||||
|
:param configuration: configuration instance
|
||||||
|
:return: valid http address
|
||||||
|
"""
|
||||||
|
address = configuration.get("web", "address", fallback=None)
|
||||||
|
if not address:
|
||||||
|
# build address from host and port directly
|
||||||
|
host = configuration.get("web", "host")
|
||||||
|
port = configuration.getint("web", "port")
|
||||||
|
address = f"http://{host}:{port}"
|
||||||
|
return address
|
||||||
|
|
||||||
|
def _login(self) -> None:
|
||||||
|
"""
|
||||||
|
process login to the service
|
||||||
|
"""
|
||||||
|
if self.user is None:
|
||||||
|
return # no auth configured
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"username": self.user.username,
|
||||||
|
"password": self.user.password
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.__session.post(self._login_url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
self.logger.exception("could not login as %s: %s", self.user, exception_response_text(e))
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("could not login as %s", self.user)
|
||||||
|
|
||||||
def _package_url(self, base: str = "") -> str:
|
def _package_url(self, base: str = "") -> str:
|
||||||
"""
|
"""
|
||||||
@ -60,14 +115,7 @@ class WebClient(Client):
|
|||||||
:param base: package base to generate url
|
:param base: package base to generate url
|
||||||
:return: full url of web service for specific package base
|
:return: full url of web service for specific package base
|
||||||
"""
|
"""
|
||||||
return f"http://{self.host}:{self.port}/api/v1/packages/{base}"
|
return f"{self.address}/api/v1/packages/{base}"
|
||||||
|
|
||||||
def _status_url(self) -> str:
|
|
||||||
"""
|
|
||||||
url generator
|
|
||||||
:return: full url for web service for status
|
|
||||||
"""
|
|
||||||
return f"http://{self.host}:{self.port}/api/v1/status"
|
|
||||||
|
|
||||||
def add(self, package: Package, status: BuildStatusEnum) -> None:
|
def add(self, package: Package, status: BuildStatusEnum) -> None:
|
||||||
"""
|
"""
|
||||||
@ -81,7 +129,7 @@ class WebClient(Client):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(self._package_url(package.base), json=payload)
|
response = self.__session.post(self._package_url(package.base), json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
self.logger.exception("could not add %s: %s", package.base, exception_response_text(e))
|
self.logger.exception("could not add %s: %s", package.base, exception_response_text(e))
|
||||||
@ -95,7 +143,7 @@ class WebClient(Client):
|
|||||||
:return: list of current package description and status if it has been found
|
:return: list of current package description and status if it has been found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = requests.get(self._package_url(base or ""))
|
response = self.__session.get(self._package_url(base or ""))
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
status_json = response.json()
|
status_json = response.json()
|
||||||
@ -115,7 +163,7 @@ class WebClient(Client):
|
|||||||
:return: current internal (web) service status
|
:return: current internal (web) service status
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = requests.get(self._status_url())
|
response = self.__session.get(self._status_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
status_json = response.json()
|
status_json = response.json()
|
||||||
@ -132,7 +180,7 @@ class WebClient(Client):
|
|||||||
:return: current ahriman status
|
:return: current ahriman status
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = requests.get(self._ahriman_url())
|
response = self.__session.get(self._ahriman_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
status_json = response.json()
|
status_json = response.json()
|
||||||
@ -149,7 +197,7 @@ class WebClient(Client):
|
|||||||
:param base: basename to remove
|
:param base: basename to remove
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = requests.delete(self._package_url(base))
|
response = self.__session.delete(self._package_url(base))
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
self.logger.exception("could not delete %s: %s", base, exception_response_text(e))
|
self.logger.exception("could not delete %s: %s", base, exception_response_text(e))
|
||||||
@ -165,7 +213,7 @@ class WebClient(Client):
|
|||||||
payload = {"status": status.value}
|
payload = {"status": status.value}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(self._package_url(base), json=payload)
|
response = self.__session.post(self._package_url(base), json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
self.logger.exception("could not update %s: %s", base, exception_response_text(e))
|
self.logger.exception("could not update %s: %s", base, exception_response_text(e))
|
||||||
@ -180,7 +228,7 @@ class WebClient(Client):
|
|||||||
payload = {"status": status.value}
|
payload = {"status": status.value}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(self._ahriman_url(), json=payload)
|
response = self.__session.post(self._ahriman_url, json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
self.logger.exception("could not update service status: %s", exception_response_text(e))
|
self.logger.exception("could not update service status: %s", exception_response_text(e))
|
||||||
|
58
src/ahriman/models/auth_settings.py
Normal file
58
src/ahriman/models/auth_settings.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
#
|
||||||
|
# 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, auto
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
|
from ahriman.core.exceptions import InvalidOption
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSettings(Enum):
|
||||||
|
"""
|
||||||
|
web authorization type
|
||||||
|
:cvar Disabled: authorization is disabled
|
||||||
|
:cvar Configuration: configuration based authorization
|
||||||
|
"""
|
||||||
|
|
||||||
|
Disabled = auto()
|
||||||
|
Configuration = auto()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_option(cls: Type[AuthSettings], value: str) -> AuthSettings:
|
||||||
|
"""
|
||||||
|
construct value from configuration
|
||||||
|
:param value: configuration value
|
||||||
|
:return: parsed value
|
||||||
|
"""
|
||||||
|
if value.lower() in ("disabled", "no"):
|
||||||
|
return cls.Disabled
|
||||||
|
if value.lower() in ("configuration", "mapping"):
|
||||||
|
return cls.Configuration
|
||||||
|
raise InvalidOption(value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""
|
||||||
|
:return: False in case if authorization is disabled and True otherwise
|
||||||
|
"""
|
||||||
|
if self == AuthSettings.Disabled:
|
||||||
|
return False
|
||||||
|
return True
|
101
src/ahriman/models/user.py
Normal file
101
src/ahriman/models/user.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
#
|
||||||
|
# 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 dataclasses import dataclass
|
||||||
|
from typing import Optional, Type
|
||||||
|
from passlib.pwd import genword as generate_password # type: ignore
|
||||||
|
from passlib.handlers.sha2_crypt import sha512_crypt # type: ignore
|
||||||
|
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class User:
|
||||||
|
"""
|
||||||
|
authorized web user model
|
||||||
|
:ivar username: username
|
||||||
|
:ivar password: hashed user password with salt
|
||||||
|
:ivar access: user role
|
||||||
|
"""
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
access: UserAccess
|
||||||
|
|
||||||
|
_HASHER = sha512_crypt
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_option(cls: Type[User], username: Optional[str], password: Optional[str]) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
build user descriptor from configuration options
|
||||||
|
:param username: username
|
||||||
|
:param password: password as string
|
||||||
|
:return: generated user descriptor if all options are supplied and None otherwise
|
||||||
|
"""
|
||||||
|
if username is None or password is None:
|
||||||
|
return None
|
||||||
|
return cls(username, password, UserAccess.Status)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_password(length: int) -> str:
|
||||||
|
"""
|
||||||
|
generate password with specified length
|
||||||
|
:param length: password length
|
||||||
|
:return: random string which contains letters and numbers
|
||||||
|
"""
|
||||||
|
password: str = generate_password(length=length)
|
||||||
|
return password
|
||||||
|
|
||||||
|
def check_credentials(self, password: str, salt: str) -> bool:
|
||||||
|
"""
|
||||||
|
validate user password
|
||||||
|
:param password: entered password
|
||||||
|
:param salt: salt for hashed password
|
||||||
|
:return: True in case if password matches, False otherwise
|
||||||
|
"""
|
||||||
|
verified: bool = self._HASHER.verify(password + salt, self.password)
|
||||||
|
return verified
|
||||||
|
|
||||||
|
def hash_password(self, password: str, salt: str) -> str:
|
||||||
|
"""
|
||||||
|
generate hashed password from plain text
|
||||||
|
:param password: entered password
|
||||||
|
:param salt: salt for hashed password
|
||||||
|
:return: hashed string to store in configuration
|
||||||
|
"""
|
||||||
|
password_hash: str = self._HASHER.hash(password + salt)
|
||||||
|
return password_hash
|
||||||
|
|
||||||
|
def verify_access(self, required: UserAccess) -> bool:
|
||||||
|
"""
|
||||||
|
validate if user has access to requested resource
|
||||||
|
:param required: required access level
|
||||||
|
:return: True in case if user is allowed to do this request and False otherwise
|
||||||
|
"""
|
||||||
|
if self.access == UserAccess.Write:
|
||||||
|
return True # everything is allowed
|
||||||
|
return self.access == required
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""
|
||||||
|
generate string representation of object
|
||||||
|
:return: unique string representation
|
||||||
|
"""
|
||||||
|
return f"User(username={self.username}, access={self.access})"
|
33
src/ahriman/models/user_access.py
Normal file
33
src/ahriman/models/user_access.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
#
|
||||||
|
# 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 enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class UserAccess(Enum):
|
||||||
|
"""
|
||||||
|
web user access enumeration
|
||||||
|
:cvar Read: user can read status page
|
||||||
|
:cvar Write: user can modify task and package list
|
||||||
|
:cvar Status: user can update statuses via API
|
||||||
|
"""
|
||||||
|
|
||||||
|
Read = "read"
|
||||||
|
Write = "write"
|
||||||
|
Status = "status"
|
@ -17,3 +17,10 @@
|
|||||||
# 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 Request
|
||||||
|
from aiohttp.web_response import StreamResponse
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
|
|
||||||
|
HandlerType = Callable[[Request], Awaitable[StreamResponse]]
|
||||||
|
MiddlewareType = Callable[[Request, HandlerType], Awaitable[StreamResponse]]
|
||||||
|
109
src/ahriman/web/middlewares/auth_handler.py
Normal file
109
src/ahriman/web/middlewares/auth_handler.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
#
|
||||||
|
import aiohttp_security # type: ignore
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.web import middleware, Request
|
||||||
|
from aiohttp.web_response import StreamResponse
|
||||||
|
from aiohttp_session import setup as setup_session # type: ignore
|
||||||
|
from aiohttp_session.cookie_storage import EncryptedCookieStorage # type: ignore
|
||||||
|
from cryptography import fernet
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ahriman.core.auth.auth import Auth
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
from ahriman.web.middlewares import HandlerType, MiddlewareType
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy): # type: ignore
|
||||||
|
"""
|
||||||
|
authorization policy implementation
|
||||||
|
:ivar validator: validator instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, validator: Auth) -> None:
|
||||||
|
"""
|
||||||
|
default constructor
|
||||||
|
:param validator: authorization module instance
|
||||||
|
"""
|
||||||
|
self.validator = validator
|
||||||
|
|
||||||
|
async def authorized_userid(self, identity: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
retrieve authorized username
|
||||||
|
:param identity: username
|
||||||
|
:return: user identity (username) in case if user exists and None otherwise
|
||||||
|
"""
|
||||||
|
return identity if self.validator.known_username(identity) else None
|
||||||
|
|
||||||
|
async def permits(self, identity: str, permission: UserAccess, context: Optional[str] = None) -> bool:
|
||||||
|
"""
|
||||||
|
check user permissions
|
||||||
|
:param identity: username
|
||||||
|
:param permission: requested permission level
|
||||||
|
:param context: URI request path
|
||||||
|
:return: True in case if user is allowed to perform this request and False otherwise
|
||||||
|
"""
|
||||||
|
return self.validator.verify_access(identity, permission, context)
|
||||||
|
|
||||||
|
|
||||||
|
def auth_handler(validator: Auth) -> MiddlewareType:
|
||||||
|
"""
|
||||||
|
authorization and authentication middleware
|
||||||
|
:param validator: authorization module instance
|
||||||
|
:return: built middleware
|
||||||
|
"""
|
||||||
|
@middleware
|
||||||
|
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
|
||||||
|
if request.path.startswith("/api"):
|
||||||
|
permission = UserAccess.Status
|
||||||
|
elif request.method in ("GET", "HEAD", "OPTIONS"):
|
||||||
|
permission = UserAccess.Read
|
||||||
|
else:
|
||||||
|
permission = UserAccess.Write
|
||||||
|
|
||||||
|
if not validator.is_safe_request(request.path):
|
||||||
|
await aiohttp_security.check_permission(request, permission, request.path)
|
||||||
|
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
def setup_auth(application: web.Application, validator: Auth) -> web.Application:
|
||||||
|
"""
|
||||||
|
setup authorization policies for the application
|
||||||
|
:param application: web application instance
|
||||||
|
:param validator: authorization module instance
|
||||||
|
:return: configured web application
|
||||||
|
"""
|
||||||
|
fernet_key = fernet.Fernet.generate_key()
|
||||||
|
secret_key = base64.urlsafe_b64decode(fernet_key)
|
||||||
|
storage = EncryptedCookieStorage(secret_key, cookie_name='API_SESSION')
|
||||||
|
setup_session(application, storage)
|
||||||
|
|
||||||
|
authorization_policy = AuthorizationPolicy(validator)
|
||||||
|
identity_policy = aiohttp_security.SessionIdentityPolicy()
|
||||||
|
|
||||||
|
aiohttp_security.setup(application, identity_policy, authorization_policy)
|
||||||
|
application.middlewares.append(auth_handler(validator))
|
||||||
|
|
||||||
|
return application
|
@ -21,13 +21,11 @@ from aiohttp.web import middleware, Request
|
|||||||
from aiohttp.web_exceptions import HTTPClientError
|
from aiohttp.web_exceptions import HTTPClientError
|
||||||
from aiohttp.web_response import StreamResponse
|
from aiohttp.web_response import StreamResponse
|
||||||
from logging import Logger
|
from logging import Logger
|
||||||
from typing import Awaitable, Callable
|
|
||||||
|
from ahriman.web.middlewares import HandlerType, MiddlewareType
|
||||||
|
|
||||||
|
|
||||||
HandlerType = Callable[[Request], Awaitable[StreamResponse]]
|
def exception_handler(logger: Logger) -> MiddlewareType:
|
||||||
|
|
||||||
|
|
||||||
def exception_handler(logger: Logger) -> Callable[[Request, HandlerType], Awaitable[StreamResponse]]:
|
|
||||||
"""
|
"""
|
||||||
exception handler middleware. Just log any exception (except for client ones)
|
exception handler middleware. Just log any exception (except for client ones)
|
||||||
:param logger: class logger
|
:param logger: class logger
|
||||||
|
@ -21,6 +21,8 @@ from aiohttp.web import Application
|
|||||||
|
|
||||||
from ahriman.web.views.ahriman import AhrimanView
|
from ahriman.web.views.ahriman import AhrimanView
|
||||||
from ahriman.web.views.index import IndexView
|
from ahriman.web.views.index import IndexView
|
||||||
|
from ahriman.web.views.login import LoginView
|
||||||
|
from ahriman.web.views.logout import LogoutView
|
||||||
from ahriman.web.views.package import PackageView
|
from ahriman.web.views.package import PackageView
|
||||||
from ahriman.web.views.packages import PackagesView
|
from ahriman.web.views.packages import PackagesView
|
||||||
from ahriman.web.views.status import StatusView
|
from ahriman.web.views.status import StatusView
|
||||||
@ -35,6 +37,9 @@ def setup_routes(application: Application) -> None:
|
|||||||
GET / get build status page
|
GET / get build status page
|
||||||
GET /index.html same as above
|
GET /index.html same as above
|
||||||
|
|
||||||
|
POST /login login to service
|
||||||
|
POST /logout logout from service
|
||||||
|
|
||||||
GET /api/v1/ahriman get current service status
|
GET /api/v1/ahriman get current service status
|
||||||
POST /api/v1/ahriman update service status
|
POST /api/v1/ahriman update service status
|
||||||
|
|
||||||
@ -52,6 +57,9 @@ def setup_routes(application: Application) -> None:
|
|||||||
application.router.add_get("/", IndexView)
|
application.router.add_get("/", IndexView)
|
||||||
application.router.add_get("/index.html", IndexView)
|
application.router.add_get("/index.html", IndexView)
|
||||||
|
|
||||||
|
application.router.add_post("/login", LoginView)
|
||||||
|
application.router.add_post("/logout", LogoutView)
|
||||||
|
|
||||||
application.router.add_get("/api/v1/ahriman", AhrimanView)
|
application.router.add_get("/api/v1/ahriman", AhrimanView)
|
||||||
application.router.add_post("/api/v1/ahriman", AhrimanView)
|
application.router.add_post("/api/v1/ahriman", AhrimanView)
|
||||||
|
|
||||||
|
@ -46,7 +46,7 @@ class AhrimanView(BaseView):
|
|||||||
|
|
||||||
:return: 204 on success
|
:return: 204 on success
|
||||||
"""
|
"""
|
||||||
data = await self.request.json()
|
data = await self.extract_data()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = BuildStatusEnum(data["status"])
|
status = BuildStatusEnum(data["status"])
|
||||||
|
@ -18,7 +18,9 @@
|
|||||||
# 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 aiohttp.web import View
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from ahriman.core.auth.auth import Auth
|
||||||
from ahriman.core.status.watcher import Watcher
|
from ahriman.core.status.watcher import Watcher
|
||||||
|
|
||||||
|
|
||||||
@ -34,3 +36,22 @@ class BaseView(View):
|
|||||||
"""
|
"""
|
||||||
watcher: Watcher = self.request.app["watcher"]
|
watcher: Watcher = self.request.app["watcher"]
|
||||||
return watcher
|
return watcher
|
||||||
|
|
||||||
|
@property
|
||||||
|
def validator(self) -> Auth:
|
||||||
|
"""
|
||||||
|
:return: authorization service instance
|
||||||
|
"""
|
||||||
|
validator: Auth = self.request.app["validator"]
|
||||||
|
return validator
|
||||||
|
|
||||||
|
async def extract_data(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
extract json data from either json or form data
|
||||||
|
:return: raw json object or form data converted to json
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
json: Dict[str, Any] = await self.request.json()
|
||||||
|
return json
|
||||||
|
except ValueError:
|
||||||
|
return dict(await self.request.post())
|
||||||
|
@ -22,6 +22,7 @@ import aiohttp_jinja2
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from ahriman import version
|
from ahriman import version
|
||||||
|
from ahriman.core.auth.helpers import authorized_userid
|
||||||
from ahriman.core.util import pretty_datetime
|
from ahriman.core.util import pretty_datetime
|
||||||
from ahriman.web.views.base import BaseView
|
from ahriman.web.views.base import BaseView
|
||||||
|
|
||||||
@ -33,6 +34,9 @@ class IndexView(BaseView):
|
|||||||
It uses jinja2 templates for report generation, the following variables are allowed:
|
It uses jinja2 templates for report generation, the following variables are allowed:
|
||||||
|
|
||||||
architecture - repository architecture, string, required
|
architecture - repository architecture, string, required
|
||||||
|
authorized - alias for `not auth_enabled or auth_username is not None`
|
||||||
|
auth_enabled - whether authorization is enabled by configuration or not, boolean, required
|
||||||
|
auth_username - authorized user id if any, string. None means not authorized
|
||||||
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
|
||||||
@ -77,8 +81,15 @@ class IndexView(BaseView):
|
|||||||
"timestamp": pretty_datetime(self.service.status.timestamp)
|
"timestamp": pretty_datetime(self.service.status.timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# auth block
|
||||||
|
auth_username = await authorized_userid(self.request)
|
||||||
|
authorized = not self.validator.enabled or auth_username is not None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"architecture": self.service.architecture,
|
"architecture": self.service.architecture,
|
||||||
|
"authorized": authorized,
|
||||||
|
"auth_enabled": self.validator.enabled,
|
||||||
|
"auth_username": auth_username,
|
||||||
"packages": packages,
|
"packages": packages,
|
||||||
"repository": self.service.repository.name,
|
"repository": self.service.repository.name,
|
||||||
"service": service,
|
"service": service,
|
||||||
|
51
src/ahriman/web/views/login.py
Normal file
51
src/ahriman/web/views/login.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
#
|
||||||
|
# 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, HTTPUnauthorized, Response
|
||||||
|
|
||||||
|
from ahriman.core.auth.helpers import remember
|
||||||
|
from ahriman.web.views.base import BaseView
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(BaseView):
|
||||||
|
"""
|
||||||
|
login endpoint view
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def post(self) -> Response:
|
||||||
|
"""
|
||||||
|
login user to service
|
||||||
|
|
||||||
|
either JSON body or form data must be supplied the following fields are required:
|
||||||
|
{
|
||||||
|
"username": "username" # username to use for login
|
||||||
|
"password": "pa55w0rd" # password to use for login
|
||||||
|
}
|
||||||
|
|
||||||
|
:return: redirect to main page
|
||||||
|
"""
|
||||||
|
data = await self.extract_data()
|
||||||
|
username = data.get("username")
|
||||||
|
|
||||||
|
response = HTTPFound("/")
|
||||||
|
if self.validator.check_credentials(username, data.get("password")):
|
||||||
|
await remember(self.request, response, username)
|
||||||
|
return response
|
||||||
|
|
||||||
|
raise HTTPUnauthorized()
|
41
src/ahriman/web/views/logout.py
Normal file
41
src/ahriman/web/views/logout.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
from ahriman.core.auth.helpers import check_authorized, forget
|
||||||
|
from ahriman.web.views.base import BaseView
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutView(BaseView):
|
||||||
|
"""
|
||||||
|
logout endpoint view
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def post(self) -> Response:
|
||||||
|
"""
|
||||||
|
logout user from the service. No parameters supported here
|
||||||
|
:return: redirect to main page
|
||||||
|
"""
|
||||||
|
await check_authorized(self.request)
|
||||||
|
|
||||||
|
response = HTTPFound("/")
|
||||||
|
await forget(self.request, response)
|
||||||
|
|
||||||
|
return response
|
@ -74,7 +74,7 @@ class PackageView(BaseView):
|
|||||||
:return: 204 on success
|
:return: 204 on success
|
||||||
"""
|
"""
|
||||||
base = self.request.match_info["package"]
|
base = self.request.match_info["package"]
|
||||||
data = await self.request.json()
|
data = await self.extract_data()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
package = Package.from_json(data["package"]) if "package" in data else None
|
package = Package.from_json(data["package"]) if "package" in data else None
|
||||||
|
@ -23,6 +23,7 @@ import logging
|
|||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
from ahriman.core.auth.auth import Auth
|
||||||
from ahriman.core.configuration import Configuration
|
from ahriman.core.configuration import Configuration
|
||||||
from ahriman.core.exceptions import InitializeException
|
from ahriman.core.exceptions import InitializeException
|
||||||
from ahriman.core.status.watcher import Watcher
|
from ahriman.core.status.watcher import Watcher
|
||||||
@ -92,4 +93,10 @@ def setup_service(architecture: str, configuration: Configuration) -> web.Applic
|
|||||||
application.logger.info("setup watcher")
|
application.logger.info("setup watcher")
|
||||||
application["watcher"] = Watcher(architecture, configuration)
|
application["watcher"] = Watcher(architecture, configuration)
|
||||||
|
|
||||||
|
application.logger.info("setup authorization")
|
||||||
|
validator = application["validator"] = Auth.load(configuration)
|
||||||
|
if validator.enabled:
|
||||||
|
from ahriman.web.middlewares.auth_handler import setup_auth
|
||||||
|
setup_auth(application, validator)
|
||||||
|
|
||||||
return application
|
return application
|
||||||
|
144
tests/ahriman/application/handlers/test_handler_create_user.py
Normal file
144
tests/ahriman/application/handlers/test_handler_create_user.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import argparse
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from ahriman.application.handlers import CreateUser
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
from ahriman.models.user import User
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
|
||||||
|
|
||||||
|
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
|
||||||
|
"""
|
||||||
|
default arguments for these test cases
|
||||||
|
:param args: command line arguments fixture
|
||||||
|
:return: generated arguments for these test cases
|
||||||
|
"""
|
||||||
|
args.username = "user"
|
||||||
|
args.password = "pa55w0rd"
|
||||||
|
args.role = UserAccess.Status
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must run command
|
||||||
|
"""
|
||||||
|
args = _default_args(args)
|
||||||
|
get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.CreateUser.get_auth_configuration")
|
||||||
|
create_configuration_mock = mocker.patch("ahriman.application.handlers.CreateUser.create_configuration")
|
||||||
|
create_user = mocker.patch("ahriman.application.handlers.CreateUser.create_user")
|
||||||
|
get_salt_mock = mocker.patch("ahriman.application.handlers.CreateUser.get_salt")
|
||||||
|
|
||||||
|
CreateUser.run(args, "x86_64", configuration)
|
||||||
|
get_auth_configuration_mock.assert_called_once()
|
||||||
|
create_configuration_mock.assert_called_once()
|
||||||
|
create_user.assert_called_once()
|
||||||
|
get_salt_mock.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_configuration(configuration: Configuration, user: User, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must correctly create configuration file
|
||||||
|
"""
|
||||||
|
section = Configuration.section_name("auth", user.access.value)
|
||||||
|
mocker.patch("pathlib.Path.open")
|
||||||
|
set_mock = mocker.patch("ahriman.core.configuration.Configuration.set_option")
|
||||||
|
write_mock = mocker.patch("ahriman.core.configuration.Configuration.write")
|
||||||
|
|
||||||
|
CreateUser.create_configuration(configuration, user, "salt")
|
||||||
|
write_mock.assert_called_once()
|
||||||
|
set_mock.assert_has_calls([
|
||||||
|
mock.call("auth", "salt", pytest.helpers.anyvar(str)),
|
||||||
|
mock.call(section, user.username, user.password)
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_configuration_not_loaded(configuration: Configuration, user: User, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must do nothing in case if configuration is not loaded
|
||||||
|
"""
|
||||||
|
configuration.path = None
|
||||||
|
mocker.patch("pathlib.Path.open")
|
||||||
|
write_mock = mocker.patch("ahriman.core.configuration.Configuration.write")
|
||||||
|
|
||||||
|
CreateUser.create_configuration(configuration, user, "salt")
|
||||||
|
write_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_configuration_user_exists(configuration: Configuration, user: User, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must correctly update configuration file if user already exists
|
||||||
|
"""
|
||||||
|
section = Configuration.section_name("auth", user.access.value)
|
||||||
|
configuration.set_option(section, user.username, "")
|
||||||
|
mocker.patch("pathlib.Path.open")
|
||||||
|
mocker.patch("ahriman.core.configuration.Configuration.write")
|
||||||
|
|
||||||
|
CreateUser.create_configuration(configuration, user, "salt")
|
||||||
|
assert configuration.get(section, user.username) == user.password
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user(args: argparse.Namespace, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must create user
|
||||||
|
"""
|
||||||
|
args = _default_args(args)
|
||||||
|
generated = CreateUser.create_user(args, "salt")
|
||||||
|
assert generated.username == user.username
|
||||||
|
assert generated.check_credentials(user.password, "salt")
|
||||||
|
assert generated.access == user.access
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_getpass(args: argparse.Namespace, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must create user and get password from command line
|
||||||
|
"""
|
||||||
|
args = _default_args(args)
|
||||||
|
args.password = None
|
||||||
|
|
||||||
|
getpass_mock = mocker.patch("getpass.getpass", return_value="password")
|
||||||
|
generated = CreateUser.create_user(args, "salt")
|
||||||
|
|
||||||
|
getpass_mock.assert_called_once()
|
||||||
|
assert generated.check_credentials("password", "salt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_salt_read(configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
must read salt from configuration
|
||||||
|
"""
|
||||||
|
assert CreateUser.get_salt(configuration) == "salt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_salt_generate(configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
must generate salt if it does not exist
|
||||||
|
"""
|
||||||
|
configuration.remove_option("auth", "salt")
|
||||||
|
|
||||||
|
salt = CreateUser.get_salt(configuration, 16)
|
||||||
|
assert salt
|
||||||
|
assert len(salt) == 16
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_auth_configuration() -> None:
|
||||||
|
"""
|
||||||
|
must load empty configuration
|
||||||
|
"""
|
||||||
|
assert CreateUser.get_auth_configuration(Path("path"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_auth_configuration_exists(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must load configuration from filesystem
|
||||||
|
"""
|
||||||
|
mocker.patch("pathlib.Path.open")
|
||||||
|
mocker.patch("pathlib.Path.is_file", return_value=True)
|
||||||
|
read_mock = mocker.patch("ahriman.core.configuration.Configuration.read")
|
||||||
|
|
||||||
|
CreateUser.get_auth_configuration(Path("path"))
|
||||||
|
read_mock.assert_called_once()
|
@ -62,19 +62,12 @@ def test_create_ahriman_configuration(args: argparse.Namespace, configuration: C
|
|||||||
"""
|
"""
|
||||||
args = _default_args(args)
|
args = _default_args(args)
|
||||||
mocker.patch("pathlib.Path.open")
|
mocker.patch("pathlib.Path.open")
|
||||||
add_section_mock = mocker.patch("configparser.RawConfigParser.add_section")
|
set_option_mock = mocker.patch("ahriman.core.configuration.Configuration.set_option")
|
||||||
set_mock = mocker.patch("configparser.RawConfigParser.set")
|
write_mock = mocker.patch("ahriman.core.configuration.Configuration.write")
|
||||||
write_mock = mocker.patch("configparser.RawConfigParser.write")
|
|
||||||
|
|
||||||
command = Setup.build_command(args.build_command, "x86_64")
|
command = Setup.build_command(args.build_command, "x86_64")
|
||||||
Setup.create_ahriman_configuration(args, "x86_64", args.repository, configuration.include)
|
Setup.create_ahriman_configuration(args, "x86_64", args.repository, configuration.include)
|
||||||
add_section_mock.assert_has_calls([
|
set_option_mock.assert_has_calls([
|
||||||
mock.call(Configuration.section_name("build", "x86_64")),
|
|
||||||
mock.call("repository"),
|
|
||||||
mock.call(Configuration.section_name("sign", "x86_64")),
|
|
||||||
mock.call(Configuration.section_name("web", "x86_64")),
|
|
||||||
])
|
|
||||||
set_mock.assert_has_calls([
|
|
||||||
mock.call(Configuration.section_name("build", "x86_64"), "build_command", str(command)),
|
mock.call(Configuration.section_name("build", "x86_64"), "build_command", str(command)),
|
||||||
mock.call("repository", "name", args.repository),
|
mock.call("repository", "name", args.repository),
|
||||||
mock.call(Configuration.section_name("sign", "x86_64"), "target",
|
mock.call(Configuration.section_name("sign", "x86_64"), "target",
|
||||||
@ -92,9 +85,9 @@ def test_create_devtools_configuration(args: argparse.Namespace, repository_path
|
|||||||
"""
|
"""
|
||||||
args = _default_args(args)
|
args = _default_args(args)
|
||||||
mocker.patch("pathlib.Path.open")
|
mocker.patch("pathlib.Path.open")
|
||||||
mocker.patch("configparser.RawConfigParser.set")
|
mocker.patch("ahriman.core.configuration.Configuration.set")
|
||||||
add_section_mock = mocker.patch("configparser.RawConfigParser.add_section")
|
add_section_mock = mocker.patch("ahriman.core.configuration.Configuration.add_section")
|
||||||
write_mock = mocker.patch("configparser.RawConfigParser.write")
|
write_mock = mocker.patch("ahriman.core.configuration.Configuration.write")
|
||||||
|
|
||||||
Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration,
|
Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration,
|
||||||
args.no_multilib, args.repository, repository_paths)
|
args.no_multilib, args.repository, repository_paths)
|
||||||
@ -112,13 +105,11 @@ def test_create_devtools_configuration_no_multilib(args: argparse.Namespace, rep
|
|||||||
"""
|
"""
|
||||||
args = _default_args(args)
|
args = _default_args(args)
|
||||||
mocker.patch("pathlib.Path.open")
|
mocker.patch("pathlib.Path.open")
|
||||||
mocker.patch("configparser.RawConfigParser.set")
|
mocker.patch("ahriman.core.configuration.Configuration.set")
|
||||||
add_section_mock = mocker.patch("configparser.RawConfigParser.add_section")
|
write_mock = mocker.patch("ahriman.core.configuration.Configuration.write")
|
||||||
write_mock = mocker.patch("configparser.RawConfigParser.write")
|
|
||||||
|
|
||||||
Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration,
|
Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration,
|
||||||
True, args.repository, repository_paths)
|
True, args.repository, repository_paths)
|
||||||
add_section_mock.assert_called_once()
|
|
||||||
write_mock.assert_called_once()
|
write_mock.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ from pytest_mock import MockerFixture
|
|||||||
from ahriman.application.handlers import Handler
|
from ahriman.application.handlers import Handler
|
||||||
from ahriman.models.build_status import BuildStatusEnum
|
from ahriman.models.build_status import BuildStatusEnum
|
||||||
from ahriman.models.sign_settings import SignSettings
|
from ahriman.models.sign_settings import SignSettings
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
|
||||||
|
|
||||||
def test_parser(parser: argparse.ArgumentParser) -> None:
|
def test_parser(parser: argparse.ArgumentParser) -> None:
|
||||||
@ -83,6 +84,28 @@ def test_subparsers_config(parser: argparse.ArgumentParser) -> None:
|
|||||||
assert args.unsafe
|
assert args.unsafe
|
||||||
|
|
||||||
|
|
||||||
|
def test_subparsers_create_user(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""
|
||||||
|
create-user command must imply architecture, lock, no-log, no-report and unsafe
|
||||||
|
"""
|
||||||
|
args = parser.parse_args(["create-user", "username"])
|
||||||
|
assert args.architecture == [""]
|
||||||
|
assert args.lock is None
|
||||||
|
assert args.no_log
|
||||||
|
assert args.no_report
|
||||||
|
assert args.unsafe
|
||||||
|
|
||||||
|
|
||||||
|
def test_subparsers_create_user_option_role(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""
|
||||||
|
create-user command must convert role option to useraccess instance
|
||||||
|
"""
|
||||||
|
args = parser.parse_args(["create-user", "username"])
|
||||||
|
assert isinstance(args.role, UserAccess)
|
||||||
|
args = parser.parse_args(["create-user", "username", "--role", "write"])
|
||||||
|
assert isinstance(args.role, UserAccess)
|
||||||
|
|
||||||
|
|
||||||
def test_subparsers_init(parser: argparse.ArgumentParser) -> None:
|
def test_subparsers_init(parser: argparse.ArgumentParser) -> None:
|
||||||
"""
|
"""
|
||||||
init command must imply no_report
|
init command must imply no_report
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
from typing import Any, Type, TypeVar
|
from typing import Any, Type, TypeVar
|
||||||
|
|
||||||
|
from ahriman.core.auth.auth import Auth
|
||||||
from ahriman.core.configuration import Configuration
|
from ahriman.core.configuration import Configuration
|
||||||
from ahriman.core.status.watcher import Watcher
|
from ahriman.core.status.watcher import Watcher
|
||||||
from ahriman.models.package import Package
|
from ahriman.models.package import Package
|
||||||
from ahriman.models.package_description import PackageDescription
|
from ahriman.models.package_description import PackageDescription
|
||||||
from ahriman.models.repository_paths import RepositoryPaths
|
from ahriman.models.repository_paths import RepositoryPaths
|
||||||
|
from ahriman.models.user import User
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@ -43,6 +43,15 @@ def anyvar(cls: Type[T], strict: bool = False) -> T:
|
|||||||
|
|
||||||
|
|
||||||
# generic fixtures
|
# generic fixtures
|
||||||
|
@pytest.fixture
|
||||||
|
def auth(configuration: Configuration) -> Auth:
|
||||||
|
"""
|
||||||
|
auth provider fixture
|
||||||
|
:return: auth service instance
|
||||||
|
"""
|
||||||
|
return Auth(configuration)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def configuration(resource_path_root: Path) -> Configuration:
|
def configuration(resource_path_root: Path) -> Configuration:
|
||||||
"""
|
"""
|
||||||
@ -158,6 +167,15 @@ def repository_paths(configuration: Configuration) -> RepositoryPaths:
|
|||||||
root=configuration.getpath("repository", "root"))
|
root=configuration.getpath("repository", "root"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user() -> User:
|
||||||
|
"""
|
||||||
|
fixture for user descriptor
|
||||||
|
:return: user descriptor instance
|
||||||
|
"""
|
||||||
|
return User("user", "pa55w0rd", UserAccess.Status)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def watcher(configuration: Configuration, mocker: MockerFixture) -> Watcher:
|
def watcher(configuration: Configuration, mocker: MockerFixture) -> Watcher:
|
||||||
"""
|
"""
|
||||||
|
13
tests/ahriman/core/auth/conftest.py
Normal file
13
tests/ahriman/core/auth/conftest.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from ahriman.core.auth.mapping_auth import MappingAuth
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mapping_auth(configuration: Configuration) -> MappingAuth:
|
||||||
|
"""
|
||||||
|
auth provider fixture
|
||||||
|
:return: auth service instance
|
||||||
|
"""
|
||||||
|
return MappingAuth(configuration)
|
81
tests/ahriman/core/auth/test_auth.py
Normal file
81
tests/ahriman/core/auth/test_auth.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
from ahriman.core.auth.auth import Auth
|
||||||
|
from ahriman.core.auth.mapping_auth import MappingAuth
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
from ahriman.models.user import User
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_dummy(configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
must load dummy validator if authorization is not enabled
|
||||||
|
"""
|
||||||
|
configuration.set_option("auth", "target", "disabled")
|
||||||
|
auth = Auth.load(configuration)
|
||||||
|
assert isinstance(auth, Auth)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_dummy_empty(configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
must load dummy validator if no option set
|
||||||
|
"""
|
||||||
|
auth = Auth.load(configuration)
|
||||||
|
assert isinstance(auth, Auth)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_mapping(configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
must load mapping validator if option set
|
||||||
|
"""
|
||||||
|
configuration.set_option("auth", "target", "configuration")
|
||||||
|
auth = Auth.load(configuration)
|
||||||
|
assert isinstance(auth, MappingAuth)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_credentials(auth: Auth, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must pass any credentials
|
||||||
|
"""
|
||||||
|
assert auth.check_credentials(user.username, user.password)
|
||||||
|
assert auth.check_credentials(None, "")
|
||||||
|
assert auth.check_credentials("", None)
|
||||||
|
assert auth.check_credentials(None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_safe_request(auth: Auth) -> None:
|
||||||
|
"""
|
||||||
|
must validate safe request
|
||||||
|
"""
|
||||||
|
# login and logout are always safe
|
||||||
|
assert auth.is_safe_request("/login")
|
||||||
|
assert auth.is_safe_request("/logout")
|
||||||
|
|
||||||
|
auth.allowed_paths.add("/safe")
|
||||||
|
auth.allowed_paths_groups.add("/unsafe/safe")
|
||||||
|
|
||||||
|
assert auth.is_safe_request("/safe")
|
||||||
|
assert not auth.is_safe_request("/unsafe")
|
||||||
|
assert auth.is_safe_request("/unsafe/safe")
|
||||||
|
assert auth.is_safe_request("/unsafe/safe/suffix")
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_safe_request_empty(auth: Auth) -> None:
|
||||||
|
"""
|
||||||
|
must not allow requests without path
|
||||||
|
"""
|
||||||
|
assert not auth.is_safe_request(None)
|
||||||
|
assert not auth.is_safe_request("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_username(auth: Auth, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must allow any username
|
||||||
|
"""
|
||||||
|
assert auth.known_username(user.username)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_access(auth: Auth, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must allow any access
|
||||||
|
"""
|
||||||
|
assert auth.verify_access(user.username, user.access, None)
|
||||||
|
assert auth.verify_access(user.username, UserAccess.Write, None)
|
102
tests/ahriman/core/auth/test_helpers.py
Normal file
102
tests/ahriman/core/auth/test_helpers.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import ahriman.core.auth.helpers as helpers
|
||||||
|
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_aiohttp_security() -> None:
|
||||||
|
"""
|
||||||
|
must import aiohttp_security correctly
|
||||||
|
"""
|
||||||
|
assert helpers._has_aiohttp_security
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_aiohttp_security_missing(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must set missing flag if no aiohttp_security module found
|
||||||
|
"""
|
||||||
|
mocker.patch.dict(sys.modules, {"aiohttp_security": None})
|
||||||
|
importlib.reload(helpers)
|
||||||
|
assert not helpers._has_aiohttp_security
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authorized_userid_dummy(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must not call authorized_userid from library if not enabled
|
||||||
|
"""
|
||||||
|
mocker.patch.object(helpers, "_has_aiohttp_security", False)
|
||||||
|
authorized_userid_mock = mocker.patch("aiohttp_security.authorized_userid")
|
||||||
|
await helpers.authorized_userid()
|
||||||
|
authorized_userid_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authorized_userid_library(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must call authorized_userid from library if enabled
|
||||||
|
"""
|
||||||
|
mocker.patch.object(helpers, "_has_aiohttp_security", True)
|
||||||
|
authorized_userid_mock = mocker.patch("aiohttp_security.authorized_userid")
|
||||||
|
await helpers.authorized_userid()
|
||||||
|
authorized_userid_mock.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_authorized_dummy(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must not call check_authorized from library if not enabled
|
||||||
|
"""
|
||||||
|
mocker.patch.object(helpers, "_has_aiohttp_security", False)
|
||||||
|
check_authorized_mock = mocker.patch("aiohttp_security.check_authorized")
|
||||||
|
await helpers.check_authorized()
|
||||||
|
check_authorized_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_authorized_library(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must call check_authorized from library if enabled
|
||||||
|
"""
|
||||||
|
mocker.patch.object(helpers, "_has_aiohttp_security", True)
|
||||||
|
check_authorized_mock = mocker.patch("aiohttp_security.check_authorized")
|
||||||
|
await helpers.check_authorized()
|
||||||
|
check_authorized_mock.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_forget_dummy(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must not call forget from library if not enabled
|
||||||
|
"""
|
||||||
|
mocker.patch.object(helpers, "_has_aiohttp_security", False)
|
||||||
|
forget_mock = mocker.patch("aiohttp_security.forget")
|
||||||
|
await helpers.forget()
|
||||||
|
forget_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_forget_library(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must call forget from library if enabled
|
||||||
|
"""
|
||||||
|
mocker.patch.object(helpers, "_has_aiohttp_security", True)
|
||||||
|
forget_mock = mocker.patch("aiohttp_security.forget")
|
||||||
|
await helpers.forget()
|
||||||
|
forget_mock.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remember_dummy(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must not call remember from library if not enabled
|
||||||
|
"""
|
||||||
|
mocker.patch.object(helpers, "_has_aiohttp_security", False)
|
||||||
|
remember_mock = mocker.patch("aiohttp_security.remember")
|
||||||
|
await helpers.remember()
|
||||||
|
remember_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remember_library(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must call remember from library if enabled
|
||||||
|
"""
|
||||||
|
mocker.patch.object(helpers, "_has_aiohttp_security", True)
|
||||||
|
remember_mock = mocker.patch("aiohttp_security.remember")
|
||||||
|
await helpers.remember()
|
||||||
|
remember_mock.assert_called_once()
|
121
tests/ahriman/core/auth/test_mapping_auth.py
Normal file
121
tests/ahriman/core/auth/test_mapping_auth.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from ahriman.core.auth.mapping_auth import MappingAuth
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
from ahriman.core.exceptions import DuplicateUser
|
||||||
|
from ahriman.models.user import User
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_users(mapping_auth: MappingAuth, configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
must return valid user list
|
||||||
|
"""
|
||||||
|
user_write = User("user_write", "pwd_write", UserAccess.Write)
|
||||||
|
write_section = Configuration.section_name("auth", user_write.access.value)
|
||||||
|
configuration.set_option(write_section, user_write.username, user_write.password)
|
||||||
|
user_read = User("user_read", "pwd_read", UserAccess.Read)
|
||||||
|
read_section = Configuration.section_name("auth", user_read.access.value)
|
||||||
|
configuration.set_option(read_section, user_read.username, user_read.password)
|
||||||
|
user_read = User("user_read", "pwd_read", UserAccess.Read)
|
||||||
|
read_section = Configuration.section_name("auth", user_read.access.value)
|
||||||
|
configuration.set_option(read_section, user_read.username, user_read.password)
|
||||||
|
|
||||||
|
users = mapping_auth.get_users(configuration)
|
||||||
|
expected = {user_write.username: user_write, user_read.username: user_read}
|
||||||
|
assert users == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_users_normalized(mapping_auth: MappingAuth, configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
must return user list with normalized usernames in keys
|
||||||
|
"""
|
||||||
|
user = User("UsEr", "pwd_read", UserAccess.Read)
|
||||||
|
read_section = Configuration.section_name("auth", user.access.value)
|
||||||
|
configuration.set_option(read_section, user.username, user.password)
|
||||||
|
|
||||||
|
users = mapping_auth.get_users(configuration)
|
||||||
|
expected = user.username.lower()
|
||||||
|
assert expected in users
|
||||||
|
assert users[expected].username == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_users_duplicate(mapping_auth: MappingAuth, configuration: Configuration, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must raise exception on duplicate username
|
||||||
|
"""
|
||||||
|
write_section = Configuration.section_name("auth", UserAccess.Write.value)
|
||||||
|
configuration.set_option(write_section, user.username, user.password)
|
||||||
|
read_section = Configuration.section_name("auth", UserAccess.Read.value)
|
||||||
|
configuration.set_option(read_section, user.username, user.password)
|
||||||
|
|
||||||
|
with pytest.raises(DuplicateUser):
|
||||||
|
mapping_auth.get_users(configuration)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_credentials(mapping_auth: MappingAuth, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must return true for valid credentials
|
||||||
|
"""
|
||||||
|
current_password = user.password
|
||||||
|
user.password = user.hash_password(user.password, mapping_auth.salt)
|
||||||
|
mapping_auth._users[user.username] = user
|
||||||
|
assert mapping_auth.check_credentials(user.username, current_password)
|
||||||
|
assert not mapping_auth.check_credentials(user.username, user.password) # here password is hashed so it is invalid
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_credentials_empty(mapping_auth: MappingAuth) -> None:
|
||||||
|
"""
|
||||||
|
must reject on empty credentials
|
||||||
|
"""
|
||||||
|
assert not mapping_auth.check_credentials(None, "")
|
||||||
|
assert not mapping_auth.check_credentials("", None)
|
||||||
|
assert not mapping_auth.check_credentials(None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_credentials_unknown(mapping_auth: MappingAuth, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must reject on unknown user
|
||||||
|
"""
|
||||||
|
assert not mapping_auth.check_credentials(user.username, user.password)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user(mapping_auth: MappingAuth, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must return user from storage by username
|
||||||
|
"""
|
||||||
|
mapping_auth._users[user.username] = user
|
||||||
|
assert mapping_auth.get_user(user.username) == user
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_normalized(mapping_auth: MappingAuth, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must return user from storage by username case-insensitive
|
||||||
|
"""
|
||||||
|
mapping_auth._users[user.username] = user
|
||||||
|
assert mapping_auth.get_user(user.username.upper()) == user
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_unknown(mapping_auth: MappingAuth, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must return None in case if no user found
|
||||||
|
"""
|
||||||
|
assert mapping_auth.get_user(user.username) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_username(mapping_auth: MappingAuth, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must allow only known users
|
||||||
|
"""
|
||||||
|
mapping_auth._users[user.username] = user
|
||||||
|
assert mapping_auth.known_username(user.username)
|
||||||
|
assert not mapping_auth.known_username(user.password)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_access(mapping_auth: MappingAuth, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must verify user access
|
||||||
|
"""
|
||||||
|
mapping_auth._users[user.username] = user
|
||||||
|
assert mapping_auth.verify_access(user.username, user.access, None)
|
||||||
|
assert not mapping_auth.verify_access(user.username, UserAccess.Write, None)
|
@ -2,6 +2,7 @@ import pytest
|
|||||||
|
|
||||||
from ahriman.core.alpm.pacman import Pacman
|
from ahriman.core.alpm.pacman import Pacman
|
||||||
from ahriman.core.alpm.repo import Repo
|
from ahriman.core.alpm.repo import Repo
|
||||||
|
from ahriman.core.auth.auth import Auth
|
||||||
from ahriman.core.build_tools.task import Task
|
from ahriman.core.build_tools.task import Task
|
||||||
from ahriman.core.configuration import Configuration
|
from ahriman.core.configuration import Configuration
|
||||||
from ahriman.core.tree import Leaf
|
from ahriman.core.tree import Leaf
|
||||||
|
@ -23,8 +23,8 @@ def test_send_auth(configuration: Configuration, mocker: MockerFixture) -> None:
|
|||||||
"""
|
"""
|
||||||
must send an email with attachment with auth
|
must send an email with attachment with auth
|
||||||
"""
|
"""
|
||||||
configuration.set("email", "user", "username")
|
configuration.set_option("email", "user", "username")
|
||||||
configuration.set("email", "password", "password")
|
configuration.set_option("email", "password", "password")
|
||||||
smtp_mock = mocker.patch("smtplib.SMTP")
|
smtp_mock = mocker.patch("smtplib.SMTP")
|
||||||
|
|
||||||
report = Email("x86_64", configuration)
|
report = Email("x86_64", configuration)
|
||||||
@ -36,7 +36,7 @@ def test_send_auth_no_password(configuration: Configuration, mocker: MockerFixtu
|
|||||||
"""
|
"""
|
||||||
must send an email with attachment without auth if no password supplied
|
must send an email with attachment without auth if no password supplied
|
||||||
"""
|
"""
|
||||||
configuration.set("email", "user", "username")
|
configuration.set_option("email", "user", "username")
|
||||||
smtp_mock = mocker.patch("smtplib.SMTP")
|
smtp_mock = mocker.patch("smtplib.SMTP")
|
||||||
|
|
||||||
report = Email("x86_64", configuration)
|
report = Email("x86_64", configuration)
|
||||||
@ -48,7 +48,7 @@ def test_send_auth_no_user(configuration: Configuration, mocker: MockerFixture)
|
|||||||
"""
|
"""
|
||||||
must send an email with attachment without auth if no user supplied
|
must send an email with attachment without auth if no user supplied
|
||||||
"""
|
"""
|
||||||
configuration.set("email", "password", "password")
|
configuration.set_option("email", "password", "password")
|
||||||
smtp_mock = mocker.patch("smtplib.SMTP")
|
smtp_mock = mocker.patch("smtplib.SMTP")
|
||||||
|
|
||||||
report = Email("x86_64", configuration)
|
report = Email("x86_64", configuration)
|
||||||
@ -60,7 +60,7 @@ def test_send_ssl_tls(configuration: Configuration, mocker: MockerFixture) -> No
|
|||||||
"""
|
"""
|
||||||
must send an email with attachment with ssl/tls
|
must send an email with attachment with ssl/tls
|
||||||
"""
|
"""
|
||||||
configuration.set("email", "ssl", "ssl")
|
configuration.set_option("email", "ssl", "ssl")
|
||||||
smtp_mock = mocker.patch("smtplib.SMTP_SSL")
|
smtp_mock = mocker.patch("smtplib.SMTP_SSL")
|
||||||
|
|
||||||
report = Email("x86_64", configuration)
|
report = Email("x86_64", configuration)
|
||||||
@ -75,7 +75,7 @@ def test_send_starttls(configuration: Configuration, mocker: MockerFixture) -> N
|
|||||||
"""
|
"""
|
||||||
must send an email with attachment with starttls
|
must send an email with attachment with starttls
|
||||||
"""
|
"""
|
||||||
configuration.set("email", "ssl", "starttls")
|
configuration.set_option("email", "ssl", "starttls")
|
||||||
smtp_mock = mocker.patch("smtplib.SMTP")
|
smtp_mock = mocker.patch("smtplib.SMTP")
|
||||||
|
|
||||||
report = Email("x86_64", configuration)
|
report = Email("x86_64", configuration)
|
||||||
@ -109,7 +109,7 @@ def test_generate_no_empty(configuration: Configuration, package_ahriman: Packag
|
|||||||
"""
|
"""
|
||||||
must not generate report with built packages if no_empty_report is set
|
must not generate report with built packages if no_empty_report is set
|
||||||
"""
|
"""
|
||||||
configuration.set("email", "no_empty_report", "yes")
|
configuration.set_option("email", "no_empty_report", "yes")
|
||||||
send_mock = mocker.patch("ahriman.core.report.email.Email._send")
|
send_mock = mocker.patch("ahriman.core.report.email.Email._send")
|
||||||
|
|
||||||
report = Email("x86_64", configuration)
|
report = Email("x86_64", configuration)
|
||||||
@ -122,7 +122,7 @@ def test_generate_no_empty_with_built(configuration: Configuration, package_ahri
|
|||||||
"""
|
"""
|
||||||
must generate report with built packages if no_empty_report is set
|
must generate report with built packages if no_empty_report is set
|
||||||
"""
|
"""
|
||||||
configuration.set("email", "no_empty_report", "yes")
|
configuration.set_option("email", "no_empty_report", "yes")
|
||||||
send_mock = mocker.patch("ahriman.core.report.email.Email._send")
|
send_mock = mocker.patch("ahriman.core.report.email.Email._send")
|
||||||
|
|
||||||
report = Email("x86_64", configuration)
|
report = Email("x86_64", configuration)
|
||||||
|
@ -2,6 +2,7 @@ import pytest
|
|||||||
|
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
from ahriman.core.status.client import Client
|
from ahriman.core.status.client import Client
|
||||||
from ahriman.core.status.web_client import WebClient
|
from ahriman.core.status.web_client import WebClient
|
||||||
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
||||||
@ -40,9 +41,11 @@ def client() -> Client:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def web_client() -> WebClient:
|
def web_client(configuration: Configuration) -> WebClient:
|
||||||
"""
|
"""
|
||||||
fixture for web client
|
fixture for web client
|
||||||
|
:param configuration: configuration fixture
|
||||||
:return: web client test instance
|
:return: web client test instance
|
||||||
"""
|
"""
|
||||||
return WebClient("localhost", 8080)
|
configuration.set("web", "port", 8080)
|
||||||
|
return WebClient(configuration)
|
||||||
|
@ -17,10 +17,18 @@ def test_load_dummy_client(configuration: Configuration) -> None:
|
|||||||
|
|
||||||
def test_load_full_client(configuration: Configuration) -> None:
|
def test_load_full_client(configuration: Configuration) -> None:
|
||||||
"""
|
"""
|
||||||
must load full client if no settings set
|
must load full client if settings set
|
||||||
"""
|
"""
|
||||||
configuration.set("web", "host", "localhost")
|
configuration.set_option("web", "host", "localhost")
|
||||||
configuration.set("web", "port", "8080")
|
configuration.set_option("web", "port", "8080")
|
||||||
|
assert isinstance(Client.load(configuration), WebClient)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_full_client_from_address(configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
must load full client if settings set
|
||||||
|
"""
|
||||||
|
configuration.set_option("web", "address", "http://localhost:8080")
|
||||||
assert isinstance(Client.load(configuration), WebClient)
|
assert isinstance(Client.load(configuration), WebClient)
|
||||||
|
|
||||||
|
|
||||||
|
@ -5,41 +5,97 @@ import requests
|
|||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
from requests import Response
|
from requests import Response
|
||||||
|
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
from ahriman.core.status.web_client import WebClient
|
from ahriman.core.status.web_client import WebClient
|
||||||
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
||||||
from ahriman.models.internal_status import InternalStatus
|
from ahriman.models.internal_status import InternalStatus
|
||||||
from ahriman.models.package import Package
|
from ahriman.models.package import Package
|
||||||
|
from ahriman.models.user import User
|
||||||
|
|
||||||
|
|
||||||
def test_ahriman_url(web_client: WebClient) -> None:
|
def test_ahriman_url(web_client: WebClient) -> None:
|
||||||
"""
|
"""
|
||||||
must generate service status url correctly
|
must generate service status url correctly
|
||||||
"""
|
"""
|
||||||
assert web_client._ahriman_url().startswith(f"http://{web_client.host}:{web_client.port}")
|
assert web_client._ahriman_url.startswith(web_client.address)
|
||||||
assert web_client._ahriman_url().endswith("/api/v1/ahriman")
|
assert web_client._ahriman_url.endswith("/api/v1/ahriman")
|
||||||
|
|
||||||
|
|
||||||
def test_package_url(web_client: WebClient, package_ahriman: Package) -> None:
|
|
||||||
"""
|
|
||||||
must generate package status correctly
|
|
||||||
"""
|
|
||||||
assert web_client._package_url(package_ahriman.base).startswith(f"http://{web_client.host}:{web_client.port}")
|
|
||||||
assert web_client._package_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_url(web_client: WebClient) -> None:
|
def test_status_url(web_client: WebClient) -> None:
|
||||||
"""
|
"""
|
||||||
must generate service status url correctly
|
must generate service status url correctly
|
||||||
"""
|
"""
|
||||||
assert web_client._status_url().startswith(f"http://{web_client.host}:{web_client.port}")
|
assert web_client._status_url.startswith(web_client.address)
|
||||||
assert web_client._status_url().endswith("/api/v1/status")
|
assert web_client._status_url.endswith("/api/v1/status")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_address(configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
must extract address correctly
|
||||||
|
"""
|
||||||
|
configuration.set_option("web", "host", "localhost")
|
||||||
|
configuration.set_option("web", "port", "8080")
|
||||||
|
assert WebClient.parse_address(configuration) == "http://localhost:8080"
|
||||||
|
|
||||||
|
configuration.set_option("web", "address", "http://localhost:8081")
|
||||||
|
assert WebClient.parse_address(configuration) == "http://localhost:8081"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login(web_client: WebClient, user: User, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must login user
|
||||||
|
"""
|
||||||
|
web_client.user = user
|
||||||
|
requests_mock = mocker.patch("requests.Session.post")
|
||||||
|
payload = {
|
||||||
|
"username": user.username,
|
||||||
|
"password": user.password
|
||||||
|
}
|
||||||
|
|
||||||
|
web_client._login()
|
||||||
|
requests_mock.assert_called_with(pytest.helpers.anyvar(str, True), json=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_failed(web_client: WebClient, user: User, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must suppress any exception happened during login
|
||||||
|
"""
|
||||||
|
web_client.user = user
|
||||||
|
mocker.patch("requests.Session.post", side_effect=Exception())
|
||||||
|
web_client._login()
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_failed_http_error(web_client: WebClient, user: User, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must suppress any exception happened during login
|
||||||
|
"""
|
||||||
|
web_client.user = user
|
||||||
|
mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError())
|
||||||
|
web_client._login()
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_skip(web_client: WebClient, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must skip login if no user set
|
||||||
|
"""
|
||||||
|
requests_mock = mocker.patch("requests.Session.post")
|
||||||
|
web_client._login()
|
||||||
|
requests_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_url(web_client: WebClient, package_ahriman: Package) -> None:
|
||||||
|
"""
|
||||||
|
must generate package status correctly
|
||||||
|
"""
|
||||||
|
assert web_client._package_url(package_ahriman.base).startswith(web_client.address)
|
||||||
|
assert web_client._package_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}")
|
||||||
|
|
||||||
|
|
||||||
def test_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
|
def test_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
|
||||||
"""
|
"""
|
||||||
must process package addition
|
must process package addition
|
||||||
"""
|
"""
|
||||||
requests_mock = mocker.patch("requests.post")
|
requests_mock = mocker.patch("requests.Session.post")
|
||||||
payload = pytest.helpers.get_package_status(package_ahriman)
|
payload = pytest.helpers.get_package_status(package_ahriman)
|
||||||
|
|
||||||
web_client.add(package_ahriman, BuildStatusEnum.Unknown)
|
web_client.add(package_ahriman, BuildStatusEnum.Unknown)
|
||||||
@ -50,7 +106,7 @@ def test_add_failed(web_client: WebClient, package_ahriman: Package, mocker: Moc
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during addition
|
must suppress any exception happened during addition
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.post", side_effect=Exception())
|
mocker.patch("requests.Session.post", side_effect=Exception())
|
||||||
web_client.add(package_ahriman, BuildStatusEnum.Unknown)
|
web_client.add(package_ahriman, BuildStatusEnum.Unknown)
|
||||||
|
|
||||||
|
|
||||||
@ -58,7 +114,7 @@ def test_add_failed_http_error(web_client: WebClient, package_ahriman: Package,
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during addition
|
must suppress any exception happened during addition
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.post", side_effect=requests.exceptions.HTTPError())
|
mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError())
|
||||||
web_client.add(package_ahriman, BuildStatusEnum.Unknown)
|
web_client.add(package_ahriman, BuildStatusEnum.Unknown)
|
||||||
|
|
||||||
|
|
||||||
@ -71,7 +127,7 @@ def test_get_all(web_client: WebClient, package_ahriman: Package, mocker: Mocker
|
|||||||
response_obj._content = json.dumps(response).encode("utf8")
|
response_obj._content = json.dumps(response).encode("utf8")
|
||||||
response_obj.status_code = 200
|
response_obj.status_code = 200
|
||||||
|
|
||||||
requests_mock = mocker.patch("requests.get", return_value=response_obj)
|
requests_mock = mocker.patch("requests.Session.get", return_value=response_obj)
|
||||||
|
|
||||||
result = web_client.get(None)
|
result = web_client.get(None)
|
||||||
requests_mock.assert_called_once()
|
requests_mock.assert_called_once()
|
||||||
@ -83,7 +139,7 @@ def test_get_failed(web_client: WebClient, mocker: MockerFixture) -> None:
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during status getting
|
must suppress any exception happened during status getting
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.get", side_effect=Exception())
|
mocker.patch("requests.Session.get", side_effect=Exception())
|
||||||
assert web_client.get(None) == []
|
assert web_client.get(None) == []
|
||||||
|
|
||||||
|
|
||||||
@ -91,7 +147,7 @@ def test_get_failed_http_error(web_client: WebClient, mocker: MockerFixture) ->
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during status getting
|
must suppress any exception happened during status getting
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.get", side_effect=requests.exceptions.HTTPError())
|
mocker.patch("requests.Session.get", side_effect=requests.exceptions.HTTPError())
|
||||||
assert web_client.get(None) == []
|
assert web_client.get(None) == []
|
||||||
|
|
||||||
|
|
||||||
@ -104,7 +160,7 @@ def test_get_single(web_client: WebClient, package_ahriman: Package, mocker: Moc
|
|||||||
response_obj._content = json.dumps(response).encode("utf8")
|
response_obj._content = json.dumps(response).encode("utf8")
|
||||||
response_obj.status_code = 200
|
response_obj.status_code = 200
|
||||||
|
|
||||||
requests_mock = mocker.patch("requests.get", return_value=response_obj)
|
requests_mock = mocker.patch("requests.Session.get", return_value=response_obj)
|
||||||
|
|
||||||
result = web_client.get(package_ahriman.base)
|
result = web_client.get(package_ahriman.base)
|
||||||
requests_mock.assert_called_once()
|
requests_mock.assert_called_once()
|
||||||
@ -120,7 +176,7 @@ def test_get_internal(web_client: WebClient, mocker: MockerFixture) -> None:
|
|||||||
response_obj._content = json.dumps(InternalStatus(architecture="x86_64").view()).encode("utf8")
|
response_obj._content = json.dumps(InternalStatus(architecture="x86_64").view()).encode("utf8")
|
||||||
response_obj.status_code = 200
|
response_obj.status_code = 200
|
||||||
|
|
||||||
requests_mock = mocker.patch("requests.get", return_value=response_obj)
|
requests_mock = mocker.patch("requests.Session.get", return_value=response_obj)
|
||||||
|
|
||||||
result = web_client.get_internal()
|
result = web_client.get_internal()
|
||||||
requests_mock.assert_called_once()
|
requests_mock.assert_called_once()
|
||||||
@ -131,7 +187,7 @@ def test_get_internal_failed(web_client: WebClient, mocker: MockerFixture) -> No
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during web service status getting
|
must suppress any exception happened during web service status getting
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.get", side_effect=Exception())
|
mocker.patch("requests.Session.get", side_effect=Exception())
|
||||||
assert web_client.get_internal() == InternalStatus()
|
assert web_client.get_internal() == InternalStatus()
|
||||||
|
|
||||||
|
|
||||||
@ -139,7 +195,7 @@ def test_get_internal_failed_http_error(web_client: WebClient, mocker: MockerFix
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during web service status getting
|
must suppress any exception happened during web service status getting
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.get", side_effect=requests.exceptions.HTTPError())
|
mocker.patch("requests.Session.get", side_effect=requests.exceptions.HTTPError())
|
||||||
assert web_client.get_internal() == InternalStatus()
|
assert web_client.get_internal() == InternalStatus()
|
||||||
|
|
||||||
|
|
||||||
@ -151,7 +207,7 @@ def test_get_self(web_client: WebClient, mocker: MockerFixture) -> None:
|
|||||||
response_obj._content = json.dumps(BuildStatus().view()).encode("utf8")
|
response_obj._content = json.dumps(BuildStatus().view()).encode("utf8")
|
||||||
response_obj.status_code = 200
|
response_obj.status_code = 200
|
||||||
|
|
||||||
requests_mock = mocker.patch("requests.get", return_value=response_obj)
|
requests_mock = mocker.patch("requests.Session.get", return_value=response_obj)
|
||||||
|
|
||||||
result = web_client.get_self()
|
result = web_client.get_self()
|
||||||
requests_mock.assert_called_once()
|
requests_mock.assert_called_once()
|
||||||
@ -162,7 +218,7 @@ def test_get_self_failed(web_client: WebClient, mocker: MockerFixture) -> None:
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during service status getting
|
must suppress any exception happened during service status getting
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.get", side_effect=Exception())
|
mocker.patch("requests.Session.get", side_effect=Exception())
|
||||||
assert web_client.get_self().status == BuildStatusEnum.Unknown
|
assert web_client.get_self().status == BuildStatusEnum.Unknown
|
||||||
|
|
||||||
|
|
||||||
@ -170,7 +226,7 @@ def test_get_self_failed_http_error(web_client: WebClient, mocker: MockerFixture
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during service status getting
|
must suppress any exception happened during service status getting
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.get", side_effect=requests.exceptions.HTTPError())
|
mocker.patch("requests.Session.get", side_effect=requests.exceptions.HTTPError())
|
||||||
assert web_client.get_self().status == BuildStatusEnum.Unknown
|
assert web_client.get_self().status == BuildStatusEnum.Unknown
|
||||||
|
|
||||||
|
|
||||||
@ -178,7 +234,7 @@ def test_remove(web_client: WebClient, package_ahriman: Package, mocker: MockerF
|
|||||||
"""
|
"""
|
||||||
must process package removal
|
must process package removal
|
||||||
"""
|
"""
|
||||||
requests_mock = mocker.patch("requests.delete")
|
requests_mock = mocker.patch("requests.Session.delete")
|
||||||
|
|
||||||
web_client.remove(package_ahriman.base)
|
web_client.remove(package_ahriman.base)
|
||||||
requests_mock.assert_called_with(pytest.helpers.anyvar(str, True))
|
requests_mock.assert_called_with(pytest.helpers.anyvar(str, True))
|
||||||
@ -188,7 +244,7 @@ def test_remove_failed(web_client: WebClient, package_ahriman: Package, mocker:
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during removal
|
must suppress any exception happened during removal
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.delete", side_effect=Exception())
|
mocker.patch("requests.Session.delete", side_effect=Exception())
|
||||||
web_client.remove(package_ahriman.base)
|
web_client.remove(package_ahriman.base)
|
||||||
|
|
||||||
|
|
||||||
@ -196,7 +252,7 @@ def test_remove_failed_http_error(web_client: WebClient, package_ahriman: Packag
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during removal
|
must suppress any exception happened during removal
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.delete", side_effect=requests.exceptions.HTTPError())
|
mocker.patch("requests.Session.delete", side_effect=requests.exceptions.HTTPError())
|
||||||
web_client.remove(package_ahriman.base)
|
web_client.remove(package_ahriman.base)
|
||||||
|
|
||||||
|
|
||||||
@ -204,7 +260,7 @@ def test_update(web_client: WebClient, package_ahriman: Package, mocker: MockerF
|
|||||||
"""
|
"""
|
||||||
must process package update
|
must process package update
|
||||||
"""
|
"""
|
||||||
requests_mock = mocker.patch("requests.post")
|
requests_mock = mocker.patch("requests.Session.post")
|
||||||
|
|
||||||
web_client.update(package_ahriman.base, BuildStatusEnum.Unknown)
|
web_client.update(package_ahriman.base, BuildStatusEnum.Unknown)
|
||||||
requests_mock.assert_called_with(pytest.helpers.anyvar(str, True), json={"status": BuildStatusEnum.Unknown.value})
|
requests_mock.assert_called_with(pytest.helpers.anyvar(str, True), json={"status": BuildStatusEnum.Unknown.value})
|
||||||
@ -214,7 +270,7 @@ def test_update_failed(web_client: WebClient, package_ahriman: Package, mocker:
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during update
|
must suppress any exception happened during update
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.post", side_effect=Exception())
|
mocker.patch("requests.Session.post", side_effect=Exception())
|
||||||
web_client.update(package_ahriman.base, BuildStatusEnum.Unknown)
|
web_client.update(package_ahriman.base, BuildStatusEnum.Unknown)
|
||||||
|
|
||||||
|
|
||||||
@ -222,7 +278,7 @@ def test_update_failed_http_error(web_client: WebClient, package_ahriman: Packag
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during update
|
must suppress any exception happened during update
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.post", side_effect=requests.exceptions.HTTPError())
|
mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError())
|
||||||
web_client.update(package_ahriman.base, BuildStatusEnum.Unknown)
|
web_client.update(package_ahriman.base, BuildStatusEnum.Unknown)
|
||||||
|
|
||||||
|
|
||||||
@ -230,7 +286,7 @@ def test_update_self(web_client: WebClient, mocker: MockerFixture) -> None:
|
|||||||
"""
|
"""
|
||||||
must process service update
|
must process service update
|
||||||
"""
|
"""
|
||||||
requests_mock = mocker.patch("requests.post")
|
requests_mock = mocker.patch("requests.Session.post")
|
||||||
|
|
||||||
web_client.update_self(BuildStatusEnum.Unknown)
|
web_client.update_self(BuildStatusEnum.Unknown)
|
||||||
requests_mock.assert_called_with(pytest.helpers.anyvar(str, True), json={"status": BuildStatusEnum.Unknown.value})
|
requests_mock.assert_called_with(pytest.helpers.anyvar(str, True), json={"status": BuildStatusEnum.Unknown.value})
|
||||||
@ -240,7 +296,7 @@ def test_update_self_failed(web_client: WebClient, mocker: MockerFixture) -> Non
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during service update
|
must suppress any exception happened during service update
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.post", side_effect=Exception())
|
mocker.patch("requests.Session.post", side_effect=Exception())
|
||||||
web_client.update_self(BuildStatusEnum.Unknown)
|
web_client.update_self(BuildStatusEnum.Unknown)
|
||||||
|
|
||||||
|
|
||||||
@ -248,5 +304,5 @@ def test_update_self_failed_http_error(web_client: WebClient, mocker: MockerFixt
|
|||||||
"""
|
"""
|
||||||
must suppress any exception happened during service update
|
must suppress any exception happened during service update
|
||||||
"""
|
"""
|
||||||
mocker.patch("requests.post", side_effect=requests.exceptions.HTTPError())
|
mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError())
|
||||||
web_client.update_self(BuildStatusEnum.Unknown)
|
web_client.update_self(BuildStatusEnum.Unknown)
|
||||||
|
@ -9,7 +9,7 @@ def test_from_path(mocker: MockerFixture) -> None:
|
|||||||
"""
|
"""
|
||||||
must load configuration
|
must load configuration
|
||||||
"""
|
"""
|
||||||
read_mock = mocker.patch("configparser.RawConfigParser.read")
|
read_mock = mocker.patch("ahriman.core.configuration.Configuration.read")
|
||||||
load_includes_mock = mocker.patch("ahriman.core.configuration.Configuration.load_includes")
|
load_includes_mock = mocker.patch("ahriman.core.configuration.Configuration.load_includes")
|
||||||
load_logging_mock = mocker.patch("ahriman.core.configuration.Configuration.load_logging")
|
load_logging_mock = mocker.patch("ahriman.core.configuration.Configuration.load_logging")
|
||||||
path = Path("path")
|
path = Path("path")
|
||||||
@ -33,7 +33,7 @@ def test_absolute_path_for_absolute(configuration: Configuration) -> None:
|
|||||||
must not change path for absolute path in settings
|
must not change path for absolute path in settings
|
||||||
"""
|
"""
|
||||||
path = Path("/a/b/c")
|
path = Path("/a/b/c")
|
||||||
configuration.set("build", "path", str(path))
|
configuration.set_option("build", "path", str(path))
|
||||||
assert configuration.getpath("build", "path") == path
|
assert configuration.getpath("build", "path") == path
|
||||||
|
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ def test_absolute_path_for_relative(configuration: Configuration) -> None:
|
|||||||
must prepend root path to relative path
|
must prepend root path to relative path
|
||||||
"""
|
"""
|
||||||
path = Path("a")
|
path = Path("a")
|
||||||
configuration.set("build", "path", str(path))
|
configuration.set_option("build", "path", str(path))
|
||||||
result = configuration.getpath("build", "path")
|
result = configuration.getpath("build", "path")
|
||||||
assert result.is_absolute()
|
assert result.is_absolute()
|
||||||
assert result.parent == configuration.path.parent
|
assert result.parent == configuration.path.parent
|
||||||
@ -61,8 +61,7 @@ def test_dump_architecture_specific(configuration: Configuration) -> None:
|
|||||||
dump must contain architecture specific settings
|
dump must contain architecture specific settings
|
||||||
"""
|
"""
|
||||||
section = configuration.section_name("build", "x86_64")
|
section = configuration.section_name("build", "x86_64")
|
||||||
configuration.add_section(section)
|
configuration.set_option(section, "archbuild_flags", "hello flag")
|
||||||
configuration.set(section, "archbuild_flags", "hello flag")
|
|
||||||
configuration.merge_sections("x86_64")
|
configuration.merge_sections("x86_64")
|
||||||
|
|
||||||
dump = configuration.dump()
|
dump = configuration.dump()
|
||||||
@ -76,7 +75,7 @@ def test_getlist(configuration: Configuration) -> None:
|
|||||||
"""
|
"""
|
||||||
must return list of string correctly
|
must return list of string correctly
|
||||||
"""
|
"""
|
||||||
configuration.set("build", "test_list", "a b c")
|
configuration.set_option("build", "test_list", "a b c")
|
||||||
assert configuration.getlist("build", "test_list") == ["a", "b", "c"]
|
assert configuration.getlist("build", "test_list") == ["a", "b", "c"]
|
||||||
|
|
||||||
|
|
||||||
@ -85,7 +84,7 @@ def test_getlist_empty(configuration: Configuration) -> None:
|
|||||||
must return list of string correctly for non-existing option
|
must return list of string correctly for non-existing option
|
||||||
"""
|
"""
|
||||||
assert configuration.getlist("build", "test_list") == []
|
assert configuration.getlist("build", "test_list") == []
|
||||||
configuration.set("build", "test_list", "")
|
configuration.set_option("build", "test_list", "")
|
||||||
assert configuration.getlist("build", "test_list") == []
|
assert configuration.getlist("build", "test_list") == []
|
||||||
|
|
||||||
|
|
||||||
@ -93,7 +92,7 @@ def test_getlist_single(configuration: Configuration) -> None:
|
|||||||
"""
|
"""
|
||||||
must return list of strings for single string
|
must return list of strings for single string
|
||||||
"""
|
"""
|
||||||
configuration.set("build", "test_list", "a")
|
configuration.set_option("build", "test_list", "a")
|
||||||
assert configuration.getlist("build", "test_list") == ["a"]
|
assert configuration.getlist("build", "test_list") == ["a"]
|
||||||
|
|
||||||
|
|
||||||
@ -101,7 +100,7 @@ def test_load_includes_missing(configuration: Configuration) -> None:
|
|||||||
"""
|
"""
|
||||||
must not fail if not include directory found
|
must not fail if not include directory found
|
||||||
"""
|
"""
|
||||||
configuration.set("settings", "include", "path")
|
configuration.set_option("settings", "include", "path")
|
||||||
configuration.load_includes()
|
configuration.load_includes()
|
||||||
|
|
||||||
|
|
||||||
@ -144,8 +143,23 @@ def test_merge_sections_missing(configuration: Configuration) -> None:
|
|||||||
"""
|
"""
|
||||||
section = configuration.section_name("build", "x86_64")
|
section = configuration.section_name("build", "x86_64")
|
||||||
configuration.remove_section("build")
|
configuration.remove_section("build")
|
||||||
configuration.add_section(section)
|
configuration.set_option(section, "key", "value")
|
||||||
configuration.set(section, "key", "value")
|
|
||||||
|
|
||||||
configuration.merge_sections("x86_64")
|
configuration.merge_sections("x86_64")
|
||||||
assert configuration.get("build", "key") == "value"
|
assert configuration.get("build", "key") == "value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_option(configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
must set option correctly
|
||||||
|
"""
|
||||||
|
configuration.set_option("settings", "option", "value")
|
||||||
|
assert configuration.get("settings", "option") == "value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_option_new_section(configuration: Configuration) -> None:
|
||||||
|
"""
|
||||||
|
must set option correctly even if no section found
|
||||||
|
"""
|
||||||
|
configuration.set_option("section", "option", "value")
|
||||||
|
assert configuration.get("section", "option") == "value"
|
||||||
|
@ -61,6 +61,8 @@ def test_get_local_files(s3: S3, resource_path_root: Path) -> None:
|
|||||||
Path("models/package_yay_srcinfo"),
|
Path("models/package_yay_srcinfo"),
|
||||||
Path("web/templates/search-line.jinja2"),
|
Path("web/templates/search-line.jinja2"),
|
||||||
Path("web/templates/build-status.jinja2"),
|
Path("web/templates/build-status.jinja2"),
|
||||||
|
Path("web/templates/login-form.jinja2"),
|
||||||
|
Path("web/templates/login-form-hide.jinja2"),
|
||||||
Path("web/templates/repo-index.jinja2"),
|
Path("web/templates/repo-index.jinja2"),
|
||||||
Path("web/templates/sorttable.jinja2"),
|
Path("web/templates/sorttable.jinja2"),
|
||||||
Path("web/templates/style.jinja2"),
|
Path("web/templates/style.jinja2"),
|
||||||
|
36
tests/ahriman/models/test_auth_settings.py
Normal file
36
tests/ahriman/models/test_auth_settings.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from ahriman.core.exceptions import InvalidOption
|
||||||
|
from ahriman.models.auth_settings import AuthSettings
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_option_invalid() -> None:
|
||||||
|
"""
|
||||||
|
must raise exception on invalid option
|
||||||
|
"""
|
||||||
|
with pytest.raises(InvalidOption, match=".* `invalid`$"):
|
||||||
|
AuthSettings.from_option("invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_option_valid() -> None:
|
||||||
|
"""
|
||||||
|
must return value from valid options
|
||||||
|
"""
|
||||||
|
assert AuthSettings.from_option("disabled") == AuthSettings.Disabled
|
||||||
|
assert AuthSettings.from_option("DISABLED") == AuthSettings.Disabled
|
||||||
|
assert AuthSettings.from_option("no") == AuthSettings.Disabled
|
||||||
|
assert AuthSettings.from_option("NO") == AuthSettings.Disabled
|
||||||
|
|
||||||
|
assert AuthSettings.from_option("configuration") == AuthSettings.Configuration
|
||||||
|
assert AuthSettings.from_option("ConFigUration") == AuthSettings.Configuration
|
||||||
|
assert AuthSettings.from_option("mapping") == AuthSettings.Configuration
|
||||||
|
assert AuthSettings.from_option("MAPPing") == AuthSettings.Configuration
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_enabled() -> None:
|
||||||
|
"""
|
||||||
|
must mark as disabled authorization for disabled and enabled otherwise
|
||||||
|
"""
|
||||||
|
assert not AuthSettings.Disabled.is_enabled
|
||||||
|
for option in filter(lambda o: o != AuthSettings.Disabled, AuthSettings):
|
||||||
|
assert option.is_enabled
|
75
tests/ahriman/models/test_user.py
Normal file
75
tests/ahriman/models/test_user.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
from ahriman.models.user import User
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_option(user: User) -> None:
|
||||||
|
"""
|
||||||
|
must generate user from options
|
||||||
|
"""
|
||||||
|
assert User.from_option(user.username, user.password) == user
|
||||||
|
# default is status access
|
||||||
|
user.access = UserAccess.Write
|
||||||
|
assert User.from_option(user.username, user.password) != user
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_option_empty() -> None:
|
||||||
|
"""
|
||||||
|
must return nothing if settings are missed
|
||||||
|
"""
|
||||||
|
assert User.from_option(None, "") is None
|
||||||
|
assert User.from_option("", None) is None
|
||||||
|
assert User.from_option(None, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_credentials_hash_password(user: User) -> None:
|
||||||
|
"""
|
||||||
|
must generate and validate user password
|
||||||
|
"""
|
||||||
|
current_password = user.password
|
||||||
|
user.password = user.hash_password(current_password, "salt")
|
||||||
|
assert user.check_credentials(current_password, "salt")
|
||||||
|
assert not user.check_credentials(current_password, "salt1")
|
||||||
|
assert not user.check_credentials(user.password, "salt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_password() -> None:
|
||||||
|
"""
|
||||||
|
must generate password with specified length
|
||||||
|
"""
|
||||||
|
password = User.generate_password(16)
|
||||||
|
assert password
|
||||||
|
assert len(password) == 16
|
||||||
|
|
||||||
|
password = User.generate_password(42)
|
||||||
|
assert password
|
||||||
|
assert len(password) == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_access_read(user: User) -> None:
|
||||||
|
"""
|
||||||
|
user with read access must be able to only request read
|
||||||
|
"""
|
||||||
|
user.access = UserAccess.Read
|
||||||
|
assert user.verify_access(UserAccess.Read)
|
||||||
|
assert not user.verify_access(UserAccess.Write)
|
||||||
|
assert not user.verify_access(UserAccess.Status)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_access_status(user: User) -> None:
|
||||||
|
"""
|
||||||
|
user with status access must be able to only request status
|
||||||
|
"""
|
||||||
|
user.access = UserAccess.Status
|
||||||
|
assert not user.verify_access(UserAccess.Read)
|
||||||
|
assert not user.verify_access(UserAccess.Write)
|
||||||
|
assert user.verify_access(UserAccess.Status)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_access_write(user: User) -> None:
|
||||||
|
"""
|
||||||
|
user with write access must be able to do anything
|
||||||
|
"""
|
||||||
|
user.access = UserAccess.Write
|
||||||
|
assert user.verify_access(UserAccess.Read)
|
||||||
|
assert user.verify_access(UserAccess.Write)
|
||||||
|
assert user.verify_access(UserAccess.Status)
|
0
tests/ahriman/models/test_user_access.py
Normal file
0
tests/ahriman/models/test_user_access.py
Normal file
@ -3,7 +3,10 @@ import pytest
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
import ahriman.core.auth.helpers
|
||||||
|
|
||||||
from ahriman.core.configuration import Configuration
|
from ahriman.core.configuration import Configuration
|
||||||
|
from ahriman.models.user import User
|
||||||
from ahriman.web.web import setup_service
|
from ahriman.web.web import setup_service
|
||||||
|
|
||||||
|
|
||||||
@ -15,5 +18,26 @@ def application(configuration: Configuration, mocker: MockerFixture) -> web.Appl
|
|||||||
:param mocker: mocker object
|
:param mocker: mocker object
|
||||||
:return: application test instance
|
:return: application test instance
|
||||||
"""
|
"""
|
||||||
|
mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", False)
|
||||||
mocker.patch("pathlib.Path.mkdir")
|
mocker.patch("pathlib.Path.mkdir")
|
||||||
return setup_service("x86_64", configuration)
|
return setup_service("x86_64", configuration)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def application_with_auth(configuration: Configuration, user: User, mocker: MockerFixture) -> web.Application:
|
||||||
|
"""
|
||||||
|
application fixture with auth enabled
|
||||||
|
:param configuration: configuration fixture
|
||||||
|
:param user: user descriptor fixture
|
||||||
|
:param mocker: mocker object
|
||||||
|
:return: application test instance
|
||||||
|
"""
|
||||||
|
configuration.set_option("auth", "target", "configuration")
|
||||||
|
mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", True)
|
||||||
|
mocker.patch("pathlib.Path.mkdir")
|
||||||
|
application = setup_service("x86_64", configuration)
|
||||||
|
|
||||||
|
generated = User(user.username, user.hash_password(user.password, application["validator"].salt), user.access)
|
||||||
|
application["validator"]._users[generated.username] = generated
|
||||||
|
|
||||||
|
return application
|
||||||
|
@ -2,8 +2,12 @@ import pytest
|
|||||||
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
|
from ahriman.core.auth.auth import Auth
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
from ahriman.models.user import User
|
||||||
|
from ahriman.web.middlewares.auth_handler import AuthorizationPolicy
|
||||||
|
|
||||||
_request = namedtuple("_request", ["path"])
|
_request = namedtuple("_request", ["path", "method"])
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@ -12,4 +16,17 @@ def aiohttp_request() -> _request:
|
|||||||
fixture for aiohttp like object
|
fixture for aiohttp like object
|
||||||
:return: aiohttp like request test instance
|
:return: aiohttp like request test instance
|
||||||
"""
|
"""
|
||||||
return _request("path")
|
return _request("path", "GET")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authorization_policy(configuration: Configuration, user: User) -> AuthorizationPolicy:
|
||||||
|
"""
|
||||||
|
fixture for authorization policy
|
||||||
|
:return: authorization policy fixture
|
||||||
|
"""
|
||||||
|
configuration.set_option("auth", "target", "configuration")
|
||||||
|
validator = Auth.load(configuration)
|
||||||
|
policy = AuthorizationPolicy(validator)
|
||||||
|
policy.validator._users = {user.username: user}
|
||||||
|
return policy
|
||||||
|
100
tests/ahriman/web/middlewares/test_auth_handler.py
Normal file
100
tests/ahriman/web/middlewares/test_auth_handler.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
from aiohttp import web
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from ahriman.core.auth.auth import Auth
|
||||||
|
from ahriman.core.configuration import Configuration
|
||||||
|
from ahriman.models.user import User
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
from ahriman.web.middlewares.auth_handler import auth_handler, AuthorizationPolicy, setup_auth
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authorized_userid(authorization_policy: AuthorizationPolicy, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must return authorized user id
|
||||||
|
"""
|
||||||
|
assert await authorization_policy.authorized_userid(user.username) == user.username
|
||||||
|
assert await authorization_policy.authorized_userid("some random name") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_permits(authorization_policy: AuthorizationPolicy, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must call validator check
|
||||||
|
"""
|
||||||
|
authorization_policy.validator = MagicMock()
|
||||||
|
authorization_policy.validator.verify_access.return_value = True
|
||||||
|
|
||||||
|
assert await authorization_policy.permits(user.username, user.access, "/endpoint")
|
||||||
|
authorization_policy.validator.verify_access.assert_called_with(user.username, user.access, "/endpoint")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auth_handler_api(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must ask for status permission for api calls
|
||||||
|
"""
|
||||||
|
aiohttp_request = aiohttp_request._replace(path="/api")
|
||||||
|
request_handler = AsyncMock()
|
||||||
|
mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False)
|
||||||
|
check_permission_mock = mocker.patch("aiohttp_security.check_permission")
|
||||||
|
|
||||||
|
handler = auth_handler(auth)
|
||||||
|
await handler(aiohttp_request, request_handler)
|
||||||
|
check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Status, aiohttp_request.path)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auth_handler_api_post(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must ask for status permission for api calls with POST
|
||||||
|
"""
|
||||||
|
aiohttp_request = aiohttp_request._replace(path="/api", method="POST")
|
||||||
|
request_handler = AsyncMock()
|
||||||
|
mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False)
|
||||||
|
check_permission_mock = mocker.patch("aiohttp_security.check_permission")
|
||||||
|
|
||||||
|
handler = auth_handler(auth)
|
||||||
|
await handler(aiohttp_request, request_handler)
|
||||||
|
check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Status, aiohttp_request.path)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auth_handler_read(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must ask for read permission for api calls with GET
|
||||||
|
"""
|
||||||
|
for method in ("GET", "HEAD", "OPTIONS"):
|
||||||
|
aiohttp_request = aiohttp_request._replace(method=method)
|
||||||
|
request_handler = AsyncMock()
|
||||||
|
mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False)
|
||||||
|
check_permission_mock = mocker.patch("aiohttp_security.check_permission")
|
||||||
|
|
||||||
|
handler = auth_handler(auth)
|
||||||
|
await handler(aiohttp_request, request_handler)
|
||||||
|
check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Read, aiohttp_request.path)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auth_handler_write(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must ask for read permission for api calls with POST
|
||||||
|
"""
|
||||||
|
for method in ("CONNECT", "DELETE", "PATCH", "POST", "PUT", "TRACE"):
|
||||||
|
aiohttp_request = aiohttp_request._replace(method=method)
|
||||||
|
request_handler = AsyncMock()
|
||||||
|
mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False)
|
||||||
|
check_permission_mock = mocker.patch("aiohttp_security.check_permission")
|
||||||
|
|
||||||
|
handler = auth_handler(auth)
|
||||||
|
await handler(aiohttp_request, request_handler)
|
||||||
|
check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Write, aiohttp_request.path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_auth(
|
||||||
|
application_with_auth: web.Application,
|
||||||
|
configuration: Configuration,
|
||||||
|
mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must setup authorization
|
||||||
|
"""
|
||||||
|
aiohttp_security_setup_mock = mocker.patch("aiohttp_security.setup")
|
||||||
|
application = setup_auth(application_with_auth, configuration)
|
||||||
|
assert application.get("validator") is not None
|
||||||
|
aiohttp_security_setup_mock.assert_called_once()
|
@ -35,9 +35,22 @@ def test_run(application: web.Application, mocker: MockerFixture) -> None:
|
|||||||
must run application
|
must run application
|
||||||
"""
|
"""
|
||||||
port = 8080
|
port = 8080
|
||||||
application["configuration"].set("web", "port", str(port))
|
application["configuration"].set_option("web", "port", str(port))
|
||||||
run_application_mock = mocker.patch("aiohttp.web.run_app")
|
run_application_mock = mocker.patch("aiohttp.web.run_app")
|
||||||
|
|
||||||
run_server(application)
|
run_server(application)
|
||||||
run_application_mock.assert_called_with(application, host="127.0.0.1", port=port,
|
run_application_mock.assert_called_with(application, 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_auth(application_with_auth: web.Application, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must run application
|
||||||
|
"""
|
||||||
|
port = 8080
|
||||||
|
application_with_auth["configuration"].set_option("web", "port", str(port))
|
||||||
|
run_application_mock = mocker.patch("aiohttp.web.run_app")
|
||||||
|
|
||||||
|
run_server(application_with_auth)
|
||||||
|
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))
|
||||||
|
@ -20,3 +20,18 @@ def client(application: web.Application, loop: BaseEventLoop,
|
|||||||
"""
|
"""
|
||||||
mocker.patch("pathlib.Path.iterdir", return_value=[])
|
mocker.patch("pathlib.Path.iterdir", return_value=[])
|
||||||
return loop.run_until_complete(aiohttp_client(application))
|
return loop.run_until_complete(aiohttp_client(application))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_with_auth(application_with_auth: web.Application, loop: BaseEventLoop,
|
||||||
|
aiohttp_client: Any, mocker: MockerFixture) -> TestClient:
|
||||||
|
"""
|
||||||
|
web client fixture with full authorization functions
|
||||||
|
:param application_with_auth: application fixture
|
||||||
|
:param loop: context event loop
|
||||||
|
:param aiohttp_client: aiohttp client fixture
|
||||||
|
:param mocker: mocker object
|
||||||
|
:return: web client test instance
|
||||||
|
"""
|
||||||
|
mocker.patch("pathlib.Path.iterdir", return_value=[])
|
||||||
|
return loop.run_until_complete(aiohttp_client(application_with_auth))
|
||||||
|
@ -1,19 +1,28 @@
|
|||||||
from pytest_aiohttp import TestClient
|
from pytest_aiohttp import TestClient
|
||||||
|
|
||||||
|
|
||||||
async def test_get(client: TestClient) -> None:
|
async def test_get(client_with_auth: TestClient) -> None:
|
||||||
"""
|
"""
|
||||||
must generate status page correctly (/)
|
must generate status page correctly (/)
|
||||||
"""
|
"""
|
||||||
|
response = await client_with_auth.get("/")
|
||||||
|
assert response.status == 200
|
||||||
|
assert await response.text()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_index(client_with_auth: TestClient) -> None:
|
||||||
|
"""
|
||||||
|
must generate status page correctly (/index.html)
|
||||||
|
"""
|
||||||
|
response = await client_with_auth.get("/index.html")
|
||||||
|
assert response.status == 200
|
||||||
|
assert await response.text()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_without_auth(client: TestClient) -> None:
|
||||||
|
"""
|
||||||
|
must use dummy authorized_userid function in case if no security library installed
|
||||||
|
"""
|
||||||
response = await client.get("/")
|
response = await client.get("/")
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert await response.text()
|
assert await response.text()
|
||||||
|
|
||||||
|
|
||||||
async def test_get_index(client: TestClient) -> None:
|
|
||||||
"""
|
|
||||||
must generate status page correctly (/index.html)
|
|
||||||
"""
|
|
||||||
response = await client.get("/index.html")
|
|
||||||
assert response.status == 200
|
|
||||||
assert await response.text()
|
|
||||||
|
41
tests/ahriman/web/views/test_view_login.py
Normal file
41
tests/ahriman/web/views/test_view_login.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from aiohttp.test_utils import TestClient
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
from ahriman.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post(client_with_auth: TestClient, user: User, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must login user correctly
|
||||||
|
"""
|
||||||
|
payload = {"username": user.username, "password": user.password}
|
||||||
|
remember_mock = mocker.patch("aiohttp_security.remember")
|
||||||
|
|
||||||
|
post_response = await client_with_auth.post("/login", json=payload)
|
||||||
|
assert post_response.status == 200
|
||||||
|
|
||||||
|
post_response = await client_with_auth.post("/login", data=payload)
|
||||||
|
assert post_response.status == 200
|
||||||
|
|
||||||
|
remember_mock.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_skip(client: TestClient, user: User) -> None:
|
||||||
|
"""
|
||||||
|
must process if no auth configured
|
||||||
|
"""
|
||||||
|
payload = {"username": user.username, "password": user.password}
|
||||||
|
post_response = await client.post("/login", json=payload)
|
||||||
|
assert post_response.status == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_unauthorized(client_with_auth: TestClient, user: User, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must return unauthorized on invalid auth
|
||||||
|
"""
|
||||||
|
payload = {"username": user.username, "password": ""}
|
||||||
|
remember_mock = mocker.patch("aiohttp_security.remember")
|
||||||
|
|
||||||
|
post_response = await client_with_auth.post("/login", json=payload)
|
||||||
|
assert post_response.status == 401
|
||||||
|
remember_mock.assert_not_called()
|
35
tests/ahriman/web/views/test_view_logout.py
Normal file
35
tests/ahriman/web/views/test_view_logout.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from aiohttp.test_utils import TestClient
|
||||||
|
from aiohttp.web import HTTPUnauthorized
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must logout user correctly
|
||||||
|
"""
|
||||||
|
mocker.patch("aiohttp_security.check_authorized")
|
||||||
|
forget_mock = mocker.patch("aiohttp_security.forget")
|
||||||
|
|
||||||
|
post_response = await client_with_auth.post("/logout")
|
||||||
|
assert post_response.status == 200
|
||||||
|
forget_mock.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_unauthorized(client_with_auth: TestClient, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must raise exception if unauthorized
|
||||||
|
"""
|
||||||
|
mocker.patch("aiohttp_security.check_authorized", side_effect=HTTPUnauthorized())
|
||||||
|
forget_mock = mocker.patch("aiohttp_security.forget")
|
||||||
|
|
||||||
|
post_response = await client_with_auth.post("/logout")
|
||||||
|
assert post_response.status == 401
|
||||||
|
forget_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_disabled(client: TestClient) -> None:
|
||||||
|
"""
|
||||||
|
must raise exception if auth is disabled
|
||||||
|
"""
|
||||||
|
post_response = await client.post("/logout")
|
||||||
|
assert post_response.status == 200
|
@ -8,6 +8,9 @@ database = /var/lib/pacman
|
|||||||
repositories = core extra community multilib
|
repositories = core extra community multilib
|
||||||
root = /
|
root = /
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
salt = salt
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
archbuild_flags =
|
archbuild_flags =
|
||||||
build_command = extra-x86_64-build
|
build_command = extra-x86_64-build
|
||||||
|
Loading…
Reference in New Issue
Block a user