From 43b634a1357ecc8f903e1992000c3471c14b8f28 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 00:50:00 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Anbindungen=20kopieren=20=E2=80=93=20in?= =?UTF-8?q?klusive=20Zugangsdaten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +- DEVLOG.md | 17 +- ROADMAP.md | 1 + compose.yml | 2 +- docs/ANLEITUNG.md | 16 ++ src/netmover/__init__.py | 2 +- src/netmover/admin.py | 43 ++++- src/netmover/web/app.py | 47 ++++- src/netmover/web/templates/mount_form.html | 6 +- src/netmover/web/templates/mounts.html | 1 + tests/test_copy_mount.py | 194 +++++++++++++++++++++ 11 files changed, 322 insertions(+), 20 deletions(-) create mode 100644 tests/test_copy_mount.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 45eddb9..72c9a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`. ## [Unreleased] +## [0.8.0] – 2026-10-12 + +### Hinzugefügt +- **Anbindungen kopieren** (Knopf „Kopieren“): Formular vorausgefüllt, Name + „… (Kopie)“. Eigene Zugangsdaten der Vorlage werden übernommen (eigenes Exemplar), + solange kein neues Passwort eingegeben wird; ein eigener SSH-Schlüssel wird + weiterverwendet; gespeicherte Zugangsdaten bleiben ausgewählt. „Testen“ im + Kopie-Formular nutzt die übernommenen Zugangsdaten. Der SFTP-Hostschlüssel wird nur + bei gleichem Server und Port übernommen. + ## [0.7.0] – 2026-10-12 ### Hinzugefügt @@ -259,7 +269,8 @@ Erste Version. - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. -[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.7.0...HEAD +[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.8.0...HEAD +[0.8.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.7.0...v0.8.0 [0.7.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.6.0...v0.7.0 [0.6.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...v0.6.0 [0.5.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.4.0...v0.5.0 diff --git a/DEVLOG.md b/DEVLOG.md index 08c730f..5ea60a9 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -21,10 +21,25 @@ Zeiten in Ortszeit (Europe/Berlin). | 2026-10-11 | 00:54 | 01:16 | 0:22 | – | SFTP-Quelle, Zugangsdaten, Anmeldung (0.5.0) | | 2026-10-11 | 08:43 | 08:55 | 0:12 | – | Anmeldung je Netz (0.6.0) | | 2026-10-12 | 00:14 | 00:34 | 0:20 | – | Archiv je Regel (0.7.0) | -| **Summe** | | | **3:49** | | | +| 2026-10-12 | 00:46 | 00:55 | 0:09 | – | Anbindungen kopieren (0.8.0) | +| **Summe** | | | **3:58** | | | --- +## 2026-10-12 · 00:46–00:55 · Anbindungen kopieren (0.8.0) + +**Erledigt** – Knopf „Kopieren“, Route `/mounts/{id}/copy` (GET Formular, POST +speichern), `save_mount(copy_from=…)`, Formular-Test mit Vorlage. 7 Tests (357 gesamt); +Gegenprobe ohne Übernahme der Zugangsdaten: 3 Tests schlagen an. + +**Entscheidungen** +- Eigene Zugangsdaten werden als eigenes Exemplar kopiert (nicht geteilt) – eigene + Zugangsdaten verschwinden mit ihrer Anbindung, eine geteilte Nutzung wäre + dafür der Fall „Zugangsdaten“. +- Eigenes Schlüsselpaar bleibt gleich (schon in `authorized_keys`). +- Hostschlüssel nur bei gleichem Server/Port übernehmen, sonst würde der Test an + einem anderen Server mit „Hostschlüssel geändert“ scheitern. + ## 2026-10-12 · 00:14–00:34 · Archiv je Regel (0.7.0) **Erledigt** diff --git a/ROADMAP.md b/ROADMAP.md index 0cbdaf5..51bde57 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht | Version | Thema | |---|---| +| 0.8.0 | Anbindungen kopieren inklusive Zugangsdaten | | 0.7.0 | Archiv je Regel: Kopie ins Ziel und ins Archiv, Quelle erst danach löschen | | 0.6.0 | Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen, Schutz vor Aussperren | | 0.5.0 | SFTP als Quelle; gespeicherte Zugangsdaten für SMB/SFTP-Passwörter (Seite „Zugangsdaten“); Anmeldung abschaltbar (vertrauenswürdige Netze / ganz) | diff --git a/compose.yml b/compose.yml index 87131a3..38a3d17 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: netmover: build: . - image: netmover:0.7.0 + image: netmover:0.8.0 container_name: netmover restart: unless-stopped cap_add: diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 22cb682..8e8c114 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -72,6 +72,22 @@ Stabilitätsfenster dort mindestens 2 Sekunden betragen. | **Beim Start automatisch einhängen** | Nach einem Neustart des Servers wieder einhängen. Fällt das NAS aus, versucht NetMover es außerdem jede Minute erneut. | | **Aktiv** | Deaktivierte Anbindungen werden von Regeln nicht benutzt. | +### Kopieren {#anbindung-kopieren} + +Für eine ähnliche Anbindung – etwa eine zweite Freigabe auf demselben NAS – bei der +Vorlage auf **Kopieren** klicken. Das Formular ist mit allen Werten vorausgefüllt +(Name „… (Kopie)“); meist muss nur die Freigabe geändert werden. + +- **Zugangsdaten werden übernommen**, solange das Passwortfeld leer bleibt. Die Kopie + bekommt ein eigenes Exemplar – das Löschen der Vorlage betrifft sie nicht. + Gespeicherte Zugangsdaten sind einfach vorausgewählt. +- Ein **eigener SSH-Schlüssel** der Vorlage wird weiter benutzt; ein neuer Eintrag in + `authorized_keys` ist nicht nötig. +- **Testen** funktioniert im Kopie-Formular bereits mit den übernommenen Zugangsdaten. + +Tipp: Nutzen mehrere Anbindungen dieselben Zugangsdaten, lohnt sich ein Eintrag unter +[Zugangsdaten](#zugangsdaten) – dann gilt ein neues Passwort sofort überall. + ### Testen {#anbindung-testen} **Testen** prüft drei Dinge einzeln und sagt im Klartext, was nicht klappt: diff --git a/src/netmover/__init__.py b/src/netmover/__init__.py index b627107..b847c1a 100644 --- a/src/netmover/__init__.py +++ b/src/netmover/__init__.py @@ -1,3 +1,3 @@ """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" -__version__ = "0.7.0" +__version__ = "0.8.0" diff --git a/src/netmover/admin.py b/src/netmover/admin.py index 581e086..f64a648 100644 --- a/src/netmover/admin.py +++ b/src/netmover/admin.py @@ -294,7 +294,11 @@ def save_mount( mounts: MountManager, data: MountInput, mount_id: int | None = None, + copy_from: Mount | None = None, ) -> tuple[Mount | None, Errors]: + """``copy_from``: Vorlage beim Kopieren – eigene Zugangsdaten (Passwort bzw. + Schlüsselpaar) werden übernommen, solange kein neues Passwort eingegeben ist; + die Kopie bekommt ein eigenes Exemplar.""" errors: Errors = {} m = s.get(Mount, mount_id) if mount_id is not None else None if mount_id is not None and m is None: @@ -349,6 +353,14 @@ def save_mount( setattr(m, f, getattr(data, f)) if not data.type.is_kernel_mount: m.auto_mount = False + if ( + mount_id is None + and copy_from is not None + and data.type is MountType.SFTP + and copy_from.type is MountType.SFTP + and (copy_from.server, copy_from.port) == (data.server, data.port) + ): + m.host_key = copy_from.host_key # gleicher Server – Hostschlüssel bleibt gültig # Zugangsdaten: entweder ein gespeicherter Schlüssel aus der Schlüsselverwaltung # (geteilt, wird hier nie verändert) oder eigene Zugangsdaten nur dieser Anbindung. @@ -386,21 +398,32 @@ def save_mount( else: m.username = None cred = own + template = ( + copy_from.credential + if copy_from is not None + and copy_from.credential is not None + and not copy_from.credential.managed + and copy_from.credential.kind is data.auth + else None + ) if cred is None or cred.kind is not data.auth: - if data.auth is CredentialKind.SSH_KEY: - private, public = generate_ssh_keypair(f"netmover@{m.slug}") - secret, pub = private, public + if template is not None and (data.auth is CredentialKind.SSH_KEY or not data.password): + # Kopie: dasselbe Passwort bzw. Schlüsselpaar (steht schon in authorized_keys) + secret_enc, pub = template.secret_enc, template.public_key + elif data.auth is CredentialKind.SSH_KEY: + private, pub = generate_ssh_keypair(f"netmover@{m.slug}") + secret_enc = secrets.encrypt(private) else: if not data.password: errors["password"] = "Passwort fehlt." # noqa: S105 s.rollback() return None, errors - secret, pub = data.password, None + secret_enc, pub = secrets.encrypt(data.password), None if cred is None: cred = Credential(name=f"mount:{m.slug}", managed=False) s.add(cred) cred.kind = data.auth - cred.secret_enc = secrets.encrypt(secret) + cred.secret_enc = secret_enc cred.public_key = pub elif data.password and data.auth is CredentialKind.PASSWORD: cred.secret_enc = secrets.encrypt(data.password) @@ -421,6 +444,16 @@ def save_mount( return m, errors +def copy_name(s: Session, name: str) -> str: + """„NAS (Kopie)“, „NAS (Kopie 2)“, … – frei in der Datenbank.""" + base = f"{name} (Kopie)"[:100] + candidate, n = base, 1 + while s.scalar(select(Mount.id).where(Mount.name == candidate)) is not None: + n += 1 + candidate = f"{name} (Kopie {n})"[:100] + return candidate + + def delete_mount(s: Session, mounts: MountManager, mount_id: int) -> None: m = s.get(Mount, mount_id) if m is None: diff --git a/src/netmover/web/app.py b/src/netmover/web/app.py index 77821f7..44e05d8 100644 --- a/src/netmover/web/app.py +++ b/src/netmover/web/app.py @@ -38,6 +38,7 @@ from netmover.admin import ( AdminError, MountInput, RuleInput, + copy_name, credential_for_test, delete_mount, mount_input_from_model, @@ -591,10 +592,12 @@ def _mount_form( errors: dict[str, str], mount: Mount | None = None, status_code: int = 200, + copy_from: Mount | None = None, ) -> Response: + owner = mount or copy_from pub = ( - mount.credential.public_key - if mount is not None and mount.credential and not mount.credential.managed + owner.credential.public_key + if owner is not None and owner.credential and not owner.credential.managed else None ) return render( @@ -607,8 +610,9 @@ def _mount_form( mount=mount, public_key=pub, has_secret=bool( - mount is not None and mount.credential is not None and not mount.credential.managed + owner is not None and owner.credential is not None and not owner.credential.managed ), + copy_from=copy_from, key_options=_key_options(request), password_options=_password_options(request), ) @@ -628,16 +632,33 @@ async def mount_edit(request: Request, mount_id: int) -> Response: return _mount_form(request, mount_input_from_model(m), {}, m) +@protected.get("/mounts/{source_id}/copy", response_class=HTMLResponse) +async def mount_copy(request: Request, source_id: int) -> Response: + """Formular für eine neue Anbindung, vorausgefüllt aus einer bestehenden.""" + with nm(request).session_factory() as s: + src = s.get(Mount, source_id) + if src is None: + return redirect("/mounts") + data = mount_input_from_model(src) + data.name = copy_name(s, src.name) + return _mount_form(request, data, {}, copy_from=src) + + @protected.post("/mounts/new", response_class=HTMLResponse) @protected.post("/mounts/{mount_id}/edit", response_class=HTMLResponse) -async def mount_save(request: Request, mount_id: int | None = None) -> Response: +@protected.post("/mounts/{source_id}/copy", response_class=HTMLResponse) +async def mount_save( + request: Request, mount_id: int | None = None, source_id: int | None = None +) -> Response: state = nm(request) data, errors = parse_mount_form(_form_dict(await request.form())) with session_scope(state.session_factory) as s: - existing = s.get(Mount, mount_id) if mount_id is not None else None + copy_from = s.get(Mount, source_id) if source_id is not None else None if not errors: try: - m, errors = save_mount(s, state.secrets, state.mounts, data, mount_id) + m, errors = save_mount( + s, state.secrets, state.mounts, data, mount_id, copy_from=copy_from + ) except AdminError as exc: errors = {"_": str(exc)} m = None @@ -646,7 +667,10 @@ async def mount_save(request: Request, mount_id: int | None = None) -> Response: if errors or m is None: s.rollback() existing = s.get(Mount, mount_id) if mount_id is not None else None - return _mount_form(request, data, errors, existing, status_code=400) + copy_from = s.get(Mount, source_id) if source_id is not None else None + return _mount_form( + request, data, errors, existing, status_code=400, copy_from=copy_from + ) saved_id, name = m.id, m.name is_sftp_key = m.type is MountType.SFTP and data.auth is CredentialKind.SSH_KEY flash(request, f"Anbindung „{name}“ gespeichert.") @@ -668,12 +692,17 @@ async def mount_test_form(request: Request) -> Response: data, errors = parse_mount_form(form) if errors: return render(request, "partials/check_result.html", errors=errors, report=None) - existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None + ref = form.get("mount_id") or form.get("copy_from") or "" + existing_id = int(ref) if ref.isdigit() else None with state.session_factory() as s: existing = s.get(Mount, existing_id) if existing_id else None cred = credential_for_test(data, existing, state.secrets, s) 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 + and (existing.server, existing.port) == (data.server, data.port) + ): cfg = dataclasses.replace(cfg, host_key=existing.host_key) report = await run_in_threadpool(state.mounts.check, cfg, cred, None) return render(request, "partials/check_result.html", report=report, errors={}) diff --git a/src/netmover/web/templates/mount_form.html b/src/netmover/web/templates/mount_form.html index 552af92..4d347e3 100644 --- a/src/netmover/web/templates/mount_form.html +++ b/src/netmover/web/templates/mount_form.html @@ -1,12 +1,14 @@ {% extends "base.html" %} {% from "macros.html" import field, select, check %} -{% block title %}{{ 'Anbindung bearbeiten' if mount else 'Neue Anbindung' }}{% endblock %} +{% block title %}{{ 'Anbindung bearbeiten' if mount else ('Anbindung kopieren' if copy_from else 'Neue Anbindung') }}{% endblock %} {% block content %} -

{{ 'Anbindung „' ~ mount.name ~ '“ bearbeiten' if mount else 'Neue Anbindung' }}

+

{% if mount %}Anbindung „{{ mount.name }}“ bearbeiten{% elif copy_from %}Kopie von „{{ copy_from.name }}“{% else %}Neue Anbindung{% endif %}

+{% if copy_from %}

Alle Werte stammen aus „{{ copy_from.name }}“. Zugangsdaten werden übernommen, solange du das Passwort leer lässt – die Kopie bekommt ihr eigenes Exemplar.{% if copy_from.credential and copy_from.credential.public_key and not copy_from.credential.managed %} Der SSH-Schlüssel bleibt derselbe, ein neuer Eintrag in authorized_keys ist nicht nötig.{% endif %}

{% endif %} {% if errors.get('_') %}

{{ errors['_'] }}

{% endif %}
{% if mount %}{% endif %} + {% if copy_from %}{% endif %}
Verbindung ?
{{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"', hint=('Verzeichnis: ' ~ mount.mountpoint if mount and mount.mountpoint else '')) }} {{ select('type', 'Typ', [('cifs', 'SMB/CIFS'), ('nfs4', 'NFS 4'), ('nfs3', 'NFS 3'), ('sftp', 'SFTP')], d.type.value, errors=errors) }} diff --git a/src/netmover/web/templates/mounts.html b/src/netmover/web/templates/mounts.html index 481d2f0..831cd6d 100644 --- a/src/netmover/web/templates/mounts.html +++ b/src/netmover/web/templates/mounts.html @@ -22,6 +22,7 @@ {% endif %} {% endif %} Bearbeiten + Kopieren {{ csrf_field(csrf) }}
diff --git a/tests/test_copy_mount.py b/tests/test_copy_mount.py new file mode 100644 index 0000000..d3b8d0c --- /dev/null +++ b/tests/test_copy_mount.py @@ -0,0 +1,194 @@ +"""0.8.0: Anbindungen kopieren – inklusive Zugangsdaten.""" + +from __future__ import annotations + +from typing import Any + +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.orm import Session + +from netmover.admin import MountInput, copy_name, delete_mount, save_mount +from netmover.crypto import SecretBox +from netmover.models import Credential, CredentialKind, Mount, MountType +from netmover.mounting.fake import FakeMountBackend +from netmover.mounting.manager import MountManager +from tests.webutil import post + + +def _cifs(**kw: object) -> MountInput: + data: dict[str, object] = { + "name": "NAS", + "type": MountType.CIFS, + "server": "10.0.10.6", + "share": "Scans", + "username": "scanner", + "password": "Pa$$wort", + } + data.update(kw) + return MountInput(**data) # type: ignore[arg-type] + + +def test_copy_reuses_own_password_as_independent_copy( + session: Session, secretbox: SecretBox, manager: MountManager +) -> None: + src, _ = save_mount(session, secretbox, manager, _cifs()) + assert src is not None and src.credential is not None + copy, err = save_mount( + session, + secretbox, + manager, + _cifs(name="Archiv", share="Archiv", password=""), + copy_from=src, + ) + assert not err and copy is not None and copy.credential is not None + assert copy.credential.id != src.credential.id and not copy.credential.managed + assert secretbox.decrypt(copy.credential.secret_enc) == "Pa$$wort" + assert copy.credential.username == "scanner" + # Vorlage löschen – die Kopie behält ihre Zugangsdaten + delete_mount(session, manager, src.id) + session.flush() + assert secretbox.decrypt(copy.credential.secret_enc) == "Pa$$wort" + + +def test_copy_with_new_password_uses_new_one( + session: Session, secretbox: SecretBox, manager: MountManager +) -> None: + src, _ = save_mount(session, secretbox, manager, _cifs()) + copy, _ = save_mount( + session, secretbox, manager, _cifs(name="B", share="B", password="anders"), copy_from=src + ) + assert copy is not None and copy.credential is not None + assert secretbox.decrypt(copy.credential.secret_enc) == "anders" + + +def test_copy_with_stored_credentials_shares_them( + session: Session, secretbox: SecretBox, manager: MountManager +) -> None: + from netmover.credentials import create_password + + cred = create_password(session, secretbox, "NAS-Scanner", "scanner", "x") + src, _ = save_mount( + session, secretbox, manager, _cifs(username="", password="", cred_id=str(cred.id)) + ) + copy, _ = save_mount( + session, + secretbox, + manager, + _cifs(name="B", share="B", username="", password="", cred_id=str(cred.id)), + copy_from=src, + ) + assert ( + copy is not None and copy.credential is cred and src is not None and src.credential is cred + ) + + +def test_copy_sftp_keeps_keypair_and_host_key_only_for_same_server( + session: Session, secretbox: SecretBox, manager: MountManager +) -> None: + sftp = { + "type": MountType.SFTP, + "server": "10.0.10.17", + "share": "/consume", + "port": 22, + "username": "paperless", + "auth": CredentialKind.SSH_KEY, + "password": "", + } + src, _ = save_mount(session, secretbox, manager, _cifs(name="Paperless", **sftp)) + assert src is not None and src.credential is not None + src.host_key = "ssh-ed25519 AAAAhost" + same, _ = save_mount( + session, + secretbox, + manager, + _cifs(name="Paperless 2", **{**sftp, "share": "/consume2"}), + copy_from=src, + ) + other, _ = save_mount( + session, + secretbox, + manager, + _cifs(name="Anderer", **{**sftp, "server": "10.0.10.99"}), + copy_from=src, + ) + assert same is not None and other is not None + for m in (same, other): + assert m.credential is not None and m.credential.id != src.credential.id + assert m.credential.public_key == src.credential.public_key # gleiches Schlüsselpaar + assert m.credential.secret_enc == src.credential.secret_enc + assert same.host_key == "ssh-ed25519 AAAAhost" and other.host_key is None + + +def test_copy_name(session: Session, secretbox: SecretBox, manager: MountManager) -> None: + save_mount(session, secretbox, manager, _cifs()) + assert copy_name(session, "NAS") == "NAS (Kopie)" + save_mount(session, secretbox, manager, _cifs(name="NAS (Kopie)", share="x")) + assert copy_name(session, "NAS") == "NAS (Kopie 2)" + + +# -- Oberfläche -------------------------------------------------------------------------------- + + +MOUNT = { + "name": "NAS", + "type": "cifs", + "server": "nas", + "share": "scans", + "username": "scanner", + "password": "Geheim,123", + "auto_mount": "on", + "enabled": "on", +} + + +def _mid(state: Any, name: str) -> int: + with state.session_factory() as s: + mid = s.scalar(select(Mount.id).where(Mount.name == name)) + assert mid is not None + return mid + + +def test_copy_button_form_test_and_save( + client: TestClient, state: Any, backend: FakeMountBackend +) -> None: + backend.export("nas", "scans") + backend.export("nas", "archiv") + post(client, "/mounts/new", MOUNT) + src_id = _mid(state, "NAS") + assert f'href="/mounts/{src_id}/copy"' in client.get("/mounts").text + + form = client.get(f"/mounts/{src_id}/copy").text + assert "Kopie von „NAS“" in form and 'value="NAS (Kopie)"' in form + assert 'value="scanner"' in form and "Geheim,123" not in form + assert f'name="copy_from" value="{src_id}"' in form + + # Testen mit übernommenem Passwort (Feld leer) + copy_form = { + **MOUNT, + "name": "NAS (Kopie)", + "share": "archiv", + "password": "", + "copy_from": str(src_id), + } + r = post(client, "/mounts/test", copy_form) + assert "Einhängen erfolgreich" in r.text + spec = [c for c in backend.calls if c.action == "mount"][-1].spec + assert spec is not None and spec.credentials is not None + assert spec.credentials.password == "Geheim,123" + + r = post(client, f"/mounts/{src_id}/copy", copy_form) + assert r.status_code == 303, r.text + with state.session_factory() as s: + copy = s.get(Mount, _mid(state, "NAS (Kopie)")) + assert copy is not None and copy.share == "archiv" and copy.slug == "nas-kopie" + assert copy.credential is not None + assert state.secrets.decrypt(copy.credential.secret_enc) == "Geheim,123" + assert len(s.scalars(select(Credential)).all()) == 2 + + +def test_copy_validation_error_keeps_copy_context(client: TestClient, state: Any) -> None: + post(client, "/mounts/new", MOUNT) + src_id = _mid(state, "NAS") + r = post(client, f"/mounts/{src_id}/copy", {**MOUNT, "name": "NAS", "password": ""}) + assert r.status_code == 400 and "bereits vergeben" in r.text and "Kopie von „NAS“" in r.text