feat(web): Seite „Schlüssel“ und Schlüsselauswahl in der Anbindung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
686542ff6f
commit
fb975f1eae
+102
-6
@@ -45,6 +45,7 @@ from netmover.admin import (
|
|||||||
from netmover.app_state import NetMover
|
from netmover.app_state import NetMover
|
||||||
from netmover.config import Settings, get_settings
|
from netmover.config import Settings, get_settings
|
||||||
from netmover.db import session_scope
|
from netmover.db import session_scope
|
||||||
|
from netmover.keys import delete_key, generate_key, import_key, list_keys, rename_key
|
||||||
from netmover.logging_setup import event, get_logger
|
from netmover.logging_setup import event, get_logger
|
||||||
from netmover.models import (
|
from netmover.models import (
|
||||||
CollisionPolicy,
|
CollisionPolicy,
|
||||||
@@ -392,10 +393,21 @@ async def mounts_page(request: Request) -> Response:
|
|||||||
pubkeys = {
|
pubkeys = {
|
||||||
m.id: m.credential.public_key
|
m.id: m.credential.public_key
|
||||||
for m in mounts
|
for m in mounts
|
||||||
if m.credential is not None and m.credential.public_key
|
if m.credential is not None and m.credential.public_key and not m.credential.managed
|
||||||
|
}
|
||||||
|
key_names = {
|
||||||
|
m.id: m.credential.name
|
||||||
|
for m in mounts
|
||||||
|
if m.credential is not None and m.credential.managed
|
||||||
}
|
}
|
||||||
return render(
|
return render(
|
||||||
request, "mounts.html", active="mounts", mounts=mounts, reports=reports, pubkeys=pubkeys
|
request,
|
||||||
|
"mounts.html",
|
||||||
|
active="mounts",
|
||||||
|
mounts=mounts,
|
||||||
|
reports=reports,
|
||||||
|
pubkeys=pubkeys,
|
||||||
|
key_names=key_names,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -409,6 +421,15 @@ def _report_data(raw: str | None) -> dict[str, Any]:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _key_options(request: Request) -> list[tuple[str, str]]:
|
||||||
|
with nm(request).session_factory() as s:
|
||||||
|
keys = list_keys(s)
|
||||||
|
return [("new", "neuer Schlüssel nur für diese Anbindung")] + [
|
||||||
|
(str(k.id), f"{k.name} ({k.key_type.removeprefix('ssh-')}, {k.fingerprint[7:19]}…)")
|
||||||
|
for k in keys
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _mount_form(
|
def _mount_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: MountInput,
|
data: MountInput,
|
||||||
@@ -416,7 +437,11 @@ def _mount_form(
|
|||||||
mount: Mount | None = None,
|
mount: Mount | None = None,
|
||||||
status_code: int = 200,
|
status_code: int = 200,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
pub = mount.credential.public_key if mount is not None and mount.credential else None
|
pub = (
|
||||||
|
mount.credential.public_key
|
||||||
|
if mount is not None and mount.credential and not mount.credential.managed
|
||||||
|
else None
|
||||||
|
)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"mount_form.html",
|
"mount_form.html",
|
||||||
@@ -426,7 +451,10 @@ def _mount_form(
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
mount=mount,
|
mount=mount,
|
||||||
public_key=pub,
|
public_key=pub,
|
||||||
has_secret=bool(mount is not None and mount.credential is not None),
|
has_secret=bool(
|
||||||
|
mount is not None and mount.credential is not None and not mount.credential.managed
|
||||||
|
),
|
||||||
|
key_options=_key_options(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -487,7 +515,7 @@ async def mount_test_form(request: Request) -> Response:
|
|||||||
existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None
|
existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None
|
||||||
with state.session_factory() as s:
|
with state.session_factory() as s:
|
||||||
existing = s.get(Mount, existing_id) if existing_id else None
|
existing = s.get(Mount, existing_id) if existing_id else None
|
||||||
cred = credential_for_test(data, existing, state.secrets)
|
cred = credential_for_test(data, existing, state.secrets, s)
|
||||||
cfg = data.config()
|
cfg = data.config()
|
||||||
if data.type is MountType.SFTP and existing is not None:
|
if data.type is MountType.SFTP and existing is not None:
|
||||||
cfg = dataclasses.replace(cfg, host_key=existing.host_key)
|
cfg = dataclasses.replace(cfg, host_key=existing.host_key)
|
||||||
@@ -503,7 +531,7 @@ async def mount_test(request: Request, mount_id: int) -> Response:
|
|||||||
if m is None:
|
if m is None:
|
||||||
return HTMLResponse("Anbindung existiert nicht.", status_code=404)
|
return HTMLResponse("Anbindung existiert nicht.", status_code=404)
|
||||||
data = mount_input_from_model(m)
|
data = mount_input_from_model(m)
|
||||||
cred = credential_for_test(data, m, state.secrets)
|
cred = credential_for_test(data, m, state.secrets, s)
|
||||||
from netmover.mounting.manager import MountConfig
|
from netmover.mounting.manager import MountConfig
|
||||||
|
|
||||||
cfg = MountConfig.from_model(m)
|
cfg = MountConfig.from_model(m)
|
||||||
@@ -563,6 +591,74 @@ async def mount_reset_hostkey(request: Request, mount_id: int) -> Response:
|
|||||||
return redirect(f"/mounts#mount-{mount_id}")
|
return redirect(f"/mounts#mount-{mount_id}")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Schlüsselverwaltung ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@protected.get("/keys", response_class=HTMLResponse)
|
||||||
|
async def keys_page(request: Request) -> Response:
|
||||||
|
with nm(request).session_factory() as s:
|
||||||
|
return render(request, "keys.html", active="keys", keys=list_keys(s))
|
||||||
|
|
||||||
|
|
||||||
|
async def _key_action(request: Request, work: Callable[[Any], str]) -> Response:
|
||||||
|
state = nm(request)
|
||||||
|
try:
|
||||||
|
with session_scope(state.session_factory) as s:
|
||||||
|
flash(request, work(s))
|
||||||
|
except AdminError as exc:
|
||||||
|
flash(request, str(exc), "error")
|
||||||
|
return redirect("/keys")
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/keys/generate")
|
||||||
|
async def key_generate(request: Request) -> Response:
|
||||||
|
form = _form_dict(await request.form())
|
||||||
|
secrets = nm(request).secrets
|
||||||
|
|
||||||
|
def work(s: Any) -> str:
|
||||||
|
key = generate_key(s, secrets, form.get("name", ""))
|
||||||
|
return f"Schlüssel „{key.name}“ erzeugt."
|
||||||
|
|
||||||
|
return await _key_action(request, work)
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/keys/import")
|
||||||
|
async def key_import(request: Request) -> Response:
|
||||||
|
form = _form_dict(await request.form())
|
||||||
|
secrets = nm(request).secrets
|
||||||
|
|
||||||
|
def work(s: Any) -> str:
|
||||||
|
key = import_key(
|
||||||
|
s,
|
||||||
|
secrets,
|
||||||
|
form.get("name", ""),
|
||||||
|
form.get("private_key", ""),
|
||||||
|
form.get("passphrase", ""),
|
||||||
|
)
|
||||||
|
return f"Schlüssel „{key.name}“ importiert."
|
||||||
|
|
||||||
|
return await _key_action(request, work)
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/keys/{key_id}/rename")
|
||||||
|
async def key_rename(request: Request, key_id: int) -> Response:
|
||||||
|
form = _form_dict(await request.form())
|
||||||
|
|
||||||
|
def work(s: Any) -> str:
|
||||||
|
rename_key(s, key_id, form.get("name", ""))
|
||||||
|
return "Schlüssel umbenannt."
|
||||||
|
|
||||||
|
return await _key_action(request, work)
|
||||||
|
|
||||||
|
|
||||||
|
@protected.post("/keys/{key_id}/delete")
|
||||||
|
async def key_delete(request: Request, key_id: int) -> Response:
|
||||||
|
def work(s: Any) -> str:
|
||||||
|
return f"Schlüssel „{delete_key(s, key_id)}“ gelöscht."
|
||||||
|
|
||||||
|
return await _key_action(request, work)
|
||||||
|
|
||||||
|
|
||||||
# -- Regeln -----------------------------------------------------------------------------------
|
# -- Regeln -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<nav class="main">
|
<nav class="main">
|
||||||
<a href="/" class="{{ 'active' if active == 'status' }}">Status</a>
|
<a href="/" class="{{ 'active' if active == 'status' }}">Status</a>
|
||||||
<a href="/mounts" class="{{ 'active' if active == 'mounts' }}">Anbindungen</a>
|
<a href="/mounts" class="{{ 'active' if active == 'mounts' }}">Anbindungen</a>
|
||||||
|
<a href="/keys" class="{{ 'active' if active == 'keys' }}">Schlüssel</a>
|
||||||
<a href="/rules" class="{{ 'active' if active == 'rules' }}">Regeln</a>
|
<a href="/rules" class="{{ 'active' if active == 'rules' }}">Regeln</a>
|
||||||
<a href="/log" class="{{ 'active' if active == 'log' }}">Protokoll</a>
|
<a href="/log" class="{{ 'active' if active == 'log' }}">Protokoll</a>
|
||||||
<a href="/settings" class="{{ 'active' if active == 'settings' }}">Einstellungen</a>
|
<a href="/settings" class="{{ 'active' if active == 'settings' }}">Einstellungen</a>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import csrf_field %}
|
||||||
|
{% block title %}Schlüssel{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>SSH-Schlüssel</h1>
|
||||||
|
<p class="muted small">Gespeicherte Schlüssel für SFTP-Anbindungen. Ein Schlüssel kann von mehreren
|
||||||
|
Anbindungen benutzt werden; den Benutzernamen legt jede Anbindung selbst fest. Private Schlüssel
|
||||||
|
liegen verschlüsselt in der Datenbank und werden nie angezeigt.</p>
|
||||||
|
|
||||||
|
{% if not keys %}<p class="muted">Noch keine Schlüssel gespeichert.</p>{% endif %}
|
||||||
|
<div class="stack">
|
||||||
|
{% for k in keys %}
|
||||||
|
<div class="card" id="key-{{ k.id }}">
|
||||||
|
<div class="row spread">
|
||||||
|
<div><strong>{{ k.name }}</strong> <span class="badge">{{ k.key_type.removeprefix('ssh-') }}</span>
|
||||||
|
{% if k.used_by %}<span class="badge ok">benutzt von {{ k.used_by|join(', ') }}</span>{% else %}<span class="badge">unbenutzt</span>{% endif %}</div>
|
||||||
|
<div class="row">
|
||||||
|
<form class="inline row" method="post" action="/keys/{{ k.id }}/rename">{{ csrf_field(csrf) }}
|
||||||
|
<input type="text" name="name" value="{{ k.name }}" aria-label="Neuer Name" required maxlength="100">
|
||||||
|
<button class="small">Umbenennen</button></form>
|
||||||
|
<form class="inline" method="post" action="/keys/{{ k.id }}/delete" data-confirm="Schlüssel „{{ k.name }}“ löschen?">{{ csrf_field(csrf) }}
|
||||||
|
<button class="small danger" {{ 'disabled' if k.used_by }}>Löschen</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="small muted">Fingerabdruck <span class="mono">{{ k.fingerprint }}</span> · angelegt {{ k.created_at|localdt }}</div>
|
||||||
|
<details class="small"><summary>Öffentlicher Schlüssel / authorized_keys</summary>
|
||||||
|
<div class="field"><label>Zeile für <code>~/.ssh/authorized_keys</code> (nur SFTP erlaubt, empfohlen)</label>
|
||||||
|
<pre class="mono card" id="auth-{{ k.id }}">{{ k.authorized_keys_line }}</pre>
|
||||||
|
<button class="small" type="button" data-copy="auth-{{ k.id }}">kopieren</button></div>
|
||||||
|
<div class="field"><label>Öffentlicher Schlüssel</label>
|
||||||
|
<pre class="mono card" id="pub-{{ k.id }}">{{ k.public_key }}</pre>
|
||||||
|
<button class="small" type="button" data-copy="pub-{{ k.id }}">kopieren</button></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Neuen Schlüssel erzeugen</h2>
|
||||||
|
<form class="form card row" method="post" action="/keys/generate">{{ csrf_field(csrf) }}
|
||||||
|
<input type="text" name="name" placeholder="Name, z. B. paperless-lxc" aria-label="Name" required maxlength="100">
|
||||||
|
<button class="primary">Ed25519-Schlüssel erzeugen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Vorhandenen Schlüssel importieren</h2>
|
||||||
|
<form class="form card stack" method="post" action="/keys/import">{{ csrf_field(csrf) }}
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field"><label for="k-name">Name</label><input id="k-name" type="text" name="name" required maxlength="100"></div>
|
||||||
|
<div class="field"><label for="k-pass">Passphrase (falls vorhanden)</label><input id="k-pass" type="password" name="passphrase" autocomplete="off"></div>
|
||||||
|
<div class="field wide"><label for="k-priv">Privater Schlüssel</label>
|
||||||
|
<textarea id="k-priv" name="private_key" class="mono" rows="8" autocomplete="off" spellcheck="false"
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- … -----END OPENSSH PRIVATE KEY-----" required></textarea>
|
||||||
|
<span class="hint">Ed25519, ECDSA oder RSA (mind. 2048 Bit), OpenSSH- oder PEM-Format. Eine Passphrase wird nur zum Entschlüsseln benutzt.</span></div>
|
||||||
|
</div>
|
||||||
|
<button>Importieren</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -23,7 +23,10 @@
|
|||||||
</div></fieldset>
|
</div></fieldset>
|
||||||
|
|
||||||
<fieldset data-show-for="type:cifs sftp"><legend>Anmeldung</legend><div class="fields">
|
<fieldset data-show-for="type:cifs sftp"><legend>Anmeldung</legend><div class="fields">
|
||||||
<div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel (NetMover erzeugt ihn)'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div>
|
<div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div>
|
||||||
|
<div data-show-for="type:sftp"><div data-show-for="auth:ssh_key">
|
||||||
|
{{ select('key_id', 'Schlüssel', key_options, d.key_id, errors=errors, hint='Gespeicherte Schlüssel verwalten unter „Schlüssel“.') }}
|
||||||
|
</div></div>
|
||||||
{{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"', hint='Bei CIFS leer lassen für Gastzugang') }}
|
{{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"', hint='Bei CIFS leer lassen für Gastzugang') }}
|
||||||
<div data-show-for="type:cifs">{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}</div>
|
<div data-show-for="type:cifs">{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}</div>
|
||||||
<div class="field{{ ' has-error' if errors.get('password') }}" id="pw-field">
|
<div class="field{{ ' has-error' if errors.get('password') }}" id="pw-field">
|
||||||
@@ -33,7 +36,7 @@
|
|||||||
<span class="hint">{{ 'Leer lassen = unverändert. ' if has_secret }}Bei SFTP mit Schlüssel leer lassen.</span>
|
<span class="hint">{{ 'Leer lassen = unverändert. ' if has_secret }}Bei SFTP mit Schlüssel leer lassen.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel (in ~/.ssh/authorized_keys des Zielbenutzers eintragen)</label>
|
{% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel dieser Anbindung (in ~/.ssh/authorized_keys des Zielbenutzers eintragen)</label>
|
||||||
<pre class="mono card" id="pubkey">{{ public_key }}</pre><button class="small" type="button" data-copy="pubkey">kopieren</button></div>{% endif %}
|
<pre class="mono card" id="pubkey">{{ public_key }}</pre><button class="small" type="button" data-copy="pubkey">kopieren</button></div>{% endif %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@
|
|||||||
<div class="small">Hostschlüssel: {% if m.host_key %}<span class="mono">{{ m.host_key.split()[0] }}</span> festgehalten
|
<div class="small">Hostschlüssel: {% if m.host_key %}<span class="mono">{{ m.host_key.split()[0] }}</span> festgehalten
|
||||||
<form class="inline" method="post" action="/mounts/{{ m.id }}/reset-hostkey" data-confirm="Hostschlüssel zurücksetzen? Nur tun, wenn der Server neu aufgesetzt wurde.">{{ csrf_field(csrf) }}<button class="small">zurücksetzen</button></form>
|
<form class="inline" method="post" action="/mounts/{{ m.id }}/reset-hostkey" data-confirm="Hostschlüssel zurücksetzen? Nur tun, wenn der Server neu aufgesetzt wurde.">{{ csrf_field(csrf) }}<button class="small">zurücksetzen</button></form>
|
||||||
{% else %}<span class="text-warn">noch nicht bestätigt – bitte „Testen“</span>{% endif %}</div>
|
{% else %}<span class="text-warn">noch nicht bestätigt – bitte „Testen“</span>{% endif %}</div>
|
||||||
|
{% if key_names.get(m.id) %}
|
||||||
|
<div class="small">Anmeldung mit gespeichertem Schlüssel <a href="/keys">„{{ key_names[m.id] }}“</a></div>
|
||||||
|
{% endif %}
|
||||||
{% if pubkeys.get(m.id) %}
|
{% if pubkeys.get(m.id) %}
|
||||||
<details class="small"><summary>Öffentlicher Schlüssel für authorized_keys</summary>
|
<details class="small"><summary>Öffentlicher Schlüssel für authorized_keys</summary>
|
||||||
<pre class="mono" id="pub-{{ m.id }}">{{ pubkeys[m.id] }}</pre>
|
<pre class="mono" id="pub-{{ m.id }}">{{ pubkeys[m.id] }}</pre>
|
||||||
|
|||||||
+67
-1
@@ -154,7 +154,16 @@ def test_security_headers(client: TestClient) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_no_inline_scripts_or_styles(client: TestClient) -> None:
|
def test_no_inline_scripts_or_styles(client: TestClient) -> None:
|
||||||
for url in ("/", "/mounts", "/mounts/new", "/rules", "/rules/new", "/log", "/settings"):
|
for url in (
|
||||||
|
"/",
|
||||||
|
"/mounts",
|
||||||
|
"/mounts/new",
|
||||||
|
"/keys",
|
||||||
|
"/rules",
|
||||||
|
"/rules/new",
|
||||||
|
"/log",
|
||||||
|
"/settings",
|
||||||
|
):
|
||||||
html = client.get(url).text
|
html = client.get(url).text
|
||||||
assert "style=" not in html, url
|
assert "style=" not in html, url
|
||||||
assert not re.search(r"<script(?![^>]*\bsrc=)", html), url
|
assert not re.search(r"<script(?![^>]*\bsrc=)", html), url
|
||||||
@@ -555,3 +564,60 @@ def test_export_excludes_secrets_and_import_roundtrip(
|
|||||||
files={"file": ("export.json", text, "application/json")},
|
files={"file": ("export.json", text, "application/json")},
|
||||||
)
|
)
|
||||||
assert "Übersprungen" in c2.get("/settings").text
|
assert "Übersprungen" in c2.get("/settings").text
|
||||||
|
|
||||||
|
|
||||||
|
# -- Schlüsselverwaltung -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_keys_page_generate_import_select_delete(client: TestClient, state: NetMover) -> None:
|
||||||
|
from netmover.crypto import generate_ssh_keypair
|
||||||
|
|
||||||
|
r = post(client, "/keys/generate", {"name": "paperless-lxc"})
|
||||||
|
assert r.status_code == 303
|
||||||
|
private, public = generate_ssh_keypair()
|
||||||
|
r = post(client, "/keys/import", {"name": "alt", "private_key": private, "passphrase": ""})
|
||||||
|
assert r.status_code == 303
|
||||||
|
page = client.get("/keys").text
|
||||||
|
assert "paperless-lxc" in page and "alt" in page
|
||||||
|
assert public.split()[1] in page # öffentlicher Teil sichtbar
|
||||||
|
for line in private.splitlines()[1:-1]: # keine Zeile des privaten Schlüssels
|
||||||
|
assert line not in page
|
||||||
|
assert "restrict,command="/usr/lib/openssh/sftp-server"" in page
|
||||||
|
|
||||||
|
with state.session_factory() as s:
|
||||||
|
key_id = s.scalar(select(Credential.id).where(Credential.name == "alt"))
|
||||||
|
form = client.get("/mounts/new").text
|
||||||
|
assert f'value="{key_id}"' in form and "neuer Schlüssel nur für diese Anbindung" in form
|
||||||
|
|
||||||
|
r = post(
|
||||||
|
client,
|
||||||
|
"/mounts/new",
|
||||||
|
{
|
||||||
|
"name": "paperless",
|
||||||
|
"type": "sftp",
|
||||||
|
"server": "10.0.0.5",
|
||||||
|
"share": "/consume",
|
||||||
|
"port": "22",
|
||||||
|
"auth": "ssh_key",
|
||||||
|
"key_id": str(key_id),
|
||||||
|
"username": "root",
|
||||||
|
"enabled": "on",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 303, r.text
|
||||||
|
mounts = client.get("/mounts").text
|
||||||
|
assert "gespeichertem Schlüssel" in mounts and "„alt“" in mounts
|
||||||
|
edit = client.get(f"/mounts/{_mount_id(state, 'paperless')}/edit").text
|
||||||
|
assert f'<option value="{key_id}" selected>' in edit and 'value="root"' in edit
|
||||||
|
|
||||||
|
post(client, f"/keys/{key_id}/delete")
|
||||||
|
assert "noch benutzt" in client.get("/keys").text
|
||||||
|
post(client, f"/mounts/{_mount_id(state, 'paperless')}/delete")
|
||||||
|
post(client, f"/keys/{key_id}/delete")
|
||||||
|
page = client.get("/keys").text
|
||||||
|
assert "gelöscht" in page and ">alt<" not in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_import_error_is_shown(client: TestClient) -> None:
|
||||||
|
post(client, "/keys/import", {"name": "x", "private_key": "kein schlüssel", "passphrase": ""})
|
||||||
|
assert "Kein lesbarer privater Schlüssel" in client.get("/keys").text
|
||||||
|
|||||||
Reference in New Issue
Block a user