feat: Anbindungen kopieren – inklusive Zugangsdaten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-12 00:50:00 +02:00
co-authored by Claude Opus 5.5
parent 660bb70c5d
commit 43b634a135
11 changed files with 322 additions and 20 deletions
+194
View File
@@ -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