add key-import button to interface

This commit is contained in:
Evgenii Alekseev 2022-11-25 02:12:05 +02:00
parent 577bd9e5f8
commit 9fa1fa108f
41 changed files with 727 additions and 177 deletions

View File

@ -22,6 +22,11 @@ fi
[ -d "$AHRIMAN_REPOSITORY_ROOT" ] || mkdir "$AHRIMAN_REPOSITORY_ROOT"
chown "$AHRIMAN_USER":"$AHRIMAN_USER" "$AHRIMAN_REPOSITORY_ROOT"
# create .gnupg directory which is required for keys
AHRIMAN_GNUPG_HOME="$(getent passwd "$AHRIMAN_USER" | cut -d : -f 6)/.gnupg"
[ -d "$AHRIMAN_GNUPG_HOME" ] || mkdir -m700 "$AHRIMAN_GNUPG_HOME"
chown "$AHRIMAN_USER":"$AHRIMAN_USER" "$AHRIMAN_GNUPG_HOME"
# run built-in setup command
AHRIMAN_SETUP_ARGS=("--build-as-user" "$AHRIMAN_USER")
AHRIMAN_SETUP_ARGS+=("--packager" "$AHRIMAN_PACKAGER")

View File

@ -12,6 +12,14 @@ ahriman.web.views.service.add module
:no-undoc-members:
:show-inheritance:
ahriman.web.views.service.pgp module
------------------------------------
.. automodule:: ahriman.web.views.service.pgp
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.service.remove module
---------------------------------------
@ -36,6 +44,14 @@ ahriman.web.views.service.search module
:no-undoc-members:
:show-inheritance:
ahriman.web.views.service.update module
---------------------------------------
.. automodule:: ahriman.web.views.service.update
:members:
:no-undoc-members:
:show-inheritance:
Module contents
---------------

View File

@ -219,6 +219,62 @@ Also, there is command ``repo-remove-unknown`` which checks packages in AUR and
Remove commands also remove any package files (patches, caches etc).
How to sign repository
^^^^^^^^^^^^^^^^^^^^^^
Repository sign feature is available in several configurations. The recommended way is just to sign repository database file by single key instead of trying to sign each package. However, the steps are pretty same, just configuration is a bit differ. For more details about options kindly refer to :doc:`configuration reference <configuration>`.
#.
First you would need to create the key on your local machine:
.. code-block:: shell
gpg --full-generate-key
This command will prompt you for several questions. Most of them may be left default, but you will need to fill real name and email address with some data. Because at the moment the service doesn't support passphrases, it must be left blank.
#.
The command above will generate key and print its hash, something like ``8BE91E5A773FB48AC05CC1EDBED105AED6246B39``. Copy it.
#.
Export your private key by using the hash above:
.. code-block:: shell
gpg --export-secret-keys -a 8BE91E5A773FB48AC05CC1EDBED105AED6246B39 > repository-key.gpg
#.
Copy the specified key to the build machine (i.e. where the service is running).
#.
Import the specified key to the service user:
.. code-block:: shell
sudo -u ahriman gpg --import repository-key.gpg
Don't forget to remove the key from filesystem after import.
#.
Change trust level to ``ultimate``:
.. code-block:: shell
sudo -u ahriman gpg --edit-key 8BE91E5A773FB48AC05CC1EDBED105AED6246B39
The command above will drop you into gpg shell, in which you will need to type ``trust``, choose ``5 = I trust ultimately``, confirm and exit ``quit``.
#.
Proceed with service configuration according to the :doc:`configuration <configuration>`:
.. code-block:: ini
[sign]
target = repository
key = 8BE91E5A773FB48AC05CC1EDBED105AED6246B39
How to rebuild packages after library update
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -26,15 +26,18 @@
<div class="container">
<div id="toolbar">
{% if not auth.enabled or auth.username is not none %}
<button id="add-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-form" hidden>
<button id="package-add-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#package-add-modal" hidden>
<i class="bi bi-plus"></i> add
</button>
<button id="update-btn" class="btn btn-secondary" onclick="updatePackages()" hidden>
<button id="package-update-btn" class="btn btn-secondary" onclick="updatePackages()" hidden>
<i class="bi bi-play"></i> update
</button>
<button id="remove-btn" class="btn btn-danger" onclick="removePackages()" disabled hidden>
<button id="package-remove-btn" class="btn btn-danger" onclick="removePackages()" disabled hidden>
<i class="bi bi-trash"></i> remove
</button>
<button id="key-import-btn" class="btn btn-info" data-bs-toggle="modal" data-bs-target="#key-import-modal" hidden>
<i class="bi bi-key"></i> import key
</button>
{% endif %}
<button class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i> reload
@ -112,6 +115,7 @@
{% include "build-status/success-modal.jinja2" %}
{% include "build-status/package-add-modal.jinja2" %}
{% include "build-status/key-import-modal.jinja2" %}
{% include "build-status/package-info-modal.jinja2" %}

View File

@ -1,13 +1,13 @@
<div id="failed-form" tabindex="-1" role="dialog" class="modal fade">
<div id="failed-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h4 id="error-title" class="modal-title"></h4>
<h4 id="failed-title" class="modal-title"></h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<p id="error-description"></p>
<p id="error-details"></p>
<p id="failed-description"></p>
<p id="failed-details"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal"><i class="bi bi-x"></i> close</button>
@ -17,16 +17,18 @@
</div>
<script>
const failedForm = $("#failed-form");
const errorDescription = $("#error-description");
const errorDetails = $("#error-details");
const errorTitle = $("#error-title");
failedForm.on("hidden.bs.modal", () => { reload(); });
const failedModal = $("#failed-modal");
failedModal.on("hidden.bs.modal", () => { reload(); });
const failedDescription = $("#failed-description");
const failedDetails = $("#failed-details");
const failedTitle = $("#failed-title");
function showFailure(title, description, details) {
errorTitle.text(title);
errorDescription.text(description);
errorDetails.text(details);
failedForm.modal("show");
failedTitle.text(title);
failedDescription.text(description);
failedDetails.text(details);
failedModal.modal("show");
}
</script>

