dynamic html load (#63)

* dynamic html load
* split by classes
This commit is contained in:
Evgenii Alekseev 2022-05-20 22:29:36 +03:00 committed by GitHub
parent 375f9fcfb7
commit b1dfafe275
72 changed files with 720 additions and 706 deletions

View File

@ -3,7 +3,7 @@
ahriman ahriman
.SH SYNOPSIS .SH SYNOPSIS
.B ahriman .B ahriman
[-h] [-a ARCHITECTURE] [-c CONFIGURATION] [--force] [-l LOCK] [--no-report] [-q] [--unsafe] [-v] {aur-search,search,help,help-commands-unsafe,key-import,package-add,add,package-update,package-remove,remove,package-status,status,package-status-remove,package-status-update,status-update,patch-add,patch-list,patch-remove,repo-backup,repo-check,check,repo-clean,clean,repo-config,config,repo-rebuild,rebuild,repo-remove-unknown,remove-unknown,repo-restore,repo-setup,init,repo-init,setup,repo-sign,sign,repo-status-update,repo-triggers,repo-update,update,user-add,user-list,user-remove,web} ... [-h] [-a ARCHITECTURE] [-c CONFIGURATION] [--force] [-l LOCK] [--no-report] [-q] [--unsafe] [-v] {aur-search,search,help,help-commands-unsafe,key-import,package-add,add,package-update,package-remove,remove,package-status,status,package-status-remove,package-status-update,status-update,patch-add,patch-list,patch-remove,repo-backup,repo-check,check,repo-clean,clean,repo-config,config,repo-rebuild,rebuild,repo-remove-unknown,remove-unknown,repo-report,report,repo-restore,repo-setup,init,repo-init,setup,repo-sign,sign,repo-status-update,repo-sync,sync,repo-triggers,repo-update,update,user-add,user-list,user-remove,web} ...
.SH DESCRIPTION .SH DESCRIPTION
ArcH Linux ReposItory MANager ArcH Linux ReposItory MANager
@ -97,6 +97,9 @@ rebuild repository
\fBahriman\fR \fI\,repo-remove-unknown\/\fR \fBahriman\fR \fI\,repo-remove-unknown\/\fR
remove unknown packages remove unknown packages
.TP .TP
\fBahriman\fR \fI\,repo-report\/\fR
generate report
.TP
\fBahriman\fR \fI\,repo-restore\/\fR \fBahriman\fR \fI\,repo-restore\/\fR
restore repository data restore repository data
.TP .TP
@ -109,6 +112,9 @@ sign packages
\fBahriman\fR \fI\,repo-status-update\/\fR \fBahriman\fR \fI\,repo-status-update\/\fR
update repository status update repository status
.TP .TP
\fBahriman\fR \fI\,repo-sync\/\fR
sync repository
.TP
\fBahriman\fR \fI\,repo-triggers\/\fR \fBahriman\fR \fI\,repo-triggers\/\fR
run triggers run triggers
.TP .TP
@ -405,6 +411,11 @@ just perform check for packages without removal
\fB\-i\fR, \fB\-\-info\fR \fB\-i\fR, \fB\-\-info\fR
show additional package information show additional package information
.SH COMMAND \fI\,'ahriman repo-report'\/\fR
usage: ahriman repo-report [-h]
generate repository report according to current settings
.SH COMMAND \fI\,'ahriman repo-restore'\/\fR .SH COMMAND \fI\,'ahriman repo-restore'\/\fR
usage: ahriman repo-restore [-h] [-o OUTPUT] path usage: ahriman repo-restore [-h] [-o OUTPUT] path
@ -485,11 +496,20 @@ update repository status on the status page
\fB\-s\fR \fI\,{BuildStatusEnum.Unknown,BuildStatusEnum.Pending,BuildStatusEnum.Building,BuildStatusEnum.Failed,BuildStatusEnum.Success}\/\fR, \fB\-\-status\fR \fI\,{BuildStatusEnum.Unknown,BuildStatusEnum.Pending,BuildStatusEnum.Building,BuildStatusEnum.Failed,BuildStatusEnum.Success}\/\fR \fB\-s\fR \fI\,{BuildStatusEnum.Unknown,BuildStatusEnum.Pending,BuildStatusEnum.Building,BuildStatusEnum.Failed,BuildStatusEnum.Success}\/\fR, \fB\-\-status\fR \fI\,{BuildStatusEnum.Unknown,BuildStatusEnum.Pending,BuildStatusEnum.Building,BuildStatusEnum.Failed,BuildStatusEnum.Success}\/\fR
new status new status
.SH COMMAND \fI\,'ahriman repo-sync'\/\fR
usage: ahriman repo-sync [-h]
sync repository files to remote server according to current settings
.SH COMMAND \fI\,'ahriman repo-triggers'\/\fR .SH COMMAND \fI\,'ahriman repo-triggers'\/\fR
usage: ahriman repo-triggers [-h] usage: ahriman repo-triggers [-h] [trigger ...]
run triggers on empty build result as configured by settings run triggers on empty build result as configured by settings
.TP
\fBtrigger\fR
instead of running all triggers as set by configuration, just process specified ones oin order of metion
.SH COMMAND \fI\,'ahriman repo-update'\/\fR .SH COMMAND \fI\,'ahriman repo-update'\/\fR
usage: ahriman repo-update [-h] [--dry-run] [-e] [--no-aur] [--no-local] [--no-manual] [--no-vcs] [package ...] usage: ahriman repo-update [-h] [--dry-run] [-e] [--no-aur] [--no-local] [--no-manual] [--no-vcs] [package ...]
@ -525,7 +545,8 @@ do not include manual updates
do not check VCS packages do not check VCS packages
.SH COMMAND \fI\,'ahriman user-add'\/\fR .SH COMMAND \fI\,'ahriman user-add'\/\fR
usage: ahriman user-add [-h] [--as-service] [-p PASSWORD] [-r {UserAccess.Safe,UserAccess.Read,UserAccess.Write}] [-s] usage: ahriman user-add [-h] [--as-service] [-p PASSWORD]
[-r {UserAccess.Unauthorized,UserAccess.Read,UserAccess.Reporter,UserAccess.Full}] [-s]
username username
update user for web services with the given password and role. In case if password was not entered it will be asked interactively update user for web services with the given password and role. In case if password was not entered it will be asked interactively
@ -545,7 +566,7 @@ user password. Blank password will be treated as empty password, which is in par
authorization type. authorization type.
.TP .TP
\fB\-r\fR \fI\,{UserAccess.Safe,UserAccess.Read,UserAccess.Write}\/\fR, \fB\-\-role\fR \fI\,{UserAccess.Safe,UserAccess.Read,UserAccess.Write}\/\fR \fB\-r\fR \fI\,{UserAccess.Unauthorized,UserAccess.Read,UserAccess.Reporter,UserAccess.Full}\/\fR, \fB\-\-role\fR \fI\,{UserAccess.Unauthorized,UserAccess.Read,UserAccess.Reporter,UserAccess.Full}\/\fR
user access level user access level
.TP .TP
@ -553,7 +574,8 @@ user access level
set file permissions to user\-only set file permissions to user\-only
.SH COMMAND \fI\,'ahriman user-list'\/\fR .SH COMMAND \fI\,'ahriman user-list'\/\fR
usage: ahriman user-list [-h] [-e] [-r {UserAccess.Safe,UserAccess.Read,UserAccess.Write}] [username] usage: ahriman user-list [-h] [-e] [-r {UserAccess.Unauthorized,UserAccess.Read,UserAccess.Reporter,UserAccess.Full}]
[username]
list users from the user mapping and their roles list users from the user mapping and their roles
@ -567,7 +589,7 @@ filter users by username
return non\-zero exit status if result is empty return non\-zero exit status if result is empty
.TP .TP
\fB\-r\fR \fI\,{UserAccess.Safe,UserAccess.Read,UserAccess.Write}\/\fR, \fB\-\-role\fR \fI\,{UserAccess.Safe,UserAccess.Read,UserAccess.Write}\/\fR \fB\-r\fR \fI\,{UserAccess.Unauthorized,UserAccess.Read,UserAccess.Reporter,UserAccess.Full}\/\fR, \fB\-\-role\fR \fI\,{UserAccess.Unauthorized,UserAccess.Read,UserAccess.Reporter,UserAccess.Full}\/\fR
filter users by role filter users by role
.SH COMMAND \fI\,'ahriman user-remove'\/\fR .SH COMMAND \fI\,'ahriman user-remove'\/\fR

View File

@ -20,6 +20,14 @@ ahriman.core.database.migrations.m001\_package\_source module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.database.migrations.m002\_user\_access module
----------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m002_user_access
:members:
:no-undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@ -4,14 +4,6 @@ ahriman.web.views.status package
Submodules Submodules
---------- ----------
ahriman.web.views.status.ahriman module
---------------------------------------
.. automodule:: ahriman.web.views.status.ahriman
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.status.package module ahriman.web.views.status.package module
--------------------------------------- ---------------------------------------

View File

@ -41,7 +41,7 @@ Base authorization settings. ``OAuth`` provider requires ``aioauth-client`` libr
* ``max_age`` - parameter which controls both cookie expiration and token expiration inside the service, integer, optional, default is 7 days. * ``max_age`` - parameter which controls both cookie expiration and token expiration inside the service, integer, optional, default is 7 days.
* ``oauth_provider`` - OAuth2 provider class name as is in ``aioauth-client`` (e.g. ``GoogleClient``, ``GithubClient`` etc), string, required in case if ``oauth`` is used. * ``oauth_provider`` - OAuth2 provider class name as is in ``aioauth-client`` (e.g. ``GoogleClient``, ``GithubClient`` etc), string, required in case if ``oauth`` is used.
* ``oauth_scopes`` - scopes list for OAuth2 provider, which will allow retrieving user email (which is used for checking user permissions), e.g. ``https://www.googleapis.com/auth/userinfo.email`` for ``GoogleClient`` or ``user:email`` for ``GithubClient``, space separated list of strings, required in case if ``oauth`` is used. * ``oauth_scopes`` - scopes list for OAuth2 provider, which will allow retrieving user email (which is used for checking user permissions), e.g. ``https://www.googleapis.com/auth/userinfo.email`` for ``GoogleClient`` or ``user:email`` for ``GithubClient``, space separated list of strings, required in case if ``oauth`` is used.
* ``safe_build_status`` - allow requesting status page without authorization, boolean, required. * ``allow_read_only`` - allow requesting status APIs without authorization, boolean, required.
* ``salt`` - password hash salt, string, required in case if authorization enabled (automatically generated by ``create-user`` subcommand). * ``salt`` - password hash salt, string, required in case if authorization enabled (automatically generated by ``create-user`` subcommand).
Authorized users are stored inside internal database, if any of external provides are used the password field for non-service users must be empty. Authorized users are stored inside internal database, if any of external provides are used the password field for non-service users must be empty.

View File

@ -52,7 +52,7 @@ Obviously you can implement the specified method in class, but for guide purpose
self.username = configuration.get("slack", "username") self.username = configuration.get("slack", "username")
def run(self, result, packages): def run(self, result, packages):
notify(result, self.slack_url, channel, username) notify(result, self.slack_url, self.channel, self.username)
Setup the trigger Setup the trigger
----------------- -----------------

View File

@ -13,7 +13,7 @@ target = disabled
max_age = 604800 max_age = 604800
oauth_provider = GoogleClient oauth_provider = GoogleClient
oauth_scopes = https://www.googleapis.com/auth/userinfo.email oauth_scopes = https://www.googleapis.com/auth/userinfo.email
safe_build_status = yes allow_read_only = yes
[build] [build]
archbuild_flags = archbuild_flags =
@ -37,7 +37,6 @@ target = console
use_utf = yes use_utf = yes
[email] [email]
full_template_path = /usr/share/ahriman/templates/repo-index.jinja2
no_empty_report = yes no_empty_report = yes
template_path = /usr/share/ahriman/templates/email-index.jinja2 template_path = /usr/share/ahriman/templates/email-index.jinja2
ssl = disabled ssl = disabled

View File

@ -14,28 +14,29 @@
<div class="container"> <div class="container">
<h1>ahriman <h1>ahriman
{% if auth.authenticated %} <img id="badge-version" src="https://img.shields.io/badge/version-unknown-informational" alt="unknown">
<img src="https://img.shields.io/badge/version-{{ version }}-informational" alt="{{ version }}"> <img id="badge-repository" src="https://img.shields.io/badge/repository-unknown-informational" alt="unknown">
<img src="https://img.shields.io/badge/repository-{{ repository | replace("-", "--") }}-informational" alt="{{ repository }}"> <img id="badge-architecture" src="https://img.shields.io/badge/architecture-unknown-informational" alt="unknown">
<img src="https://img.shields.io/badge/architecture-{{ architecture }}-informational" alt="{{ architecture }}"> <img id="badge-status" src="https://img.shields.io/badge/service%20status-unknown-inactive" alt="unknown">
<img src="https://img.shields.io/badge/service%20status-{{ service.status }}-{{ service.status_color }}" alt="{{ service.status }}" title="{{ service.timestamp }}">
{% endif %}
</h1> </h1>
</div> </div>
<div class="container"> <div class="container">
<div id="toolbar"> <div id="toolbar">
{% if not auth.enabled or auth.username is not none %} {% if not auth.enabled or auth.username is not none %}
<button id="add" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addForm"> <button id="add-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-form" hidden>
<i class="fa fa-plus"></i> add <i class="bi bi-plus"></i> add
</button> </button>
<button id="update" class="btn btn-secondary" onclick="updatePackages()" disabled> <button id="update-btn" class="btn btn-secondary" onclick="updatePackages()" disabled hidden>
<i class="fa fa-play"></i> update <i class="bi bi-play"></i> update
</button> </button>
<button id="remove" class="btn btn-danger" onclick="removePackages()" disabled> <button id="remove-btn" class="btn btn-danger" onclick="removePackages()" disabled hidden>
<i class="fa fa-trash"></i> remove <i class="bi bi-trash"></i> remove
</button> </button>
{% endif %} {% endif %}
<button class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i> reload
</button>
</div> </div>
<table id="packages" class="table table-striped table-hover" <table id="packages" class="table table-striped table-hover"
@ -53,42 +54,22 @@
data-show-fullscreen="true" data-show-fullscreen="true"
data-show-search-clear-button="true" data-show-search-clear-button="true"
data-sortable="true" data-sortable="true"
data-sort-reset="true" data-sort-name="base"
data-sort-order="asc"
data-toggle="table" data-toggle="table"
data-toolbar="#toolbar"> data-toolbar="#toolbar">
<thead class="table-primary"> <thead class="table-primary">
<tr> <tr>
<th data-checkbox="true"></th> <th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false">package base</th> <th data-sortable="true" data-switchable="false" data-field="base">package base</th>
<th data-sortable="true">version</th> <th data-sortable="true" data-field="version">version</th>
<th data-sortable="true">packages</th> <th data-sortable="true" data-field="packages">packages</th>
<th data-sortable="true" data-visible="false">groups</th> <th data-sortable="true" data-visible="false" data-field="groups">groups</th>
<th data-sortable="true" data-visible="false">licenses</th> <th data-sortable="true" data-visible="false" data-field="licenses">licenses</th>
<th data-sortable="true">last update</th> <th data-sortable="true" data-field="timestamp">last update</th>
<th data-sortable="true">status</th> <th data-sortable="true" data-cell-style="statusFormat" data-field="status">status</th>
</tr> </tr>
</thead> </thead>
<tbody>
{% if auth.authenticated %}
{% for package in packages %}
<tr data-package-base="{{ package.base }}">
<td data-checkbox="true"></td>
<td>{% if package.web_url is not none %}<a href="{{ package.web_url }}" title="{{ package.base }}">{{ package.base }}</a>{% else %}{{ package.base }}{% endif %}</td>
<td>{{ package.version }}</td>
<td>{{ package.packages|join("<br>"|safe) }}</td>
<td>{{ package.groups|join("<br>"|safe) }}</td>
<td>{{ package.licenses|join("<br>"|safe) }}</td>
<td>{{ package.timestamp }}</td>
<td class="table-{{ package.status_color }}">{{ package.status }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="100%">In order to see statuses you must login first.</td>
</tr>
{% endif %}
</tbody>
</table> </table>
</div> </div>
@ -122,11 +103,14 @@
{% include "build-status/login-modal.jinja2" %} {% include "build-status/login-modal.jinja2" %}
{% endif %} {% endif %}
{% include "build-status/package-actions-modals.jinja2" %}
{% include "utils/bootstrap-scripts.jinja2" %} {% include "utils/bootstrap-scripts.jinja2" %}
{% include "build-status/package-actions-script.jinja2" %} {% include "build-status/failed-modal.jinja2" %}
{% include "build-status/success-modal.jinja2" %}
{% include "build-status/package-add-modal.jinja2" %}
{% include "build-status/table.jinja2" %}
</body> </body>

View File

@ -0,0 +1,28 @@
<div id="failed-form" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-danger">
<h4 class="modal-title">failed</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<p>Packages update has failed.</p>
<p id="error-details"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">close</button>
</div>
</div>
</div>
</div>
<script>
const failedForm = $("#failed-form");
const errorDetails = $("#error-details");
failedForm.on("hidden.bs.modal", () => { reload(); });
function showFailure(details) {
errorDetails.text(details);
failedForm.modal("show");
}
</script>

View File

@ -1,60 +0,0 @@
<div id="addForm" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">add new packages</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<label for="package" class="col-sm-2 col-form-label">package</label>
<div class="col-sm-10">
<input id="package" type="text" list="knownPackages" class="form-control" placeholder="AUR package" name="package" required>
<datalist id="knownPackages"></datalist>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">close</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal" onclick="requestPackages()">request</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPackages()">add</button>
</div>
</div>
</div>
</div>
<div id="failedForm" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-danger">
<h4 class="modal-title">failed</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<p>Packages update has failed.</p>
<p id="errorDetails"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">close</button>
</div>
</div>
</div>
</div>
<div id="successForm" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-success">
<h4 class="modal-title">success</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<p>Packages update has been run.</p>
<ul id="successDetails"></ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">close</button>
</div>
</div>
</div>
</div>

View File

@ -1,95 +0,0 @@
<script>
const $remove = $("#remove");
const $update = $("#update");
const $table = $("#packages");
$table.on("check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table",
function () {
$remove.prop("disabled", !$table.bootstrapTable("getSelections").length);
$update.prop("disabled", !$table.bootstrapTable("getSelections").length);
})
const $successForm = $("#successForm");
const $successDetails = $("#successDetails");
$successForm.on("hidden.bs.modal", function() { window.location.reload(); });
const $failedForm = $("#failedForm");
const $errorDetails = $("#errorDetails");
$failedForm.on("hidden.bs.modal", function() { window.location.reload(); });
const $package = $("#package");
const $knownPackages = $("#knownPackages");
$package.keyup(function () {
const $this = $(this);
clearTimeout($this.data("timeout"));
$this.data("timeout", setTimeout($.proxy(function () {
const $value = $package.val();
$.ajax({
url: "/service-api/v1/search",
data: {"for": $value},
type: "GET",
dataType: "json",
success: function (resp) {
const $options = resp.map(function (pkg) {
const $option = document.createElement("option");
$option.value = pkg.package;
$option.innerText = `${pkg.package} (${pkg.description})`;
return $option;
});
$knownPackages.empty().append($options);
$this.focus();
},
})
}, this), 500));
})
function doPackageAction($uri, $packages) {
if ($packages.length === 0)
return;
$.ajax({
url: $uri,
data: JSON.stringify({packages: $packages}),
type: "POST",
contentType: "application/json",
success: function (_) {
const $details = $packages.map(function (pkg) {
const $li = document.createElement("li");
$li.innerText = pkg;
return $li;
});
$successDetails.empty().append($details);
$successForm.modal("show");
},
error: function (jqXHR, textStatus, errorThrown) {
$errorDetails.text(errorThrown);
$failedForm.modal("show");
},
})
}
function getSelection() {
return $.map($table.bootstrapTable("getSelections"), function(row) {
return row._data["package-base"];
})
}
function addPackages() {
const $packages = [$package.val()]
doPackageAction("/service-api/v1/add", $packages);
}
function requestPackages() {
const $packages = [$package.val()]
doPackageAction("/service-api/v1/request", $packages);
}
function removePackages() { doPackageAction("/service-api/v1/remove", getSelection()); }
function updatePackages() { doPackageAction("/service-api/v1/add", getSelection()); }
$(function () {
$table.bootstrapTable("uncheckAll");
})
</script>

View File

@ -0,0 +1,62 @@
<div id="add-form" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">add new packages</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<label for="package" class="col-sm-2 col-form-label">package</label>
<div class="col-sm-10">
<input id="package-form" type="text" list="known-packages-dlist" class="form-control" placeholder="AUR package" name="package" required>
<datalist id="known-packages-dlist"></datalist>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">close</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal" onclick="requestPackages()">request</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPackages()">add</button>
</div>
</div>
</div>
</div>
<script>
const packageInput = $("#package-form");
const knownPackages = $("#known-packages-dlist");
packageInput.keyup(() => {
clearTimeout(packageInput.data("timeout"));
packageInput.data("timeout", setTimeout($.proxy(() => {
const value = packageInput.val();
$.ajax({
url: "/service-api/v1/search",
data: {"for": value},
type: "GET",
dataType: "json",
success: response => {
const options = response.map(pkg => {
const option = document.createElement("option");
option.value = pkg.package;
option.innerText = `${pkg.package} (${pkg.description})`;
return option;
});
knownPackages.empty().append(options);
packageInput.focus();
},
})
}, this), 500));
});
function addPackages() {
const packages = [packageInput.val()]
doPackageAction("/service-api/v1/add", packages);
}
function requestPackages() {
const packages = [packageInput.val()]
doPackageAction("/service-api/v1/request", packages);
}
</script>

View File

@ -0,0 +1,28 @@
<div id="success-form" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-success">
<h4 class="modal-title">success</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<p>Packages update has been run.</p>
<ul id="success-details"></ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">close</button>
</div>
</div>
</div>
</div>
<script>
const successForm = $("#success-form");
const successDetails = $("#success-details");
successForm.on("hidden.bs.modal", () => { reload(); });
function showSuccess(details) {
successDetails.empty().append(details);
successForm.modal("show");
}
</script>

View File

@ -0,0 +1,147 @@
<script>
const addButton = $("#add-btn");
const removeButton = $("#remove-btn");
const updateButton = $("#update-btn");
const table = $("#packages");
table.on("check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table",
() => {
removeButton.prop("disabled", !table.bootstrapTable("getSelections").length);
updateButton.prop("disabled", !table.bootstrapTable("getSelections").length);
});
const architectureBadge = $("#badge-architecture");
const repositoryBadge = $("#badge-repository");
const statusBadge = $("#badge-status");
const versionBadge = $("#badge-version");
function doPackageAction(uri, packages) {
if (packages.length === 0)
return;
$.ajax({
url: uri,
data: JSON.stringify({packages: packages}),
type: "POST",
contentType: "application/json",
success: _ => {
const details = packages.map(pkg => {
const li = document.createElement("li");
li.innerText = pkg;
return li;
});
showSuccess(details);
},
error: (jqXHR, _, errorThrown) => { showFailure(errorThrown); },
})
}
function getSelection() {
return table.bootstrapTable("getSelections").map(row => { return row.id; });
}
function removePackages() { doPackageAction("/service-api/v1/remove", getSelection()); }
function updatePackages() { doPackageAction("/service-api/v1/add", getSelection()); }
function hideControls(hidden) {
addButton.attr("hidden", hidden);
removeButton.attr("hidden", hidden);
updateButton.attr("hidden", hidden);
}
function reload() {
table.bootstrapTable("showLoading");
$.ajax({
url: "/status-api/v1/packages",
type: "GET",
dataType: "json",
success: response => {
const extractListProperties = (description, property) => {
return Object.values(description.packages).map(pkg => {
return pkg[property];
}).reduce((left, right) => { return left.concat(right); }, []);
};
const listToTable = data => { return Array.from(new Set(data)).sort().join("<br>"); };
const payload = response.map(description => {
const package_base = description.package.base;
const web_url = description.package.remote?.web_url;
return {
id: description.package.base,
base: web_url ? `<a href="${web_url}" title="${package_base}">${package_base}</a>` : package_base,
version: description.package.version,
packages: listToTable(Object.keys(description.package.packages)),
groups: listToTable(extractListProperties(description.package, "groups")),
licenses: listToTable(extractListProperties(description.package, "licenses")),
timestamp: new Date(1000 * description.status.timestamp).toISOString(),
status: description.status.status
}
});
table.bootstrapTable("load", payload);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
hideControls(false);
},
error: (jqXHR, _, errorThrown) => {
hideControls(true);
if ((jqXHR.status === 401) || (jqXHR.status === 403)) {
// authorization error
const text = "In order to see statuses you must login first.";
table.find("tr.unauthorized").remove();
table.find("tbody").append(`<tr class="unauthorized"><td colspan="100%">${text}</td></tr>`);
table.bootstrapTable("hideLoading");
} else {
// other errors
showFailure(errorThrown);
}
},
});
$.ajax({
url: "/status-api/v1/status",
type: "GET",
dataType: "json",
success: response => {
const badgeColor = status => {
if (status === "pending") return "yellow";
if (status === "building") return "yellow";
if (status === "failed") return "critical";
if (status === "success") return "success";
return "inactive";
};
architectureBadge
.attr("src", `https://img.shields.io/badge/architecture-${response.architecture}-informational`)
.attr("alt", response.architecture);
repositoryBadge
.attr("src", `https://img.shields.io/badge/repository-${response.repository.replace(/-/g, "--")}-informational`)
.attr("alt", response.repository);
statusBadge
.attr("src", `https://img.shields.io/badge/service%20status-${response.status.status}-${badgeColor(response.status.status)}`)
.attr("alt", response.status.status)
.attr("title", `at ${new Date(1000 * response.status.timestamp).toISOString()}`);
versionBadge
.attr("src", `https://img.shields.io/badge/version-${response.version}-informational`)
.attr("alt", response.version);
},
});
}
function statusFormat(value) {
const cellClass = status => {
if (status === "pending") return "table-warning";
if (status === "building") return "table-warning";
if (status === "failed") return "table-danger";
if (status === "success") return "table-success";
return "table-secondary";
};
return {classes: cellClass(value)};
}
$(() => {
table.bootstrapTable({});
reload();
})
</script>

View File

@ -40,7 +40,8 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
data-show-fullscreen="true" data-show-fullscreen="true"
data-show-search-clear-button="true" data-show-search-clear-button="true"
data-sortable="true" data-sortable="true"
data-sort-reset="true" data-sort-name="base"
data-sort-order="asc"
data-toggle="table"> data-toggle="table">
<thead class="table-primary"> <thead class="table-primary">
<tr> <tr>

View File

@ -4,12 +4,12 @@
<script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script> <script src="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script src="https://unpkg.com/bootstrap-table@1.18.3/dist/bootstrap-table.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.18.3/dist/extensions/export/bootstrap-table-export.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.18.3/dist/extensions/resizable/bootstrap-table-resizable.js"></script> <script src="https://unpkg.com/bootstrap-table@1.19.1/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script> <script>
$("#packages").bootstrapTable({ $("#packages").bootstrapTable({

View File

@ -1,7 +1,7 @@
<script src="https://kit.fontawesome.com/0d6d6d5226.js" crossorigin="anonymous"></script> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.18.3/dist/bootstrap-table.min.css" rel="stylesheet"> <link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet"> <link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet">

View File

@ -69,9 +69,11 @@ setup(
"package/share/ahriman/templates/telegram-index.jinja2", "package/share/ahriman/templates/telegram-index.jinja2",
]), ]),
("share/ahriman/templates/build-status", [ ("share/ahriman/templates/build-status", [
"package/share/ahriman/templates/build-status/failed-modal.jinja2",
"package/share/ahriman/templates/build-status/login-modal.jinja2", "package/share/ahriman/templates/build-status/login-modal.jinja2",
"package/share/ahriman/templates/build-status/package-actions-modals.jinja2", "package/share/ahriman/templates/build-status/package-add-modal.jinja2",
"package/share/ahriman/templates/build-status/package-actions-script.jinja2", "package/share/ahriman/templates/build-status/success-modal.jinja2",
"package/share/ahriman/templates/build-status/table.jinja2",
]), ]),
("share/ahriman/templates/static", [ ("share/ahriman/templates/static", [
"package/share/ahriman/templates/static/favicon.ico", "package/share/ahriman/templates/static/favicon.ico",

View File

@ -52,8 +52,8 @@ class Status(Handler):
# we are using reporter here # we are using reporter here
client = Application(architecture, configuration, no_report=False, unsafe=unsafe).repository.reporter client = Application(architecture, configuration, no_report=False, unsafe=unsafe).repository.reporter
if args.ahriman: if args.ahriman:
ahriman = client.get_self() service_status = client.get_internal()
StatusPrinter(ahriman).print(args.info) StatusPrinter(service_status.status).print(args.info)
if args.package: if args.package:
packages: Iterable[Tuple[Package, BuildStatus]] = sum( packages: Iterable[Tuple[Package, BuildStatus]] = sum(
[client.get(base) for base in args.package], [client.get(base) for base in args.package],

View File

@ -37,7 +37,7 @@ class Auth:
enabled(bool): indicates if authorization is enabled enabled(bool): indicates if authorization is enabled
logger(logging.Logger): class logger logger(logging.Logger): class logger
max_age(int): session age in seconds. It will be used for both client side and server side checks max_age(int): session age in seconds. It will be used for both client side and server side checks
safe_build_status(bool): allow read only access to the index page allow_read_only(bool): allow read only access to APIs
""" """
def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None: def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None:
@ -50,7 +50,7 @@ class Auth:
""" """
self.logger = logging.getLogger("http") self.logger = logging.getLogger("http")
self.safe_build_status = configuration.getboolean("auth", "safe_build_status") self.allow_read_only = configuration.getboolean("auth", "allow_read_only")
self.enabled = provider.is_enabled self.enabled = provider.is_enabled
self.max_age = configuration.getint("auth", "max_age", fallback=7 * 24 * 3600) self.max_age = configuration.getint("auth", "max_age", fallback=7 * 24 * 3600)

View File

@ -0,0 +1,33 @@
#
# Copyright (c) 2021-2022 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__all__ = ["steps"]
steps = [
"""
update users set access = 'read' where access = 'safe'
""",
"""
update users set access = 'reporter' where access = 'read'
""",
"""
update users set access = 'full' where access = 'write'
""",
]

View File

@ -80,16 +80,7 @@ class Client:
Returns: Returns:
InternalStatus: current internal (web) service status InternalStatus: current internal (web) service status
""" """
return InternalStatus() return InternalStatus(BuildStatus())
def get_self(self) -> BuildStatus: # pylint: disable=no-self-use
"""
get ahriman status itself
Returns:
BuildStatus: current ahriman status
"""
return BuildStatus()
def remove(self, base: str) -> None: def remove(self, base: str) -> None:
""" """

View File

@ -57,16 +57,6 @@ class WebClient(Client):
self.__session = requests.session() self.__session = requests.session()
self._login() self._login()
@property
def _ahriman_url(self) -> str:
"""
get url for the service status api
Returns:
str: full url for web service for ahriman service itself
"""
return f"{self.address}/status-api/v1/ahriman"
@property @property
def _login_url(self) -> str: def _login_url(self) -> str:
""" """
@ -201,26 +191,7 @@ class WebClient(Client):
self.logger.exception("could not get web service status: %s", exception_response_text(e)) self.logger.exception("could not get web service status: %s", exception_response_text(e))
except Exception: except Exception:
self.logger.exception("could not get web service status") self.logger.exception("could not get web service status")
return InternalStatus() return InternalStatus(BuildStatus())
def get_self(self) -> BuildStatus:
"""
get ahriman status itself
Returns:
BuildStatus: current ahriman status
"""
try:
response = self.__session.get(self._ahriman_url)
response.raise_for_status()
status_json = response.json()
return BuildStatus.from_json(status_json)
except requests.HTTPError as e:
self.logger.exception("could not get service status: %s", exception_response_text(e))
except Exception:
self.logger.exception("could not get service status")
return BuildStatus()
def remove(self, base: str) -> None: def remove(self, base: str) -> None:
""" """
@ -265,7 +236,7 @@ class WebClient(Client):
payload = {"status": status.value} payload = {"status": status.value}
try: try:
response = self.__session.post(self._ahriman_url, json=payload) response = self.__session.post(self._status_url, json=payload)
response.raise_for_status() response.raise_for_status()
except requests.HTTPError as e: except requests.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))

View File

@ -46,40 +46,6 @@ class BuildStatusEnum(str, Enum):
Failed = "failed" Failed = "failed"
Success = "success" Success = "success"
def badges_color(self) -> str:
"""
convert itself to shield.io badges color
Returns:
str: shields.io color
"""
if self == BuildStatusEnum.Pending:
return "yellow"
if self == BuildStatusEnum.Building:
return "yellow"
if self == BuildStatusEnum.Failed:
return "critical"
if self == BuildStatusEnum.Success:
return "success"
return "inactive"
def bootstrap_color(self) -> str:
"""
converts itself to bootstrap color
Returns:
str: bootstrap color
"""
if self == BuildStatusEnum.Pending:
return "warning"
if self == BuildStatusEnum.Building:
return "warning"
if self == BuildStatusEnum.Failed:
return "danger"
if self == BuildStatusEnum.Success:
return "success"
return "secondary"
@dataclass @dataclass
class BuildStatus: class BuildStatus:

View File

@ -22,6 +22,7 @@ from __future__ import annotations
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from typing import Any, Dict, Optional, Type from typing import Any, Dict, Optional, Type
from ahriman.models.build_status import BuildStatus
from ahriman.models.counters import Counters from ahriman.models.counters import Counters
@ -31,12 +32,14 @@ class InternalStatus:
internal server status internal server status
Attributes: Attributes:
status(BuildStatus): service status
architecture(Optional[str]): repository architecture architecture(Optional[str]): repository architecture
packages(Counters): packages statuses counter object packages(Counters): packages statuses counter object
repository(Optional[str]): repository name repository(Optional[str]): repository name
version(Optional[str]): service version version(Optional[str]): service version
""" """
status: BuildStatus
architecture: Optional[str] = None architecture: Optional[str] = None
packages: Counters = field(default=Counters(total=0)) packages: Counters = field(default=Counters(total=0))
repository: Optional[str] = None repository: Optional[str] = None
@ -54,7 +57,8 @@ class InternalStatus:
InternalStatus: internal status InternalStatus: internal status
""" """
counters = Counters.from_json(dump["packages"]) if "packages" in dump else Counters(total=0) counters = Counters.from_json(dump["packages"]) if "packages" in dump else Counters(total=0)
return cls(architecture=dump.get("architecture"), return cls(status=BuildStatus.from_json(dump.get("status", {})),
architecture=dump.get("architecture"),
packages=counters, packages=counters,
repository=dump.get("repository"), repository=dump.get("repository"),
version=dump.get("version")) version=dump.get("version"))

View File

@ -142,9 +142,7 @@ class User:
Returns: Returns:
bool: True in case if user is allowed to do this request and False otherwise bool: True in case if user is allowed to do this request and False otherwise
""" """
if self.access == UserAccess.Write: return self.access.permits(required)
return True # everything is allowed
return self.access == required
def __repr__(self) -> str: def __repr__(self) -> str:
""" """

View File

@ -17,6 +17,8 @@
# 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 __future__ import annotations
from enum import Enum from enum import Enum
@ -25,12 +27,31 @@ class UserAccess(str, Enum):
web user access enumeration web user access enumeration
Attributes: Attributes:
Safe(UserAccess): (class attribute) user can access the page without authorization, Unauthorized(UserAccess): (class attribute) user can access specific resources which are marked as available
should not be used for user configuration without authorization (e.g. login, logout, static)
Read(UserAccess): (class attribute) user can read the page Read(UserAccess): (class attribute) user can read the page
Write(UserAccess): (class attribute) user can modify task and package list Reporter(UserAccess): (class attribute) user can read everything and is able to perform some modifications
Full(UserAccess): (class attribute) user has full access
""" """
Safe = "safe" Unauthorized = "unauthorized"
Read = "read" Read = "read"
Write = "write" Reporter = "reporter"
Full = "full"
def permits(self, other: UserAccess) -> bool:
"""
compare enumeration between each other and check if current permission allows the ``other``
Args:
other(UserAccess): other permission to compare
Returns:
bool: True in case if current permission allows the operation and False otherwise
"""
for member in UserAccess:
if member == other:
return True
if member == self:
return False
return False # must never happen

View File

@ -89,10 +89,13 @@ class AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy): # type
return await self.validator.verify_access(user.username, permission, context) return await self.validator.verify_access(user.username, permission, context)
def auth_handler() -> MiddlewareType: def auth_handler(allow_read_only: bool) -> MiddlewareType:
""" """
authorization and authentication middleware authorization and authentication middleware
Args:
allow_read_only: allow
Returns: Returns:
MiddlewareType: built middleware MiddlewareType: built middleware
""" """
@ -102,10 +105,14 @@ def auth_handler() -> MiddlewareType:
permission = await permission_method(request) permission = await permission_method(request)
elif isinstance(handler, types.MethodType): # additional wrapper for static resources elif isinstance(handler, types.MethodType): # additional wrapper for static resources
handler_instance = getattr(handler, "__self__", None) handler_instance = getattr(handler, "__self__", None)
permission = UserAccess.Safe if isinstance(handler_instance, StaticResource) else UserAccess.Write permission = UserAccess.Unauthorized if isinstance(handler_instance, StaticResource) else UserAccess.Full
else:
permission = UserAccess.Full
if permission == UserAccess.Unauthorized: # explicit if elif else for better code coverage
pass
elif allow_read_only and UserAccess.Read.permits(permission):
pass
else: else:
permission = UserAccess.Write
if permission != UserAccess.Safe:
await aiohttp_security.check_permission(request, permission, request.path) await aiohttp_security.check_permission(request, permission, request.path)
return await handler(request) return await handler(request)
@ -133,6 +140,6 @@ def setup_auth(application: web.Application, validator: Auth) -> web.Application
identity_policy = aiohttp_security.SessionIdentityPolicy() identity_policy = aiohttp_security.SessionIdentityPolicy()
aiohttp_security.setup(application, identity_policy, authorization_policy) aiohttp_security.setup(application, identity_policy, authorization_policy)
application.middlewares.append(auth_handler()) application.middlewares.append(auth_handler(validator.allow_read_only))
return application return application

View File

@ -25,7 +25,6 @@ from ahriman.web.views.service.add import AddView
from ahriman.web.views.service.remove import RemoveView from ahriman.web.views.service.remove import RemoveView
from ahriman.web.views.service.request import RequestView from ahriman.web.views.service.request import RequestView
from ahriman.web.views.service.search import SearchView from ahriman.web.views.service.search import SearchView
from ahriman.web.views.status.ahriman import AhrimanView
from ahriman.web.views.status.package import PackageView from ahriman.web.views.status.package import PackageView
from ahriman.web.views.status.packages import PackagesView from ahriman.web.views.status.packages import PackagesView
from ahriman.web.views.status.status import StatusView from ahriman.web.views.status.status import StatusView
@ -55,9 +54,6 @@ def setup_routes(application: Application, static_path: Path) -> None:
* POST /service-api/v1/update update packages in repository, actually it is just alias for add * POST /service-api/v1/update update packages in repository, actually it is just alias for add
* GET /status-api/v1/ahriman get current service status
* POST /status-api/v1/ahriman update service status
* GET /status-api/v1/packages get all known packages * GET /status-api/v1/packages get all known packages
* POST /status-api/v1/packages force update every package from repository * POST /status-api/v1/packages force update every package from repository
@ -65,7 +61,8 @@ def setup_routes(application: Application, static_path: Path) -> None:
* GET /status-api/v1/package/:base get package base status * GET /status-api/v1/package/:base get package base status
* POST /status-api/v1/package/:base update package base status * POST /status-api/v1/package/:base update package base status
* GET /status-api/v1/status get web service status itself * GET /status-api/v1/status get service status itself
* POST /status-api/v1/status update service status itself
* GET /user-api/v1/login OAuth2 handler for login * GET /user-api/v1/login OAuth2 handler for login
* POST /user-api/v1/login login to service * POST /user-api/v1/login login to service
@ -90,9 +87,6 @@ def setup_routes(application: Application, static_path: Path) -> None:
application.router.add_post("/service-api/v1/update", AddView) application.router.add_post("/service-api/v1/update", AddView)
application.router.add_get("/status-api/v1/ahriman", AhrimanView, allow_head=True)
application.router.add_post("/status-api/v1/ahriman", AhrimanView)
application.router.add_get("/status-api/v1/packages", PackagesView, allow_head=True) application.router.add_get("/status-api/v1/packages", PackagesView, allow_head=True)
application.router.add_post("/status-api/v1/packages", PackagesView) application.router.add_post("/status-api/v1/packages", PackagesView)
@ -101,6 +95,7 @@ def setup_routes(application: Application, static_path: Path) -> None:
application.router.add_post("/status-api/v1/packages/{package}", PackageView) application.router.add_post("/status-api/v1/packages/{package}", PackageView)
application.router.add_get("/status-api/v1/status", StatusView, allow_head=True) application.router.add_get("/status-api/v1/status", StatusView, allow_head=True)
application.router.add_post("/status-api/v1/status", StatusView)
application.router.add_get("/user-api/v1/login", LoginView) application.router.add_get("/user-api/v1/login", LoginView)
application.router.add_post("/user-api/v1/login", LoginView) application.router.add_post("/user-api/v1/login", LoginView)

View File

@ -101,7 +101,7 @@ class BaseView(View):
Returns: Returns:
UserAccess: extracted permission UserAccess: extracted permission
""" """
permission: UserAccess = getattr(cls, f"{request.method.upper()}_PERMISSION", UserAccess.Write) permission: UserAccess = getattr(cls, f"{request.method.upper()}_PERMISSION", UserAccess.Full)
return permission return permission
async def extract_data(self, list_keys: Optional[List[str]] = None) -> Dict[str, Any]: async def extract_data(self, list_keys: Optional[List[str]] = None) -> Dict[str, Any]:

View File

@ -21,9 +21,7 @@ import aiohttp_jinja2
from typing import Any, Dict from typing import Any, Dict
from ahriman import version
from ahriman.core.auth.helpers import authorized_userid from ahriman.core.auth.helpers import authorized_userid
from ahriman.core.util import pretty_datetime
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@ -34,37 +32,19 @@ 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
* auth - authorization descriptor, required * auth - authorization descriptor, required
* authenticated - alias to check if user can see the page, boolean, required
* control - HTML to insert for login control, HTML string, required * control - HTML to insert for login control, HTML string, required
* enabled - whether authorization is enabled by configuration or not, boolean, required * enabled - whether authorization is enabled by configuration or not, boolean, required
* username - authenticated username if any, string, null means not authenticated * username - authenticated username if any, string, null means not authenticated
* index_url - url to the repository index, string, optional * index_url - url to the repository index, string, optional
* packages - sorted list of packages properties, required
* base, string
* depends, sorted list of strings
* groups, sorted list of strings
* licenses, sorted list of strings
* packages, sorted list of strings
* status, string based on enum value
* status_color, string based on enum value
* timestamp, pretty printed datetime, string
* version, string
* web_url, string
* repository - repository name, string, required * repository - repository name, string, required
* service - service status properties, required
* status, string based on enum value
* status_color, string based on enum value
* timestamp, pretty printed datetime, string
* version - ahriman version, string, required
Attributes: Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self
""" """
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Safe GET_PERMISSION = HEAD_PERMISSION = UserAccess.Unauthorized
@aiohttp_jinja2.template("build-status.jinja2") @aiohttp_jinja2.template("build-status.jinja2")
async def get(self) -> Dict[str, Any]: async def get(self) -> Dict[str, Any]:
@ -74,43 +54,15 @@ class IndexView(BaseView):
Returns: Returns:
Dict[str, Any]: parameters for jinja template Dict[str, Any]: parameters for jinja template
""" """
# some magic to make it jinja-friendly
packages = [
{
"base": package.base,
"depends": package.depends,
"groups": package.groups,
"licenses": package.licenses,
"packages": list(sorted(package.packages)),
"status": status.status.value,
"status_color": status.status.bootstrap_color(),
"timestamp": pretty_datetime(status.timestamp),
"version": package.version,
"web_url": package.remote.web_url if package.remote is not None else None,
} for package, status in sorted(self.service.packages, key=lambda item: item[0].base)
]
service = {
"status": self.service.status.status.value,
"status_color": self.service.status.status.badges_color(),
"timestamp": pretty_datetime(self.service.status.timestamp),
}
# auth block
auth_username = await authorized_userid(self.request) auth_username = await authorized_userid(self.request)
authenticated = not self.validator.enabled or self.validator.safe_build_status or auth_username is not None
auth = { auth = {
"authenticated": authenticated,
"control": self.validator.auth_control, "control": self.validator.auth_control,
"enabled": self.validator.enabled, "enabled": self.validator.enabled,
"username": auth_username, "username": auth_username,
} }
return { return {
"architecture": self.service.architecture,
"auth": auth, "auth": auth,
"index_url": self.configuration.get("web", "index_url", fallback=None), "index_url": self.configuration.get("web", "index_url", fallback=None),
"packages": packages,
"repository": self.service.repository.name, "repository": self.service.repository.name,
"service": service,
"version": version.__version__,
} }

View File

@ -31,7 +31,7 @@ class AddView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
POST_PERMISSION = UserAccess.Write POST_PERMISSION = UserAccess.Full
async def post(self) -> None: async def post(self) -> None:
""" """

View File

@ -31,7 +31,7 @@ class RemoveView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
POST_PERMISSION = UserAccess.Write POST_PERMISSION = UserAccess.Full
async def post(self) -> None: async def post(self) -> None:
""" """

View File

@ -31,7 +31,7 @@ class RequestView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
POST_PERMISSION = UserAccess.Read POST_PERMISSION = UserAccess.Reporter
async def post(self) -> None: async def post(self) -> None:
""" """

View File

@ -35,7 +35,7 @@ class SearchView(BaseView):
HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self
""" """
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read GET_PERMISSION = HEAD_PERMISSION = UserAccess.Reporter
async def get(self) -> Response: async def get(self) -> Response:
""" """

View File

@ -1,71 +0,0 @@
#
# Copyright (c) 2021-2022 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 HTTPBadRequest, HTTPNoContent, Response, json_response
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
class AhrimanView(BaseView):
"""
service status web view
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
POST_PERMISSION = UserAccess.Write
async def get(self) -> Response:
"""
get current service status
Returns:
Response: 200 with service status object
"""
return json_response(self.service.status.view())
async def post(self) -> None:
"""
update service status
JSON body must be supplied, the following model is used::
{
"status": "unknown", # service status string, must be valid ``BuildStatusEnum``
}
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
"""
try:
data = await self.extract_data()
status = BuildStatusEnum(data["status"])
except Exception as e:
raise HTTPBadRequest(reason=str(e))
self.service.update_self(status)
raise HTTPNoContent()

View File

@ -37,7 +37,7 @@ class PackageView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
DELETE_PERMISSION = POST_PERMISSION = UserAccess.Write DELETE_PERMISSION = POST_PERMISSION = UserAccess.Full
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
async def get(self) -> Response: async def get(self) -> Response:

View File

@ -34,7 +34,7 @@ class PackagesView(BaseView):
""" """
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
POST_PERMISSION = UserAccess.Write POST_PERMISSION = UserAccess.Full
async def get(self) -> Response: async def get(self) -> Response:
""" """

View File

@ -17,9 +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 Response, json_response from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from ahriman import version from ahriman import version
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.counters import Counters from ahriman.models.counters import Counters
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
@ -33,9 +34,11 @@ class StatusView(BaseView):
Attributes: Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self GET_PERMISSION(UserAccess): (class attribute) get permissions of self
HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read GET_PERMISSION = HEAD_PERMISSION = UserAccess.Read
POST_PERMISSION = UserAccess.Full
async def get(self) -> Response: async def get(self) -> Response:
""" """
@ -46,9 +49,34 @@ class StatusView(BaseView):
""" """
counters = Counters.from_packages(self.service.packages) counters = Counters.from_packages(self.service.packages)
status = InternalStatus( status = InternalStatus(
status=self.service.status,
architecture=self.service.architecture, architecture=self.service.architecture,
packages=counters, packages=counters,
repository=self.service.repository.name, repository=self.service.repository.name,
version=version.__version__) version=version.__version__)
return json_response(status.view()) return json_response(status.view())
async def post(self) -> None:
"""
update service status
JSON body must be supplied, the following model is used::
{
"status": "unknown", # service status string, must be valid ``BuildStatusEnum``
}
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
"""
try:
data = await self.extract_data()
status = BuildStatusEnum(data["status"])
except Exception as e:
raise HTTPBadRequest(reason=str(e))
self.service.update_self(status)
raise HTTPNoContent()

View File

@ -34,7 +34,7 @@ class LoginView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
GET_PERMISSION = POST_PERMISSION = UserAccess.Safe GET_PERMISSION = POST_PERMISSION = UserAccess.Unauthorized
async def get(self) -> None: async def get(self) -> None:
""" """

View File

@ -32,7 +32,7 @@ class LogoutView(BaseView):
POST_PERMISSION(UserAccess): (class attribute) post permissions of self POST_PERMISSION(UserAccess): (class attribute) post permissions of self
""" """
POST_PERMISSION = UserAccess.Safe POST_PERMISSION = UserAccess.Unauthorized
async def post(self) -> None: async def post(self) -> None:
""" """

View File

@ -34,7 +34,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, package_ahr
""" """
args = _default_args(args) args = _default_args(args)
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create")
application_mock = mocker.patch("ahriman.core.status.client.Client.get_self") application_mock = mocker.patch("ahriman.core.status.client.Client.get_internal")
packages_mock = mocker.patch("ahriman.core.status.client.Client.get", packages_mock = mocker.patch("ahriman.core.status.client.Client.get",
return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success)), return_value=[(package_ahriman, BuildStatus(BuildStatusEnum.Success)),
(package_python_schedule, BuildStatus(BuildStatusEnum.Failed))]) (package_python_schedule, BuildStatus(BuildStatusEnum.Failed))])
@ -55,7 +55,7 @@ def test_run_empty_exception(args: argparse.Namespace, configuration: Configurat
args = _default_args(args) args = _default_args(args)
args.exit_code = True args.exit_code = True
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create")
mocker.patch("ahriman.core.status.client.Client.get_self") mocker.patch("ahriman.core.status.client.Client.get_internal")
mocker.patch("ahriman.core.status.client.Client.get", return_value=[]) mocker.patch("ahriman.core.status.client.Client.get", return_value=[])
check_mock = mocker.patch("ahriman.application.handlers.Handler.check_if_empty") check_mock = mocker.patch("ahriman.application.handlers.Handler.check_if_empty")

View File

@ -28,7 +28,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
args.as_service = False args.as_service = False
args.exit_code = False args.exit_code = False
args.password = "pa55w0rd" args.password = "pa55w0rd"
args.role = UserAccess.Read args.role = UserAccess.Reporter
args.secure = False args.secure = False
return args return args

View File

@ -519,7 +519,7 @@ def test_subparsers_user_add_option_role(parser: argparse.ArgumentParser) -> Non
""" """
args = parser.parse_args(["user-add", "username"]) args = parser.parse_args(["user-add", "username"])
assert isinstance(args.role, UserAccess) assert isinstance(args.role, UserAccess)
args = parser.parse_args(["user-add", "username", "--role", "write"]) args = parser.parse_args(["user-add", "username", "--role", "full"])
assert isinstance(args.role, UserAccess) assert isinstance(args.role, UserAccess)
@ -549,7 +549,7 @@ def test_subparsers_user_list_option_role(parser: argparse.ArgumentParser) -> No
""" """
user-list command must convert role option to useraccess instance user-list command must convert role option to useraccess instance
""" """
args = parser.parse_args(["user-list", "--role", "write"]) args = parser.parse_args(["user-list", "--role", "full"])
assert isinstance(args.role, UserAccess) assert isinstance(args.role, UserAccess)

View File

@ -8,7 +8,7 @@ from unittest import mock
from ahriman import version from ahriman import version
from ahriman.application.lock import Lock from ahriman.application.lock import Lock
from ahriman.core.exceptions import DuplicateRun, UnsafeRun from ahriman.core.exceptions import DuplicateRun, UnsafeRun
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
@ -57,7 +57,7 @@ def test_check_version(lock: Lock, mocker: MockerFixture) -> None:
must check version correctly must check version correctly
""" """
mocker.patch("ahriman.core.status.client.Client.get_internal", mocker.patch("ahriman.core.status.client.Client.get_internal",
return_value=InternalStatus(version=version.__version__)) return_value=InternalStatus(status=BuildStatus(), version=version.__version__))
logging_mock = mocker.patch("logging.Logger.warning") logging_mock = mocker.patch("logging.Logger.warning")
lock.check_version() lock.check_version()
@ -69,7 +69,7 @@ def test_check_version_mismatch(lock: Lock, mocker: MockerFixture) -> None:
must check mismatched version correctly must check mismatched version correctly
""" """
mocker.patch("ahriman.core.status.client.Client.get_internal", mocker.patch("ahriman.core.status.client.Client.get_internal",
return_value=InternalStatus(version="version")) return_value=InternalStatus(status=BuildStatus(), version="version"))
logging_mock = mocker.patch("logging.Logger.warning") logging_mock = mocker.patch("logging.Logger.warning")
lock.check_version() lock.check_version()

View File

@ -426,7 +426,7 @@ def user() -> User:
Returns: Returns:
User: user descriptor instance User: user descriptor instance
""" """
return User("user", "pa55w0rd", UserAccess.Read) return User("user", "pa55w0rd", UserAccess.Reporter)
@pytest.fixture @pytest.fixture

View File

@ -71,4 +71,4 @@ async def test_verify_access(auth: Auth, user: User) -> None:
must allow any access must allow any access
""" """
assert await auth.verify_access(user.username, user.access, None) assert await auth.verify_access(user.username, user.access, None)
assert await auth.verify_access(user.username, UserAccess.Write, None) assert await auth.verify_access(user.username, UserAccess.Full, None)

View File

@ -79,4 +79,4 @@ async def test_verify_access(mapping: Mapping, user: User, mocker: MockerFixture
""" """
mocker.patch("ahriman.core.database.SQLite.user_get", return_value=user) mocker.patch("ahriman.core.database.SQLite.user_get", return_value=user)
assert await mapping.verify_access(user.username, user.access, None) assert await mapping.verify_access(user.username, user.access, None)
assert not await mapping.verify_access(user.username, UserAccess.Write, None) assert not await mapping.verify_access(user.username, UserAccess.Full, None)

View File

@ -0,0 +1,8 @@
from ahriman.core.database.migrations.m002_user_access import steps
def test_migration_package_source() -> None:
"""
migration must not be empty
"""
assert steps

View File

@ -29,7 +29,7 @@ def test_user_list_filter_by_username(database: SQLite) -> None:
must return users filtered by its id must return users filtered by its id
""" """
first = User("1", "", UserAccess.Read) first = User("1", "", UserAccess.Read)
second = User("2", "", UserAccess.Write) second = User("2", "", UserAccess.Full)
third = User("3", "", UserAccess.Read) third = User("3", "", UserAccess.Read)
database.user_update(first) database.user_update(first)
@ -46,7 +46,7 @@ def test_user_list_filter_by_access(database: SQLite) -> None:
must return users filtered by its access must return users filtered by its access
""" """
first = User("1", "", UserAccess.Read) first = User("1", "", UserAccess.Read)
second = User("2", "", UserAccess.Write) second = User("2", "", UserAccess.Full)
third = User("3", "", UserAccess.Read) third = User("3", "", UserAccess.Read)
database.user_update(first) database.user_update(first)
@ -64,7 +64,7 @@ def test_user_list_filter_by_username_access(database: SQLite) -> None:
must return users filtered by its access and username must return users filtered by its access and username
""" """
first = User("1", "", UserAccess.Read) first = User("1", "", UserAccess.Read)
second = User("2", "", UserAccess.Write) second = User("2", "", UserAccess.Full)
third = User("3", "", UserAccess.Read) third = User("3", "", UserAccess.Read)
database.user_update(first) database.user_update(first)
@ -72,7 +72,7 @@ def test_user_list_filter_by_username_access(database: SQLite) -> None:
database.user_update(third) database.user_update(third)
assert database.user_list("1", UserAccess.Read) == [first] assert database.user_list("1", UserAccess.Read) == [first]
assert not database.user_list("1", UserAccess.Write) assert not database.user_list("1", UserAccess.Full)
def test_user_remove_update(database: SQLite, user: User) -> None: def test_user_remove_update(database: SQLite, user: User) -> None:
@ -92,6 +92,6 @@ def test_user_update(database: SQLite, user: User) -> None:
assert database.user_get(user.username) == user assert database.user_get(user.username) == user
new_user = user.hash_password("salt") new_user = user.hash_password("salt")
new_user.access = UserAccess.Write new_user.access = UserAccess.Full
database.user_update(new_user) database.user_update(new_user)
assert database.user_get(new_user.username) == new_user assert database.user_get(new_user.username) == new_user

View File

@ -3,7 +3,7 @@ from pytest_mock import MockerFixture
from ahriman.core.configuration import Configuration 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 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
@ -51,14 +51,11 @@ def test_get_internal(client: Client) -> None:
""" """
must return dummy status for web service must return dummy status for web service
""" """
assert client.get_internal() == InternalStatus() expected = InternalStatus(BuildStatus())
actual = client.get_internal()
actual.status.timestamp = expected.status.timestamp
assert actual == expected
def test_get_self(client: Client) -> None:
"""
must return unknown status for service
"""
assert client.get_self().status == BuildStatusEnum.Unknown
def test_remove(client: Client, package_ahriman: Package) -> None: def test_remove(client: Client, package_ahriman: Package) -> None:

View File

@ -13,14 +13,6 @@ from ahriman.models.package import Package
from ahriman.models.user import User from ahriman.models.user import User
def test_ahriman_url(web_client: WebClient) -> None:
"""
must generate service status url correctly
"""
assert web_client._ahriman_url.startswith(web_client.address)
assert web_client._ahriman_url.endswith("/status-api/v1/ahriman")
def test_status_url(web_client: WebClient) -> None: def test_status_url(web_client: WebClient) -> None:
""" """
must generate package status url correctly must generate package status url correctly
@ -173,7 +165,7 @@ def test_get_internal(web_client: WebClient, mocker: MockerFixture) -> None:
must return web service status must return web service status
""" """
response_obj = Response() response_obj = Response()
response_obj._content = json.dumps(InternalStatus(architecture="x86_64").view()).encode("utf8") response_obj._content = json.dumps(InternalStatus(BuildStatus(), architecture="x86_64").view()).encode("utf8")
response_obj.status_code = 200 response_obj.status_code = 200
requests_mock = mocker.patch("requests.Session.get", return_value=response_obj) requests_mock = mocker.patch("requests.Session.get", return_value=response_obj)
@ -188,7 +180,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.Session.get", side_effect=Exception()) mocker.patch("requests.Session.get", side_effect=Exception())
assert web_client.get_internal() == InternalStatus() assert web_client.get_internal().architecture is None
def test_get_internal_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: def test_get_internal_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None:
@ -196,38 +188,7 @@ def test_get_internal_failed_http_error(web_client: WebClient, mocker: MockerFix
must suppress HTTP exception happened during web service status getting must suppress HTTP exception happened during web service status getting
""" """
mocker.patch("requests.Session.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().architecture is None
def test_get_self(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must return service status
"""
response_obj = Response()
response_obj._content = json.dumps(BuildStatus().view()).encode("utf8")
response_obj.status_code = 200
requests_mock = mocker.patch("requests.Session.get", return_value=response_obj)
result = web_client.get_self()
requests_mock.assert_called_once_with(web_client._ahriman_url)
assert result.status == BuildStatusEnum.Unknown
def test_get_self_failed(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during service status getting
"""
mocker.patch("requests.Session.get", side_effect=Exception())
assert web_client.get_self().status == BuildStatusEnum.Unknown
def test_get_self_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during service status getting
"""
mocker.patch("requests.Session.get", side_effect=requests.exceptions.HTTPError())
assert web_client.get_self().status == BuildStatusEnum.Unknown
def test_remove(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: def test_remove(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:

View File

@ -320,9 +320,11 @@ def test_walk(resource_path_root: Path) -> None:
resource_path_root / "models" / "package_gcc10_srcinfo", resource_path_root / "models" / "package_gcc10_srcinfo",
resource_path_root / "models" / "package_tpacpi-bat-git_srcinfo", resource_path_root / "models" / "package_tpacpi-bat-git_srcinfo",
resource_path_root / "models" / "package_yay_srcinfo", resource_path_root / "models" / "package_yay_srcinfo",
resource_path_root / "web" / "templates" / "build-status" / "failed-modal.jinja2",
resource_path_root / "web" / "templates" / "build-status" / "login-modal.jinja2", resource_path_root / "web" / "templates" / "build-status" / "login-modal.jinja2",
resource_path_root / "web" / "templates" / "build-status" / "package-actions-modals.jinja2", resource_path_root / "web" / "templates" / "build-status" / "package-add-modal.jinja2",
resource_path_root / "web" / "templates" / "build-status" / "package-actions-script.jinja2", resource_path_root / "web" / "templates" / "build-status" / "success-modal.jinja2",
resource_path_root / "web" / "templates" / "build-status" / "table.jinja2",
resource_path_root / "web" / "templates" / "static" / "favicon.ico", resource_path_root / "web" / "templates" / "static" / "favicon.ico",
resource_path_root / "web" / "templates" / "utils" / "bootstrap-scripts.jinja2", resource_path_root / "web" / "templates" / "utils" / "bootstrap-scripts.jinja2",
resource_path_root / "web" / "templates" / "utils" / "style.jinja2", resource_path_root / "web" / "templates" / "utils" / "style.jinja2",

View File

@ -54,7 +54,8 @@ def internal_status(counters: Counters) -> InternalStatus:
Returns: Returns:
InternalStatus: internal status test instance InternalStatus: internal status test instance
""" """
return InternalStatus(architecture="x86_64", return InternalStatus(status=BuildStatus(),
architecture="x86_64",
packages=counters, packages=counters,
version=version.__version__, version=version.__version__,
repository="aur-clone") repository="aur-clone")

View File

@ -4,31 +4,6 @@ import time
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
def test_build_status_enum_badges_color() -> None:
"""
status color must be one of shields.io supported
"""
SUPPORTED_COLORS = [
"brightgreen", "green", "yellowgreen", "yellow", "orange", "red", "blue", "lightgrey",
"success", "important", "critical", "informational", "inactive", "blueviolet"
]
for status in BuildStatusEnum:
assert status.badges_color() in SUPPORTED_COLORS
def test_build_status_enum_bootstrap_color() -> None:
"""
status color must be one of bootstrap supported
"""
SUPPORTED_COLORS = [
"primary", "secondary", "success", "danger", "warning", "info", "light", "dark"
]
for status in BuildStatusEnum:
assert status.bootstrap_color() in SUPPORTED_COLORS
def test_build_status_init_1() -> None: def test_build_status_init_1() -> None:
""" """
must construct status object from None must construct status object from None

View File

@ -6,9 +6,10 @@ def test_from_option(user: User) -> None:
""" """
must generate user from options must generate user from options
""" """
user.access = UserAccess.Read
assert User.from_option(user.username, user.password) == user assert User.from_option(user.username, user.password) == user
# default is read access # default is read access
user.access = UserAccess.Write user.access = UserAccess.Full
assert User.from_option(user.username, user.password) != user assert User.from_option(user.username, user.password) != user
assert User.from_option(user.username, user.password, user.access) == user assert User.from_option(user.username, user.password, user.access) == user
@ -72,16 +73,16 @@ def test_verify_access_read(user: User) -> None:
""" """
user.access = UserAccess.Read user.access = UserAccess.Read
assert user.verify_access(UserAccess.Read) assert user.verify_access(UserAccess.Read)
assert not user.verify_access(UserAccess.Write) assert not user.verify_access(UserAccess.Full)
def test_verify_access_write(user: User) -> None: def test_verify_access_write(user: User) -> None:
""" """
user with write access must be able to do anything user with write access must be able to do anything
""" """
user.access = UserAccess.Write user.access = UserAccess.Full
assert user.verify_access(UserAccess.Read) assert user.verify_access(UserAccess.Read)
assert user.verify_access(UserAccess.Write) assert user.verify_access(UserAccess.Full)
def test_repr(user: User) -> None: def test_repr(user: User) -> None:

View File

@ -0,0 +1,51 @@
from pytest_mock import MockerFixture
from ahriman.models.user_access import UserAccess
def test_permits_full() -> None:
"""
full access must allow everything
"""
assert UserAccess.Full.permits(UserAccess.Full)
assert UserAccess.Full.permits(UserAccess.Reporter)
assert UserAccess.Full.permits(UserAccess.Read)
assert UserAccess.Full.permits(UserAccess.Unauthorized)
def test_permits_reporter() -> None:
"""
reporter access must allow everything except full
"""
assert not UserAccess.Reporter.permits(UserAccess.Full)
assert UserAccess.Reporter.permits(UserAccess.Reporter)
assert UserAccess.Reporter.permits(UserAccess.Read)
assert UserAccess.Reporter.permits(UserAccess.Unauthorized)
def test_permits_read() -> None:
"""
read access must allow read only and unauthorized
"""
assert not UserAccess.Read.permits(UserAccess.Full)
assert not UserAccess.Read.permits(UserAccess.Reporter)
assert UserAccess.Read.permits(UserAccess.Read)
assert UserAccess.Read.permits(UserAccess.Unauthorized)
def test_permits_unauthorized() -> None:
"""
unauthorized access must only allow unauthorized
"""
assert not UserAccess.Unauthorized.permits(UserAccess.Full)
assert not UserAccess.Unauthorized.permits(UserAccess.Reporter)
assert not UserAccess.Unauthorized.permits(UserAccess.Read)
assert UserAccess.Unauthorized.permits(UserAccess.Unauthorized)
def test_permits_unknown(mocker: MockerFixture) -> None:
"""
must return False in case if input does not match
"""
mocker.patch.object(UserAccess, "_member_names_", ["Read"])
assert not UserAccess.Full.permits(UserAccess.Unauthorized)

View File

@ -1,12 +1,16 @@
import pytest import pytest
from asyncio import BaseEventLoop
from aiohttp import web from aiohttp import web
from aiohttp.test_utils import TestClient
from collections import namedtuple from collections import namedtuple
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from typing import Any from typing import Any
from unittest.mock import MagicMock
import ahriman.core.auth.helpers import ahriman.core.auth.helpers
from ahriman.core.auth import OAuth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite from ahriman.core.database import SQLite
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
@ -105,3 +109,61 @@ def application_with_debug(configuration: Configuration, user: User, spawner: Sp
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create") mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create")
mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", False) mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", False)
return setup_service("x86_64", configuration, spawner) return setup_service("x86_64", configuration, spawner)
@pytest.fixture
def client(application: web.Application, event_loop: BaseEventLoop,
aiohttp_client: Any, mocker: MockerFixture) -> TestClient:
"""
web client fixture
Args:
application(web.Application): application fixture
event_loop(BaseEventLoop): context event loop
aiohttp_client(Any): aiohttp client fixture
mocker(MockerFixture): mocker object
Returns:
TestClient: web client test instance
"""
mocker.patch("pathlib.Path.iterdir", return_value=[])
return event_loop.run_until_complete(aiohttp_client(application))
@pytest.fixture
def client_with_auth(application_with_auth: web.Application, event_loop: BaseEventLoop,
aiohttp_client: Any, mocker: MockerFixture) -> TestClient:
"""
web client fixture with full authorization functions
Args:
application_with_auth(web.Application): application fixture
event_loop(BaseEventLoop): context event loop
aiohttp_client(Any): aiohttp client fixture
mocker(MockerFixture): mocker object
Returns:
TestClient: web client test instance
"""
mocker.patch("pathlib.Path.iterdir", return_value=[])
return event_loop.run_until_complete(aiohttp_client(application_with_auth))
@pytest.fixture
def client_with_oauth_auth(application_with_auth: web.Application, event_loop: BaseEventLoop,
aiohttp_client: Any, mocker: MockerFixture) -> TestClient:
"""
web client fixture with full authorization functions
Args:
application_with_auth(web.Application): application fixture
event_loop(BaseEventLoop): context event loop
aiohttp_client(Any): aiohttp client fixture
mocker(MockerFixture): mocker object
Returns:
TestClient: web client test instance
"""
mocker.patch("pathlib.Path.iterdir", return_value=[])
application_with_auth["validator"] = MagicMock(spec=OAuth)
return event_loop.run_until_complete(aiohttp_client(application_with_auth))

View File

@ -1,6 +1,7 @@
import pytest import pytest
from aiohttp import web from aiohttp import web
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@ -63,11 +64,43 @@ async def test_auth_handler_api(mocker: MockerFixture) -> None:
request_handler.get_permission.return_value = UserAccess.Read request_handler.get_permission.return_value = UserAccess.Read
check_permission_mock = mocker.patch("aiohttp_security.check_permission") check_permission_mock = mocker.patch("aiohttp_security.check_permission")
handler = auth_handler() handler = auth_handler(allow_read_only=False)
await handler(aiohttp_request, request_handler) await handler(aiohttp_request, request_handler)
check_permission_mock.assert_called_once_with(aiohttp_request, UserAccess.Read, aiohttp_request.path) check_permission_mock.assert_called_once_with(aiohttp_request, UserAccess.Read, aiohttp_request.path)
async def test_auth_handler_static(client_with_auth: TestClient, mocker: MockerFixture) -> None:
"""
must allow static calls
"""
check_permission_mock = mocker.patch("aiohttp_security.check_permission")
await client_with_auth.get("/static/favicon.ico")
check_permission_mock.assert_not_called()
async def test_auth_handler_unauthorized(client_with_auth: TestClient, mocker: MockerFixture) -> None:
"""
must allow pages with unauthorized access
"""
check_permission_mock = mocker.patch("aiohttp_security.check_permission")
await client_with_auth.get("/")
check_permission_mock.assert_not_called()
async def test_auth_handler_allow_read_only(mocker: MockerFixture) -> None:
"""
must allow pages with allow read only flag
"""
aiohttp_request = pytest.helpers.request("", "/status-api", "GET")
request_handler = AsyncMock()
request_handler.get_permission.return_value = UserAccess.Read
check_permission_mock = mocker.patch("aiohttp_security.check_permission")
handler = auth_handler(allow_read_only=True)
await handler(aiohttp_request, request_handler)
check_permission_mock.assert_not_called()
async def test_auth_handler_api_no_method(mocker: MockerFixture) -> None: async def test_auth_handler_api_no_method(mocker: MockerFixture) -> None:
""" """
must ask for write permission if handler does not have get_permission method must ask for write permission if handler does not have get_permission method
@ -77,9 +110,9 @@ async def test_auth_handler_api_no_method(mocker: MockerFixture) -> None:
request_handler.get_permission = None request_handler.get_permission = None
check_permission_mock = mocker.patch("aiohttp_security.check_permission") check_permission_mock = mocker.patch("aiohttp_security.check_permission")
handler = auth_handler() handler = auth_handler(allow_read_only=False)
await handler(aiohttp_request, request_handler) await handler(aiohttp_request, request_handler)
check_permission_mock.assert_called_once_with(aiohttp_request, UserAccess.Write, aiohttp_request.path) check_permission_mock.assert_called_once_with(aiohttp_request, UserAccess.Full, aiohttp_request.path)
async def test_auth_handler_api_post(mocker: MockerFixture) -> None: async def test_auth_handler_api_post(mocker: MockerFixture) -> None:
@ -88,12 +121,12 @@ async def test_auth_handler_api_post(mocker: MockerFixture) -> None:
""" """
aiohttp_request = pytest.helpers.request("", "/status-api", "POST") aiohttp_request = pytest.helpers.request("", "/status-api", "POST")
request_handler = AsyncMock() request_handler = AsyncMock()
request_handler.get_permission.return_value = UserAccess.Write request_handler.get_permission.return_value = UserAccess.Full
check_permission_mock = mocker.patch("aiohttp_security.check_permission") check_permission_mock = mocker.patch("aiohttp_security.check_permission")
handler = auth_handler() handler = auth_handler(allow_read_only=False)
await handler(aiohttp_request, request_handler) await handler(aiohttp_request, request_handler)
check_permission_mock.assert_called_once_with(aiohttp_request, UserAccess.Write, aiohttp_request.path) check_permission_mock.assert_called_once_with(aiohttp_request, UserAccess.Full, aiohttp_request.path)
async def test_auth_handler_read(mocker: MockerFixture) -> None: async def test_auth_handler_read(mocker: MockerFixture) -> None:
@ -106,7 +139,7 @@ async def test_auth_handler_read(mocker: MockerFixture) -> None:
request_handler.get_permission.return_value = UserAccess.Read request_handler.get_permission.return_value = UserAccess.Read
check_permission_mock = mocker.patch("aiohttp_security.check_permission") check_permission_mock = mocker.patch("aiohttp_security.check_permission")
handler = auth_handler() handler = auth_handler(allow_read_only=False)
await handler(aiohttp_request, request_handler) await handler(aiohttp_request, request_handler)
check_permission_mock.assert_called_once_with(aiohttp_request, UserAccess.Read, aiohttp_request.path) check_permission_mock.assert_called_once_with(aiohttp_request, UserAccess.Read, aiohttp_request.path)
@ -118,12 +151,12 @@ async def test_auth_handler_write(mocker: MockerFixture) -> None:
for method in ("CONNECT", "DELETE", "PATCH", "POST", "PUT", "TRACE"): for method in ("CONNECT", "DELETE", "PATCH", "POST", "PUT", "TRACE"):
aiohttp_request = pytest.helpers.request("", "", method) aiohttp_request = pytest.helpers.request("", "", method)
request_handler = AsyncMock() request_handler = AsyncMock()
request_handler.get_permission.return_value = UserAccess.Write request_handler.get_permission.return_value = UserAccess.Full
check_permission_mock = mocker.patch("aiohttp_security.check_permission") check_permission_mock = mocker.patch("aiohttp_security.check_permission")
handler = auth_handler() handler = auth_handler(allow_read_only=False)
await handler(aiohttp_request, request_handler) await handler(aiohttp_request, request_handler)
check_permission_mock.assert_called_once_with(aiohttp_request, UserAccess.Write, aiohttp_request.path) check_permission_mock.assert_called_once_with(aiohttp_request, UserAccess.Full, aiohttp_request.path)
def test_setup_auth(application_with_auth: web.Application, auth: Auth, mocker: MockerFixture) -> None: def test_setup_auth(application_with_auth: web.Application, auth: Auth, mocker: MockerFixture) -> None:

View File

@ -1,13 +1,7 @@
import pytest import pytest
from aiohttp import web from aiohttp import web
from asyncio import BaseEventLoop
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
from typing import Any
from unittest.mock import MagicMock
from ahriman.core.auth import OAuth
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@ -23,61 +17,3 @@ def base(application: web.Application) -> BaseView:
BaseView: generated base view fixture BaseView: generated base view fixture
""" """
return BaseView(pytest.helpers.request(application, "", "")) return BaseView(pytest.helpers.request(application, "", ""))
@pytest.fixture
def client(application: web.Application, event_loop: BaseEventLoop,
aiohttp_client: Any, mocker: MockerFixture) -> TestClient:
"""
web client fixture
Args:
application(web.Application): application fixture
event_loop(BaseEventLoop): context event loop
aiohttp_client(Any): aiohttp client fixture
mocker(MockerFixture): mocker object
Returns:
TestClient: web client test instance
"""
mocker.patch("pathlib.Path.iterdir", return_value=[])
return event_loop.run_until_complete(aiohttp_client(application))
@pytest.fixture
def client_with_auth(application_with_auth: web.Application, event_loop: BaseEventLoop,
aiohttp_client: Any, mocker: MockerFixture) -> TestClient:
"""
web client fixture with full authorization functions
Args:
application_with_auth(web.Application): application fixture
event_loop(BaseEventLoop): context event loop
aiohttp_client(Any): aiohttp client fixture
mocker(MockerFixture): mocker object
Returns:
TestClient: web client test instance
"""
mocker.patch("pathlib.Path.iterdir", return_value=[])
return event_loop.run_until_complete(aiohttp_client(application_with_auth))
@pytest.fixture
def client_with_oauth_auth(application_with_auth: web.Application, event_loop: BaseEventLoop,
aiohttp_client: Any, mocker: MockerFixture) -> TestClient:
"""
web client fixture with full authorization functions
Args:
application_with_auth(web.Application): application fixture
event_loop(BaseEventLoop): context event loop
aiohttp_client(Any): aiohttp client fixture
mocker(MockerFixture): mocker object
Returns:
TestClient: web client test instance
"""
mocker.patch("pathlib.Path.iterdir", return_value=[])
application_with_auth["validator"] = MagicMock(spec=OAuth)
return event_loop.run_until_complete(aiohttp_client(application_with_auth))

View File

@ -13,7 +13,7 @@ async def test_get_permission() -> None:
""" """
for method in ("POST",): for method in ("POST",):
request = pytest.helpers.request("", "", method) request = pytest.helpers.request("", "", method)
assert await AddView.get_permission(request) == UserAccess.Write assert await AddView.get_permission(request) == UserAccess.Full
async def test_post(client: TestClient, mocker: MockerFixture) -> None: async def test_post(client: TestClient, mocker: MockerFixture) -> None:

View File

@ -13,7 +13,7 @@ async def test_get_permission() -> None:
""" """
for method in ("POST",): for method in ("POST",):
request = pytest.helpers.request("", "", method) request = pytest.helpers.request("", "", method)
assert await RemoveView.get_permission(request) == UserAccess.Write assert await RemoveView.get_permission(request) == UserAccess.Full
async def test_post(client: TestClient, mocker: MockerFixture) -> None: async def test_post(client: TestClient, mocker: MockerFixture) -> None:

View File

@ -13,7 +13,7 @@ async def test_get_permission() -> None:
""" """
for method in ("POST",): for method in ("POST",):
request = pytest.helpers.request("", "", method) request = pytest.helpers.request("", "", method)
assert await RequestView.get_permission(request) == UserAccess.Read assert await RequestView.get_permission(request) == UserAccess.Reporter
async def test_post(client: TestClient, mocker: MockerFixture) -> None: async def test_post(client: TestClient, mocker: MockerFixture) -> None:

View File

@ -14,7 +14,7 @@ async def test_get_permission() -> None:
""" """
for method in ("GET", "HEAD"): for method in ("GET", "HEAD"):
request = pytest.helpers.request("", "", method) request = pytest.helpers.request("", "", method)
assert await SearchView.get_permission(request) == UserAccess.Read assert await SearchView.get_permission(request) == UserAccess.Reporter
async def test_get(client: TestClient, aur_package_ahriman: AURPackage, mocker: MockerFixture) -> None: async def test_get(client: TestClient, aur_package_ahriman: AURPackage, mocker: MockerFixture) -> None:

View File

@ -1,65 +0,0 @@
import pytest
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.user_access import UserAccess
from ahriman.web.views.status.ahriman import AhrimanView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET", "HEAD"):
request = pytest.helpers.request("", "", method)
assert await AhrimanView.get_permission(request) == UserAccess.Read
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await AhrimanView.get_permission(request) == UserAccess.Write
async def test_get(client: TestClient) -> None:
"""
must return valid service status
"""
response = await client.get("/status-api/v1/ahriman")
status = BuildStatus.from_json(await response.json())
assert response.ok
assert status.status == BuildStatusEnum.Unknown
async def test_post(client: TestClient) -> None:
"""
must update service status correctly
"""
payload = {"status": BuildStatusEnum.Success.value}
post_response = await client.post("/status-api/v1/ahriman", json=payload)
assert post_response.status == 204
response = await client.get("/status-api/v1/ahriman")
status = BuildStatus.from_json(await response.json())
assert response.ok
assert status.status == BuildStatusEnum.Success
async def test_post_exception(client: TestClient) -> None:
"""
must raise exception on invalid payload
"""
post_response = await client.post("/status-api/v1/ahriman", json={})
assert post_response.status == 400
async def test_post_exception_inside(client: TestClient, mocker: MockerFixture) -> None:
"""
exception handler must handle 500 errors
"""
payload = {"status": BuildStatusEnum.Success.value}
mocker.patch("ahriman.core.status.watcher.Watcher.update_self", side_effect=Exception())
post_response = await client.post("/status-api/v1/ahriman", json=payload)
assert post_response.status == 500

View File

@ -17,7 +17,7 @@ async def test_get_permission() -> None:
assert await PackageView.get_permission(request) == UserAccess.Read assert await PackageView.get_permission(request) == UserAccess.Read
for method in ("DELETE", "POST"): for method in ("DELETE", "POST"):
request = pytest.helpers.request("", "", method) request = pytest.helpers.request("", "", method)
assert await PackageView.get_permission(request) == UserAccess.Write assert await PackageView.get_permission(request) == UserAccess.Full
async def test_get(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None: async def test_get(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None:

View File

@ -18,7 +18,7 @@ async def test_get_permission() -> None:
assert await PackagesView.get_permission(request) == UserAccess.Read assert await PackagesView.get_permission(request) == UserAccess.Read
for method in ("POST",): for method in ("POST",):
request = pytest.helpers.request("", "", method) request = pytest.helpers.request("", "", method)
assert await PackagesView.get_permission(request) == UserAccess.Write assert await PackagesView.get_permission(request) == UserAccess.Full
async def test_get(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None: async def test_get(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None:

View File

@ -1,10 +1,12 @@
import pytest import pytest
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
import ahriman.version as version import ahriman.version as version
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.views.status.status import StatusView from ahriman.web.views.status.status import StatusView
@ -17,6 +19,9 @@ async def test_get_permission() -> None:
for method in ("GET", "HEAD"): for method in ("GET", "HEAD"):
request = pytest.helpers.request("", "", method) request = pytest.helpers.request("", "", method)
assert await StatusView.get_permission(request) == UserAccess.Read assert await StatusView.get_permission(request) == UserAccess.Read
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await StatusView.get_permission(request) == UserAccess.Full
async def test_get(client: TestClient, package_ahriman: Package) -> None: async def test_get(client: TestClient, package_ahriman: Package) -> None:
@ -33,3 +38,37 @@ async def test_get(client: TestClient, package_ahriman: Package) -> None:
assert json["version"] == version.__version__ assert json["version"] == version.__version__
assert json["packages"] assert json["packages"]
assert json["packages"]["total"] == 1 assert json["packages"]["total"] == 1
async def test_post(client: TestClient) -> None:
"""
must update service status correctly
"""
payload = {"status": BuildStatusEnum.Success.value}
post_response = await client.post("/status-api/v1/status", json=payload)
assert post_response.status == 204
response = await client.get("/status-api/v1/status")
status = InternalStatus.from_json(await response.json())
assert response.ok
assert status.status.status == BuildStatusEnum.Success
async def test_post_exception(client: TestClient) -> None:
"""
must raise exception on invalid payload
"""
post_response = await client.post("/status-api/v1/status", json={})
assert post_response.status == 400
async def test_post_exception_inside(client: TestClient, mocker: MockerFixture) -> None:
"""
exception handler must handle 500 errors
"""
payload = {"status": BuildStatusEnum.Success.value}
mocker.patch("ahriman.core.status.watcher.Watcher.update_self", side_effect=Exception())
post_response = await client.post("/status-api/v1/status", json=payload)
assert post_response.status == 500

View File

@ -12,7 +12,7 @@ async def test_get_permission() -> None:
""" """
for method in ("GET", "HEAD"): for method in ("GET", "HEAD"):
request = pytest.helpers.request("", "", method) request = pytest.helpers.request("", "", method)
assert await IndexView.get_permission(request) == UserAccess.Safe assert await IndexView.get_permission(request) == UserAccess.Unauthorized
async def test_get(client_with_auth: TestClient) -> None: async def test_get(client_with_auth: TestClient) -> None:

View File

@ -14,7 +14,7 @@ async def test_get_permission() -> None:
""" """
for method in ("GET", "POST"): for method in ("GET", "POST"):
request = pytest.helpers.request("", "", method) request = pytest.helpers.request("", "", method)
assert await LoginView.get_permission(request) == UserAccess.Safe assert await LoginView.get_permission(request) == UserAccess.Unauthorized
async def test_get_default_validator(client_with_auth: TestClient) -> None: async def test_get_default_validator(client_with_auth: TestClient) -> None:

View File

@ -14,7 +14,7 @@ async def test_get_permission() -> None:
""" """
for method in ("POST",): for method in ("POST",):
request = pytest.helpers.request("", "", method) request = pytest.helpers.request("", "", method)
assert await LogoutView.get_permission(request) == UserAccess.Safe assert await LogoutView.get_permission(request) == UserAccess.Unauthorized
async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None: async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None:

View File

@ -14,7 +14,7 @@ client_secret = client_secret
oauth_provider = GoogleClient oauth_provider = GoogleClient
oauth_scopes = https://www.googleapis.com/auth/userinfo.email oauth_scopes = https://www.googleapis.com/auth/userinfo.email
salt = salt salt = salt
safe_build_status = no allow_read_only = no
[build] [build]
archbuild_flags = archbuild_flags =