View File

@ -0,0 +1,92 @@
<div id="key-import-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<form id="key-import-form" onsubmit="return false">
<div class="modal-header">
<h4 class="modal-title">Import key from PGP server</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="key-fingerprint-input" class="col-sm-2 col-form-label">fingerprint</label>
<div class="col-sm-10">
<input id="key-fingerprint-input" type="text" class="form-control" placeholder="PGP key fingerprint" name="key" required>
</div>
</div>
<div class="form-group row">
<label for="key-server-input" class="col-sm-2 col-form-label">key server</label>
<div class="col-sm-10">
<input id="key-server-input" type="text" class="form-control" placeholder="PGP key server" name="server" value="keyserver.ubuntu.com" required>
</div>
</div>
<div class="form-group row">
<div class="col-sm-2"></div>
<div class="col-sm-10">
<pre class="language-less"><code id="key-body-input" class="pre-scrollable language-less"></code><button id="key-copy-btn" type="button" class="btn language-less" onclick="copyPgpKey()"><i class="bi bi-clipboard"></i> copy</button></pre>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" onclick="importPgpKey()"><i class="bi bi-play"></i> import</button>
<button type="submit" class="btn btn-success" onclick="fetchPgpKey()"><i class="bi bi-arrow-clockwise"></i> fetch</button>
</div>
</form>
</div>
</div>
</div>
<script>
const keyImportModal = $("#key-import-modal");
const keyImportForm = $("#key-import-form");
keyImportModal.on("hidden.bs.modal", () => {
keyBodyInput.text("");
keyImportForm.trigger("reset");
});
const keyBodyInput = $("#key-body-input");
const keyCopyButton = $("#key-copy-btn");
const keyFingerprintInput = $("#key-fingerprint-input");
const keyServerInput = $("#key-server-input");
async function copyPgpKey() {
const logs = keyBodyInput.text();
await copyToClipboard(logs, keyCopyButton);
}
function fetchPgpKey() {
const key = keyFingerprintInput.val();
const server = keyServerInput.val();
if (key && server) {
$.ajax({
url: "/api/v1/service/pgp",
data: {"key": key, "server": server},
type: "GET",
dataType: "json",
success: response => { keyBodyInput.text(response.key); },
});
}
}
function importPgpKey() {
const key = keyFingerprintInput.val();
const server = keyServerInput.val();
if (key && server) {
$.ajax({
url: "/api/v1/service/pgp",
data: JSON.stringify({key: key, server: server}),
type: "POST",
contentType: "application/json",
success: _ => {
keyImportModal.modal("hide");
showSuccess("Success", `Key ${key} has been imported`, "");
},
error: (jqXHR, _, errorThrown) => {
showFailure("Action failed", `Could not import key ${key} from ${server}`, errorThrown);
},
});
}
}
</script>

View File

@ -1,4 +1,4 @@
<div id="loginForm" tabindex="-1" role="dialog" class="modal fade">
<div id="login-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form action="/api/v1/login" method="post">
@ -19,7 +19,7 @@
<div class="input-group">
<input id="password" type="password" class="form-control" placeholder="enter password" name="password" required>
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" onclick="showPassword()"><i id="show-hide-password" class="bi bi-eye"></i></button>
<button class="btn btn-outline-secondary" type="button" onclick="showPassword()"><i id="show-hide-password-btn" class="bi bi-eye"></i></button>
</div>
</div>
</div>
@ -35,7 +35,7 @@
<script>
const passwordInput = $("#password");
const showHidePasswordButton = $("#show-hide-password");
const showHidePasswordButton = $("#show-hide-password-btn");
function showPassword() {
if (passwordInput.attr("type") === "password") {

View File

@ -1,61 +1,74 @@
<div id="add-form" tabindex="-1" role="dialog" class="modal fade">
<div id="package-add-modal" 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" autocomplete="off" class="form-control" placeholder="AUR package" name="package" required>
<datalist id="known-packages-dlist"></datalist>
<form id="package-add-form" onsubmit="return false">
<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-input" class="col-sm-2 col-form-label">package</label>
<div class="col-sm-10">
<input id="package-input" type="text" list="known-packages-dlist" autocomplete="off" class="form-control" placeholder="AUR package" name="package" required>
<datalist id="known-packages-dlist"></datalist>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPackages()"><i class="bi bi-play"></i> add</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal" onclick="requestPackages()"><i class="bi bi-plus"></i> request</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><i class="bi bi-x"></i> close</button>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" onclick="packagesAdd()"><i class="bi bi-play"></i> add</button>
<button type="submit" class="btn btn-success" onclick="packagesRequest()"><i class="bi bi-plus"></i> request</button>
</div>
</form>
</div>
</div>
</div>
<script>
const packageInput = $("#package-form");
const knownPackages = $("#known-packages-dlist");
const packageAddModal = $("#package-add-modal");
const packageAddForm = $("#package-add-form");
packageAddModal.on("hidden.bs.modal", () => { packageAddForm.trigger("reset"); });
const packageInput = $("#package-input");
const knownPackagesList = $("#known-packages-dlist");
packageInput.keyup(() => {
clearTimeout(packageInput.data("timeout"));
packageInput.data("timeout", setTimeout($.proxy(() => {
const value = packageInput.val();
$.ajax({
url: "/api/v1/service/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);
},
})
if (value.length >= 3) {
$.ajax({
url: "/api/v1/service/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;
});
knownPackagesList.empty().append(options);
},
});
}
}, this), 500));
});
function addPackages() {
const packages = [packageInput.val()];
doPackageAction("/api/v1/service/add", packages, "The following package has been added:", "Package addition failed:");
function packagesAdd() {
const packages = packageInput.val();
if (packages) {
packageAddModal.modal("hide");
doPackageAction("/api/v1/service/add", [packages], "The following package has been added:", "Package addition failed:");
}
}
function requestPackages() {
const packages = [packageInput.val()];
doPackageAction("/api/v1/service/request", packages, "The following package has been requested:", "Package request failed:");
function packagesRequest() {
const packages = packageInput.val();
if (packages) {
packageAddModal.modal("hide");
doPackageAction("/api/v1/service/request", [packages], "The following package has been requested:", "Package request failed:");
}
}
</script>

View File

@ -1,4 +1,4 @@
<div id="package-info-form" tabindex="-1" role="dialog" class="modal fade">
<div id="package-info-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div id="package-info-modal-header" class="modal-header">
@ -6,7 +6,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<pre class="language-logs"><code id="package-info-logs" class="pre-scrollable language-logs"></code><button id="copy-btn" type="button" class="btn language-logs" onclick="copyLogs()"><i class="bi bi-clipboard"></i> copy</button></pre>
<pre class="language-logs"><code id="package-info-logs-input" class="pre-scrollable language-logs"></code><button id="logs-copy-btn" type="button" class="btn language-logs" onclick="copyLogs()"><i class="bi bi-clipboard"></i> copy</button></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="showLogs()"><i class="bi bi-arrow-clockwise"></i> reload</button>
@ -17,28 +17,24 @@
</div>
<script>
const packageInfoModal = $("#package-info-modal");
const packageInfoModalHeader = $("#package-info-modal-header");
const packageInfo = $("#package-info");
const packageInfoForm = $("#package-info-form");
const packageInfoHeader = $("#package-info-modal-header");
const packageInfoLogs = $("#package-info-logs");
const packageInfoLogsCopyButton = $("#copy-btn");
const packageInfoLogsInput = $("#package-info-logs-input");
const packageInfoLogsCopyButton = $("#logs-copy-btn");
async function copyLogs() {
const logs = packageInfoLogs.text();
await copyToClipboard(logs);
packageInfoLogsCopyButton.html("<i class=\"bi bi-clipboard-check\"></i> copied");
setTimeout(()=> {
packageInfoLogsCopyButton.html("<i class=\"bi bi-clipboard\"></i> copy");
}, 2000);
const logs = packageInfoLogsInput.text();
await copyToClipboard(logs, packageInfoLogsCopyButton);
}
function showLogs(package) {
const isPackageBaseSet = package !== undefined;
if (isPackageBaseSet)
packageInfoForm.data("package", package); // set package base as currently used
packageInfoModal.data("package", package); // set package base as currently used
else
package = packageInfoForm.data("package"); // read package base from the current window attribute
package = packageInfoModal.data("package"); // read package base from the current window attribute
const headerClass = status => {
if (status === "pending") return ["bg-warning"];
@ -54,13 +50,13 @@
dataType: "json",
success: response => {
packageInfo.text(`${response.package_base} ${response.status.status} at ${new Date(1000 * response.status.timestamp).toISOString()}`);
packageInfoLogs.text(response.logs);
packageInfoLogsInput.text(response.logs);
packageInfoHeader.removeClass();
packageInfoHeader.addClass("modal-header");
headerClass(response.status.status).forEach((clz) => packageInfoHeader.addClass(clz));
packageInfoModalHeader.removeClass();
packageInfoModalHeader.addClass("modal-header");
headerClass(response.status.status).forEach((clz) => packageInfoModalHeader.addClass(clz));
if (isPackageBaseSet) packageInfoForm.modal("show"); // we don't need to show window again
if (isPackageBaseSet) packageInfoModal.modal("show"); // we don't need to show window again
},
error: (jqXHR, _, errorThrown) => {
// show failed modal in case if first time loading

View File

@ -1,4 +1,4 @@
<div id="success-form" tabindex="-1" role="dialog" class="modal fade">
<div id="success-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-success text-white">
@ -17,16 +17,18 @@
</div>
<script>
const successForm = $("#success-form");
const successModal = $("#success-modal");
successModal.on("hidden.bs.modal", () => { reload(); });
const successDescription = $("#success-description");
const successDetails = $("#success-details");
const successTitle = $("#success-title");
successForm.on("hidden.bs.modal", () => { reload(); });
function showSuccess(title, description, details) {
successTitle.text(title);
successDescription.text(description);
successDetails.empty().append(details);
successForm.modal("show");
successModal.modal("show");
}
</script>

View File

@ -1,14 +1,19 @@
<script>
const addButton = $("#add-btn");
const removeButton = $("#remove-btn");
const updateButton = $("#update-btn");
const keyImportButton = $("#key-import-btn");
const packageAddButton = $("#package-add-btn");
const packageRemoveButton = $("#package-remove-btn");
const packageUpdateButton = $("#package-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);
});
table.on("click-row.bs.table", (_, row) => { showLogs(row.id); });
table.on("check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table", () => {
packageRemoveButton.prop("disabled", !table.bootstrapTable("getSelections").length);
});
table.on("click-row.bs.table", (self, data, row, cell) => {
if (0 === cell || "base" === cell) {
const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript
table.bootstrapTable(method, {field: "id", values: [data.id]});
} else showLogs(data.id);
});
const architectureBadge = $("#badge-architecture");
const repositoryBadge = $("#badge-repository");
@ -50,9 +55,10 @@
}
function hideControls(hidden) {
addButton.attr("hidden", hidden);
removeButton.attr("hidden", hidden);
updateButton.attr("hidden", hidden);
keyImportButton.attr("hidden", hidden);
packageAddButton.attr("hidden", hidden);
packageRemoveButton.attr("hidden", hidden);
packageUpdateButton.attr("hidden", hidden);
}
function reload() {

View File

@ -18,7 +18,7 @@
<div class="container">
{% if pgp_key is not none %}
<p>This repository is signed with <a href="https://pgp.mit.edu/pks/lookup?search=0x{{ pgp_key }}&fingerprint=on&op=index" title="key search">{{ pgp_key }}</a> by default.</p>
<p>This repository is signed with <a href="https://keyserver.ubuntu.com/pks/lookup?search=0x{{ pgp_key }}&fingerprint=on&op=index" title="key search">{{ pgp_key }}</a> by default.</p>
{% endif %}
<p>In order to use this repository edit your <code>/etc/pacman.conf</code> as following:</p>
@ -101,12 +101,7 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
async function copyPacmanConf() {
const conf = pacmanConf.text();
await copyToClipboard(conf);
pacmanConfCopyButton.html("<i class=\"bi bi-clipboard-check\"></i> copied");
setTimeout(() => {
pacmanConfCopyButton.html("<i class=\"bi bi-clipboard\"></i> copy");
}, 2000);
await copyToClipboard(conf, pacmanConfCopyButton);
}
</script>

View File

@ -13,16 +13,21 @@
<script src="https://unpkg.com/bootstrap-table@1.21.1/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script>
async function copyToClipboard(text) {
async function copyToClipboard(text, button) {
if (navigator.clipboard === undefined) {
const input = document.createElement("textarea");
input.innerHTML = text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.execCommand("copy");
document.body.removeChild(input);
} else {
await navigator.clipboard.writeText(text);
}
button.html("<i class=\"bi bi-clipboard-check\"></i> copied");
setTimeout(()=> {
button.html("<i class=\"bi bi-clipboard\"></i> copy");
}, 2000);
}
</script>

View File

@ -73,6 +73,7 @@ setup(
]),
("share/ahriman/templates/build-status", [
"package/share/ahriman/templates/build-status/failed-modal.jinja2",
"package/share/ahriman/templates/build-status/key-import-modal.jinja2",
"package/share/ahriman/templates/build-status/login-modal.jinja2",
"package/share/ahriman/templates/build-status/package-add-modal.jinja2",
"package/share/ahriman/templates/build-status/package-info-modal.jinja2",

View File

@ -229,7 +229,7 @@ def _set_key_import_parser(root: SubParserAction) -> argparse.ArgumentParser:
"fail in case if key is not known for build user. This subcommand can be used "
"in order to import the PGP key to user keychain.",
formatter_class=_formatter)
parser.add_argument("--key-server", help="key server for key import", default="pgp.mit.edu")
parser.add_argument("--key-server", help="key server for key import", default="keyserver.ubuntu.com")
parser.add_argument("key", help="PGP key to import from public server")
parser.set_defaults(handler=handlers.KeyImport, architecture=[""], lock=None, report=False)
return parser

View File

@ -62,7 +62,7 @@ class Auth(LazyLogging):
Returns:
str: login control as html code to insert
"""
return """<button type="button" class="btn btn-link" data-bs-toggle="modal" data-bs-target="#loginForm" style="text-decoration: none"><i class="bi bi-box-arrow-in-right"></i> login</button>"""
return """<button type="button" class="btn btn-link" data-bs-toggle="modal" data-bs-target="#login-modal" style="text-decoration: none"><i class="bi bi-box-arrow-in-right"></i> login</button>"""
@classmethod
def load(cls: Type[Auth], configuration: Configuration, database: SQLite) -> Auth:

View File

@ -118,7 +118,7 @@ class GPG(LazyLogging):
"""
key = key if key.startswith("0x") else f"0x{key}"
try:
response = requests.get(f"http://{server}/pks/lookup", params={
response = requests.get(f"https://{server}/pks/lookup", params={
"op": "get",
"options": "mr",
"search": key

View File

@ -24,7 +24,7 @@ import uuid
from multiprocessing import Process, Queue
from threading import Lock, Thread
from typing import Callable, Dict, Iterable, Tuple
from typing import Callable, Dict, Iterable, Optional, Tuple
from ahriman.core.configuration import Configuration
from ahriman.core.log import LazyLogging
@ -78,6 +78,17 @@ class Spawn(Thread, LazyLogging):
result = callback(args, architecture)
queue.put((process_id, result))
def key_import(self, key: str, server: Optional[str]) -> None:
"""
import key to service cache
Args:
key(str): key to import
server(str): PGP key server
"""
kwargs = {} if server is None else {"key-server": server}
self.spawn_process("key-import", key, **kwargs)
def packages_add(self, packages: Iterable[str], *, now: bool) -> None:
"""
add packages
@ -86,12 +97,10 @@ class Spawn(Thread, LazyLogging):
packages(Iterable[str]): packages list to add
now(bool): build packages now
"""
if not packages:
return self.spawn_process("repo-update")
kwargs = {"source": PackageSource.AUR.value} # avoid abusing by building non-aur packages
if now:
kwargs["now"] = ""
return self.spawn_process("package-add", *packages, **kwargs)
self.spawn_process("package-add", *packages, **kwargs)
def packages_remove(self, packages: Iterable[str]) -> None:
"""
@ -102,6 +111,12 @@ class Spawn(Thread, LazyLogging):
"""
self.spawn_process("package-remove", *packages)
def packages_update(self, ) -> None:
"""
run full repository update
"""
self.spawn_process("repo-update")
def spawn_process(self, command: str, *args: str, **kwargs: str) -> None:
"""
spawn external ahriman process with supplied arguments

View File

@ -20,9 +20,8 @@
import aiohttp_jinja2
import logging
from aiohttp.web import middleware, Request
from aiohttp.web_exceptions import HTTPClientError, HTTPException, HTTPServerError, HTTPUnauthorized
from aiohttp.web_response import json_response, StreamResponse
from aiohttp.web import HTTPClientError, HTTPException, HTTPServerError, HTTPUnauthorized, Request, StreamResponse, \
json_response, middleware
from ahriman.web.middlewares import HandlerType, MiddlewareType

View File

@ -22,9 +22,11 @@ from pathlib import Path
from ahriman.web.views.index import IndexView
from ahriman.web.views.service.add import AddView
from ahriman.web.views.service.pgp import PGPView
from ahriman.web.views.service.remove import RemoveView
from ahriman.web.views.service.request import RequestView
from ahriman.web.views.service.search import SearchView
from ahriman.web.views.service.update import UpdateView
from ahriman.web.views.status.logs import LogsView
from ahriman.web.views.status.package import PackageView
from ahriman.web.views.status.packages import PackagesView
@ -47,13 +49,16 @@ def setup_routes(application: Application, static_path: Path) -> None:
* ``POST /api/v1/service/add`` add new packages to repository
* ``GET /api/v1/service/pgp`` fetch PGP key from the keyserver
* ``POST /api/v1/service/pgp`` import PGP key from the keyserver
* ``POST /api/v1/service/remove`` remove existing package from repository
* ``POST /api/v1/service/request`` request to add new packages to repository
* ``GET /api/v1/service/search`` search for substring in AUR
* ``POST /api/v1/service/update`` update packages in repository, actually it is just alias for add
* ``POST /api/v1/service/update`` update all packages in repository
* ``GET /api/v1/packages`` get all known packages
* ``POST /api/v1/packages`` force update every package from repository
@ -84,13 +89,16 @@ def setup_routes(application: Application, static_path: Path) -> None:
application.router.add_post("/api/v1/service/add", AddView)
application.router.add_get("/api/v1/service/pgp", PGPView, allow_head=True)
application.router.add_post("/api/v1/service/pgp", PGPView)
application.router.add_post("/api/v1/service/remove", RemoveView)
application.router.add_post("/api/v1/service/request", RequestView)
application.router.add_get("/api/v1/service/search", SearchView, allow_head=False)
application.router.add_post("/api/v1/service/update", AddView)
application.router.add_post("/api/v1/service/update", UpdateView)
application.router.add_get("/api/v1/packages", PackagesView, allow_head=True)
application.router.add_post("/api/v1/packages", PackagesView)

View File

@ -20,16 +20,18 @@
from __future__ import annotations
from aiohttp.web import Request, View
from typing import Any, Dict, List, Optional, Type
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar
from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher
from ahriman.models.user_access import UserAccess
T = TypeVar("T", str, List[str])
class BaseView(View):
"""
base web view to make things typed
@ -46,17 +48,6 @@ class BaseView(View):
configuration: Configuration = self.request.app["configuration"]
return configuration
@property
def database(self) -> SQLite:
"""
get database instance
Returns:
SQLite: database instance
"""
database: SQLite = self.request.app["database"]
return database
@property
def service(self) -> Watcher:
"""
@ -104,6 +95,29 @@ class BaseView(View):
permission: UserAccess = getattr(cls, f"{request.method.upper()}_PERMISSION", UserAccess.Full)
return permission
@staticmethod
def get_non_empty(extractor: Callable[[str], Optional[T]], key: str) -> T:
"""
get non-empty value from request parameters
Args:
extractor(Callable[[str], T]): function to get value by key
key(str): key to extract value
Returns:
T: extracted values if it is presented and not empty
Raises:
KeyError: in case if key was not found or value is empty
"""
try:
value = extractor(key)
if not value:
raise KeyError(key)
except Exception:
raise KeyError(f"Key {key} is missing or empty")
return value
async def extract_data(self, list_keys: Optional[List[str]] = None) -> Dict[str, Any]:
"""
extract json data from either json or form data

View File

@ -17,7 +17,7 @@
# 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 HTTPNoContent
from aiohttp.web import HTTPBadRequest, HTTPNoContent
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
@ -62,8 +62,11 @@ class AddView(BaseView):
< Server: Python/3.10 aiohttp/3.8.3
<
"""
data = await self.extract_data(["packages"])
packages = data.get("packages", [])
try:
data = await self.extract_data(["packages"])
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
except Exception as e:
raise HTTPBadRequest(reason=str(e))
self.spawner.packages_add(packages, now=True)

View File

@ -0,0 +1,121 @@
#
# 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, HTTPNotFound, Response, json_response
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
class PGPView(BaseView):
"""
pgp key management 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
"""
POST_PERMISSION = UserAccess.Full
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Reporter
async def get(self) -> Response:
"""
retrieve key from the key server. It supports two query parameters: ``key`` - pgp key fingerprint and
``server`` which points to valid PGP key server
Returns:
Response: 200 with key body on success
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNotFound: if key wasn't found or service was unable to fetch it
Examples:
Example of command by using curl::
$ curl -v -H 'Accept: application/json' 'http://example.com/api/v1/service/pgp?key=0xE989490C&server=keyserver.ubuntu.com'
> GET /api/v1/service/pgp?key=0xE989490C&server=keyserver.ubuntu.com HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: application/json
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Content-Length: 3275
< Date: Fri, 25 Nov 2022 22:54:02 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
{"key": "key"}
"""
try:
key = self.get_non_empty(self.request.query.getone, "key")
server = self.get_non_empty(self.request.query.getone, "server")
except Exception as e:
raise HTTPBadRequest(reason=str(e))
try:
key = self.service.repository.sign.key_download(server, key)
except Exception:
raise HTTPNotFound()
return json_response({"key": key})
async def post(self) -> None:
"""
store key to the local service environment
JSON body must be supplied, the following model is used::
{
"key": "0x8BE91E5A773FB48AC05CC1EDBED105AED6246B39", # key fingerprint to import
"server": "keyserver.ubuntu.com" # optional pgp server address
}
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
Examples:
Example of command by using curl::
$ curl -v -H 'Content-Type: application/json' 'http://example.com/api/v1/service/pgp' -d '{"key": "0xE989490C"}'
> POST /api/v1/service/pgp HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 21
>
< HTTP/1.1 204 No Content
< Date: Fri, 25 Nov 2022 22:55:56 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
data = await self.extract_data()
try:
key = self.get_non_empty(data.get, "key")
except Exception as e:
raise HTTPBadRequest(reason=str(e))
self.spawner.key_import(key, data.get("server"))
raise HTTPNoContent()

View File

@ -65,7 +65,7 @@ class RemoveView(BaseView):
"""
try:
data = await self.extract_data(["packages"])
packages = data["packages"]
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
except Exception as e:
raise HTTPBadRequest(reason=str(e))

View File

@ -65,7 +65,7 @@ class RequestView(BaseView):
"""
try:
data = await self.extract_data(["packages"])
packages = data["packages"]
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
except Exception as e:
raise HTTPBadRequest(reason=str(e))

View File

@ -17,7 +17,7 @@
# 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 HTTPNotFound, Response, json_response
from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response
from typing import Callable, List
from ahriman.core.alpm.remote import AUR
@ -45,6 +45,7 @@ class SearchView(BaseView):
Response: 200 with found package bases and descriptions sorted by base
Raises:
HTTPBadRequest: in case if bad data is supplied
HTTPNotFound: if no packages found
Examples:
@ -64,8 +65,12 @@ class SearchView(BaseView):
<
[{"package": "ahriman", "description": "ArcH linux ReposItory MANager"}, {"package": "ahriman-git", "description": "ArcH Linux ReposItory MANager"}]
"""
search: List[str] = self.request.query.getall("for", default=[])
packages = AUR.multisearch(*search, pacman=self.service.repository.pacman)
try:
search: List[str] = self.get_non_empty(lambda key: self.request.query.getall(key, default=[]), "for")
packages = AUR.multisearch(*search, pacman=self.service.repository.pacman)
except Exception as e:
raise HTTPBadRequest(reason=str(e))
if not packages:
raise HTTPNotFound(reason=f"No packages found for terms: {search}")

View File

@ -0,0 +1,59 @@
#
# 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 HTTPNoContent
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
class UpdateView(BaseView):
"""
update repository web view
Attributes:
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION = UserAccess.Full
async def post(self) -> None:
"""
run repository update. No parameters supported here
Raises:
HTTPNoContent: in case of success response
Examples:
Example of command by using curl::
$ curl -v -XPOST 'http://example.com/api/v1/service/update'
> POST /api/v1/service/update HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
>
< HTTP/1.1 204 No Content
< Date: Fri, 25 Nov 2022 22:57:56 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
self.spawner.packages_update()
raise HTTPNoContent()

View File

@ -17,8 +17,7 @@
# 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 aiohttp.web_exceptions import HTTPNotFound
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
from ahriman.core.exceptions import UnknownPackageError
from ahriman.models.log_record_id import LogRecordId

View File

@ -17,8 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPFound
from aiohttp.web_exceptions import HTTPUnauthorized
from aiohttp.web import HTTPFound, HTTPUnauthorized
from ahriman.core.auth.helpers import check_authorized, forget
from ahriman.models.user_access import UserAccess

View File

@ -17,7 +17,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
argparse.Namespace: generated arguments for these test cases
"""
args.key = "0xE989490C"
args.key_server = "pgp.mit.edu"
args.key_server = "keyserver.ubuntu.com"
return args

View File

@ -81,9 +81,9 @@ def test_key_download(gpg: GPG, mocker: MockerFixture) -> None:
must download the key from public server
"""
requests_mock = mocker.patch("requests.get")
gpg.key_download("pgp.mit.edu", "0xE989490C")
gpg.key_download("keyserver.ubuntu.com", "0xE989490C")
requests_mock.assert_called_once_with(
"http://pgp.mit.edu/pks/lookup",
"https://keyserver.ubuntu.com/pks/lookup",
params={"op": "get", "options": "mr", "search": "0xE989490C"},
timeout=gpg.DEFAULT_TIMEOUT)
@ -94,7 +94,7 @@ def test_key_download_failure(gpg: GPG, mocker: MockerFixture) -> None:
"""
mocker.patch("requests.get", side_effect=requests.exceptions.HTTPError())
with pytest.raises(requests.exceptions.HTTPError):
gpg.key_download("pgp.mit.edu", "0xE989490C")
gpg.key_download("keyserver.ubuntu.com", "0xE989490C")
def test_key_import(gpg: GPG, mocker: MockerFixture) -> None:
@ -104,7 +104,7 @@ def test_key_import(gpg: GPG, mocker: MockerFixture) -> None:
mocker.patch("ahriman.core.sign.gpg.GPG.key_download", return_value="key")
check_output_mock = mocker.patch("ahriman.core.sign.gpg.GPG._check_output")
gpg.key_import("pgp.mit.edu", "0xE989490C")
gpg.key_import("keyserver.ubuntu.com", "0xE989490C")
check_output_mock.assert_called_once_with("gpg", "--import", input_data="key", logger=pytest.helpers.anyvar(int))

View File

@ -36,6 +36,24 @@ def test_process_error(spawner: Spawn) -> None:
assert spawner.queue.empty()
def test_key_import(spawner: Spawn, mocker: MockerFixture) -> None:
"""
must call key import
"""
spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process")
spawner.key_import("0xdeadbeaf", None)
spawn_mock.assert_called_once_with("key-import", "0xdeadbeaf")
def test_key_import_with_server(spawner: Spawn, mocker: MockerFixture) -> None:
"""
must call key import with server specified
"""
spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process")
spawner.key_import("0xdeadbeaf", "keyserver.ubuntu.com")
spawn_mock.assert_called_once_with("key-import", "0xdeadbeaf", **{"key-server": "keyserver.ubuntu.com"})
def test_packages_add(spawner: Spawn, mocker: MockerFixture) -> None:
"""
must call package addition
@ -54,15 +72,6 @@ def test_packages_add_with_build(spawner: Spawn, mocker: MockerFixture) -> None:
spawn_mock.assert_called_once_with("package-add", "ahriman", "linux", source="aur", now="")
def test_packages_add_update(spawner: Spawn, mocker: MockerFixture) -> None:
"""
must call repo update
"""
spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process")
spawner.packages_add([], now=False)
spawn_mock.assert_called_once_with("repo-update")
def test_packages_remove(spawner: Spawn, mocker: MockerFixture) -> None:
"""
must call package removal
@ -72,6 +81,15 @@ def test_packages_remove(spawner: Spawn, mocker: MockerFixture) -> None:
spawn_mock.assert_called_once_with("package-remove", "ahriman", "linux")
def test_packages_update(spawner: Spawn, mocker: MockerFixture) -> None:
"""
must call repo update
"""
spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process")
spawner.packages_update()
spawn_mock.assert_called_once_with("repo-update")
def test_spawn_process(spawner: Spawn, mocker: MockerFixture) -> None:
"""
must correctly spawn child process

View File

@ -325,6 +325,7 @@ def test_walk(resource_path_root: Path) -> None:
resource_path_root / "models" / "package_tpacpi-bat-git_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" / "key-import-modal.jinja2",
resource_path_root / "web" / "templates" / "build-status" / "login-modal.jinja2",
resource_path_root / "web" / "templates" / "build-status" / "package-add-modal.jinja2",
resource_path_root / "web" / "templates" / "build-status" / "package-info-modal.jinja2",

View File

@ -2,7 +2,7 @@ import json
import logging
import pytest
from aiohttp.web_exceptions import HTTPBadRequest, HTTPInternalServerError, HTTPNoContent, HTTPUnauthorized
from aiohttp.web import HTTPBadRequest, HTTPInternalServerError, HTTPNoContent, HTTPUnauthorized
from pytest_mock import MockerFixture
from typing import Any
from unittest.mock import AsyncMock, MagicMock

View File

@ -21,18 +21,26 @@ async def test_post(client: TestClient, mocker: MockerFixture) -> None:
must call post request correctly
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/api/v1/service/add", json={"packages": ["ahriman"]})
response = await client.post("/api/v1/service/add", json={"packages": ["ahriman"]})
assert response.ok
add_mock.assert_called_once_with(["ahriman"], now=True)
async def test_post_update(client: TestClient, mocker: MockerFixture) -> None:
async def test_post_empty(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post request correctly for alias
must call raise 400 on empty request
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/api/v1/service/update", json={"packages": ["ahriman"]})
assert response.ok
add_mock.assert_called_once_with(["ahriman"], now=True)
response = await client.post("/api/v1/service/add", json={"packages": [""]})
assert response.status == 400
add_mock.assert_not_called()
response = await client.post("/api/v1/service/add", json={"packages": []})
assert response.status == 400
add_mock.assert_not_called()
response = await client.post("/api/v1/service/add", json={})
assert response.status == 400
add_mock.assert_not_called()

View File

@ -0,0 +1,75 @@
import pytest
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
from ahriman.models.user_access import UserAccess
from ahriman.web.views.service.pgp import PGPView
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 PGPView.get_permission(request) == UserAccess.Reporter
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await PGPView.get_permission(request) == UserAccess.Full
async def test_get(client: TestClient, mocker: MockerFixture) -> None:
"""
must retrieve key from the keyserver
"""
import_mock = mocker.patch("ahriman.core.sign.gpg.GPG.key_download", return_value="imported")
response = await client.get("/api/v1/service/pgp", params={"key": "0xdeadbeaf", "server": "keyserver.ubuntu.com"})
assert response.ok
import_mock.assert_called_once_with("keyserver.ubuntu.com", "0xdeadbeaf")
assert await response.json() == {"key": "imported"}
async def test_get_empty(client: TestClient, mocker: MockerFixture) -> None:
"""
must raise 400 on missing parameters
"""
import_mock = mocker.patch("ahriman.core.sign.gpg.GPG.key_download")
response = await client.get("/api/v1/service/pgp")
assert response.status == 400
import_mock.assert_not_called()
async def test_get_process_exception(client: TestClient, mocker: MockerFixture) -> None:
"""
must raise 404 on invalid PGP server response
"""
import_mock = mocker.patch("ahriman.core.sign.gpg.GPG.key_download", side_effect=Exception())
response = await client.get("/api/v1/service/pgp", params={"key": "0xdeadbeaf", "server": "keyserver.ubuntu.com"})
assert response.status == 404
import_mock.assert_called_once_with("keyserver.ubuntu.com", "0xdeadbeaf")
async def test_post(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post request correctly
"""
import_mock = mocker.patch("ahriman.core.spawn.Spawn.key_import")
response = await client.post("/api/v1/service/pgp", json={"key": "0xdeadbeaf", "server": "keyserver.ubuntu.com"})
assert response.ok
import_mock.assert_called_once_with("0xdeadbeaf", "keyserver.ubuntu.com")
async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None:
"""
must raise exception on missing key payload
"""
import_mock = mocker.patch("ahriman.core.spawn.Spawn.key_import")
response = await client.post("/api/v1/service/pgp")
assert response.status == 400
import_mock.assert_not_called()

View File

@ -21,8 +21,8 @@ async def test_post(client: TestClient, mocker: MockerFixture) -> None:
must call post request correctly
"""
remove_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove")
response = await client.post("/api/v1/service/remove", json={"packages": ["ahriman"]})
response = await client.post("/api/v1/service/remove", json={"packages": ["ahriman"]})
assert response.ok
remove_mock.assert_called_once_with(["ahriman"])
@ -32,7 +32,7 @@ async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None
must raise exception on missing packages payload
"""
remove_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove")
response = await client.post("/api/v1/service/remove")
response = await client.post("/api/v1/service/remove")
assert response.status == 400
remove_mock.assert_not_called()

View File

@ -21,8 +21,8 @@ async def test_post(client: TestClient, mocker: MockerFixture) -> None:
must call post request correctly
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/api/v1/service/request", json={"packages": ["ahriman"]})
response = await client.post("/api/v1/service/request", json={"packages": ["ahriman"]})
assert response.ok
add_mock.assert_called_once_with(["ahriman"], now=False)
@ -32,7 +32,7 @@ async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None
must raise exception on missing packages payload
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/api/v1/service/request")
response = await client.post("/api/v1/service/request")
assert response.status == 400
add_mock.assert_not_called()

View File

@ -22,8 +22,8 @@ async def test_get(client: TestClient, aur_package_ahriman: AURPackage, mocker:
must call get request correctly
"""
mocker.patch("ahriman.core.alpm.remote.AUR.multisearch", return_value=[aur_package_ahriman])
response = await client.get("/api/v1/service/search", params={"for": "ahriman"})
response = await client.get("/api/v1/service/search", params={"for": "ahriman"})
assert response.ok
assert await response.json() == [{"package": aur_package_ahriman.package_base,
"description": aur_package_ahriman.description}]
@ -33,11 +33,20 @@ async def test_get_exception(client: TestClient, mocker: MockerFixture) -> None:
"""
must raise 400 on empty search string
"""
search_mock = mocker.patch("ahriman.core.alpm.remote.AUR.multisearch", return_value=[])
response = await client.get("/api/v1/service/search")
search_mock = mocker.patch("ahriman.core.alpm.remote.AUR.multisearch")
response = await client.get("/api/v1/service/search")
assert response.status == 400
search_mock.assert_not_called()
async def test_get_empty(client: TestClient, mocker: MockerFixture) -> None:
"""
must raise 404 on empty search result
"""
mocker.patch("ahriman.core.alpm.remote.AUR.multisearch", return_value=[])
response = await client.get("/api/v1/service/search", params={"for": "ahriman"})
assert response.status == 404
search_mock.assert_called_once_with(pacman=pytest.helpers.anyvar(int))
async def test_get_join(client: TestClient, mocker: MockerFixture) -> None:
@ -45,7 +54,7 @@ async def test_get_join(client: TestClient, mocker: MockerFixture) -> None:
must join search args with space
"""
search_mock = mocker.patch("ahriman.core.alpm.remote.AUR.multisearch")
response = await client.get("/api/v1/service/search", params=[("for", "ahriman"), ("for", "maybe")])
response = await client.get("/api/v1/service/search", params=[("for", "ahriman"), ("for", "maybe")])
assert response.ok
search_mock.assert_called_once_with("ahriman", "maybe", pacman=pytest.helpers.anyvar(int))

View File

@ -0,0 +1,13 @@
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
async def test_post_update(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post request correctly for alias
"""
update_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_update")
response = await client.post("/api/v1/service/update")
assert response.ok
update_mock.assert_called_once_with()

View File

@ -12,13 +12,6 @@ def test_configuration(base: BaseView) -> None:
assert base.configuration
def test_database(base: BaseView) -> None:
"""
must return database
"""
assert base.database
def test_service(base: BaseView) -> None:
"""
must return service
@ -50,6 +43,24 @@ async def test_get_permission(base: BaseView) -> None:
assert await base.get_permission(request) == "permission"
def test_get_non_empty() -> None:
"""
must correctly extract non-empty values
"""
assert BaseView.get_non_empty(lambda k: k, "key")
with pytest.raises(KeyError):
BaseView.get_non_empty(lambda k: None, "key")
with pytest.raises(KeyError):
BaseView.get_non_empty(lambda k: "", "key")
assert BaseView.get_non_empty(lambda k: [k], "key")
with pytest.raises(KeyError):
BaseView.get_non_empty(lambda k: [], "key")
async def test_extract_data_json(base: BaseView) -> None:
"""
must parse and return json