feat: gespeicherte Zugangsdaten für SMB und SFTP-Passwörter
Seite „Zugangsdaten“ (vorher „Schlüssel“) mit Passwörtern und SSH-Schlüsseln; Anbindungen wählen gespeicherte Passwörter aus, ein neues Passwort gilt sofort für alle. /keys leitet auf /credentials um. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
893fe0c95d
commit
f5ea5840d4
@@ -13,9 +13,16 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from netmover.admin import AdminError, MountInput, delete_mount, save_mount
|
||||
from netmover.credentials import (
|
||||
delete_key,
|
||||
fingerprint,
|
||||
generate_key,
|
||||
import_key,
|
||||
list_keys,
|
||||
rename_key,
|
||||
)
|
||||
from netmover.crypto import SecretBox, generate_ssh_keypair
|
||||
from netmover.db import session_scope
|
||||
from netmover.keys import delete_key, fingerprint, generate_key, import_key, list_keys, rename_key
|
||||
from netmover.models import Credential, CredentialKind, Mount, MountType
|
||||
from netmover.mounting.manager import MountConfig, MountManager
|
||||
from tests.sftp_stub import StubSFTPServer
|
||||
@@ -142,7 +149,7 @@ def test_import_errors(session: Session, secretbox: SecretBox) -> None:
|
||||
|
||||
def test_names(session: Session, secretbox: SecretBox) -> None:
|
||||
generate_key(session, secretbox, "eins")
|
||||
with pytest.raises(AdminError, match="existiert bereits"):
|
||||
with pytest.raises(AdminError, match="existieren bereits"):
|
||||
generate_key(session, secretbox, "eins")
|
||||
with pytest.raises(AdminError, match="reserviert"):
|
||||
generate_key(session, secretbox, "mount:x")
|
||||
@@ -275,3 +282,102 @@ def test_list_keys_ignores_per_mount_credentials(
|
||||
save_mount(session, secretbox, manager, _sftp_input())
|
||||
assert list_keys(session) == []
|
||||
assert session.scalars(select(Credential)).one().managed is False
|
||||
|
||||
|
||||
# -- Gespeicherte Passwörter (0.5.0) ----------------------------------------------------------
|
||||
|
||||
|
||||
def _cifs(**kw: object) -> MountInput:
|
||||
data: dict[str, object] = {
|
||||
"name": "nas",
|
||||
"type": MountType.CIFS,
|
||||
"server": "10.0.10.6",
|
||||
"share": "scans",
|
||||
}
|
||||
data.update(kw)
|
||||
return MountInput(**data) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_stored_password_shared_by_mounts(
|
||||
session: Session, secretbox: SecretBox, manager: MountManager
|
||||
) -> None:
|
||||
from netmover.credentials import create_password, list_passwords, update_password
|
||||
|
||||
cred = create_password(session, secretbox, "NAS-Scanner", "scanner", "Pa$$,wort", "WG")
|
||||
a, err = save_mount(session, secretbox, manager, _cifs(cred_id=str(cred.id)))
|
||||
assert not err and a is not None
|
||||
b, err = save_mount(
|
||||
session, secretbox, manager, _cifs(name="archiv", share="archiv", cred_id=str(cred.id))
|
||||
)
|
||||
assert not err and b is not None
|
||||
assert a.credential is cred and b.credential is cred and a.username is None
|
||||
assert list_passwords(session)[0].used_by == ["archiv", "nas"]
|
||||
|
||||
# ein neues Passwort gilt für beide
|
||||
update_password(session, secretbox, cred.id, "scanner", "neu!", "WG")
|
||||
from netmover.admin import credential_for_test, mount_input_from_model
|
||||
|
||||
for m in (a, b):
|
||||
data = mount_input_from_model(m)
|
||||
assert data.cred_id == str(cred.id) and data.username == "scanner"
|
||||
c = credential_for_test(data, m, secretbox, session)
|
||||
assert c is not None and c.secret == "neu!" and c.domain == "WG"
|
||||
|
||||
# Anbindung löschen lässt die gespeicherten Zugangsdaten stehen
|
||||
delete_mount(session, manager, a.id)
|
||||
from netmover.credentials import delete_credential
|
||||
|
||||
with pytest.raises(AdminError, match="noch benutzt"):
|
||||
delete_credential(session, cred.id)
|
||||
delete_mount(session, manager, b.id)
|
||||
assert delete_credential(session, cred.id) == "NAS-Scanner"
|
||||
|
||||
|
||||
def test_switch_from_own_to_stored_password_cleans_up(
|
||||
session: Session, secretbox: SecretBox, manager: MountManager
|
||||
) -> None:
|
||||
from netmover.credentials import create_password
|
||||
|
||||
m, _ = save_mount(session, secretbox, manager, _cifs(username="alt", password="pw"))
|
||||
assert m is not None and m.credential is not None
|
||||
own_id = m.credential.id
|
||||
cred = create_password(session, secretbox, "geteilt", "scanner", "x")
|
||||
m, err = save_mount(session, secretbox, manager, _cifs(cred_id=str(cred.id)), m.id)
|
||||
assert not err and m is not None and m.credential is cred
|
||||
assert session.get(Credential, own_id) is None
|
||||
# zurück auf eigene Angaben: gespeicherter Eintrag bleibt unverändert
|
||||
m, err = save_mount(session, secretbox, manager, _cifs(username="eigen", password="pw2"), m.id)
|
||||
assert not err and m is not None and not m.credential.managed # type: ignore[union-attr]
|
||||
assert cred.username == "scanner" and secretbox.decrypt(cred.secret_enc) == "x"
|
||||
|
||||
|
||||
def test_stored_password_validation(session: Session, secretbox: SecretBox) -> None:
|
||||
from netmover.credentials import create_password, update_password
|
||||
|
||||
with pytest.raises(AdminError, match="Passwort fehlt"):
|
||||
create_password(session, secretbox, "a", "u", "")
|
||||
with pytest.raises(AdminError):
|
||||
create_password(session, secretbox, "a", "u", "zeile\numbruch")
|
||||
cred = create_password(session, secretbox, "a", "u", "pw")
|
||||
update_password(session, secretbox, cred.id, "u2", "") # leer = unverändert
|
||||
assert cred.username == "u2" and secretbox.decrypt(cred.secret_enc) == "pw"
|
||||
|
||||
|
||||
def test_stored_password_mount_actually_mounts(
|
||||
session_factory: sessionmaker[Session],
|
||||
secretbox: SecretBox,
|
||||
manager: MountManager,
|
||||
fake_backend: object,
|
||||
) -> None:
|
||||
from netmover.credentials import create_password
|
||||
|
||||
fake_backend.export("10.0.10.6", "scans") # type: ignore[attr-defined]
|
||||
with session_scope(session_factory) as s:
|
||||
cred = create_password(s, secretbox, "NAS", "scanner", "Pa$$wort", "WG")
|
||||
m, _ = save_mount(s, secretbox, manager, _cifs(cred_id=str(cred.id)))
|
||||
assert m is not None
|
||||
mid = m.id
|
||||
manager.mount(mid)
|
||||
spec = fake_backend.calls[-1].spec # type: ignore[attr-defined]
|
||||
assert spec.credentials.username == "scanner" and spec.credentials.password == "Pa$$wort"
|
||||
assert spec.credentials.domain == "WG"
|
||||
+51
-11
@@ -512,12 +512,16 @@ def test_export_excludes_secrets_and_import_roundtrip(
|
||||
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"})
|
||||
r = post(client, "/credentials/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": ""})
|
||||
r = post(
|
||||
client,
|
||||
"/credentials/keys/import",
|
||||
{"name": "alt", "private_key": private, "passphrase": ""},
|
||||
)
|
||||
assert r.status_code == 303
|
||||
page = client.get("/keys").text
|
||||
page = client.get("/credentials").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
|
||||
@@ -546,27 +550,31 @@ def test_keys_page_generate_import_select_delete(client: TestClient, state: NetM
|
||||
)
|
||||
assert r.status_code == 303, r.text
|
||||
mounts = client.get("/mounts").text
|
||||
assert "gespeichertem Schlüssel" in mounts and "„alt“" in mounts
|
||||
assert "gespeicherten Zugangsdaten" 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"/credentials/{key_id}/delete")
|
||||
assert "noch benutzt" in client.get("/credentials").text
|
||||
post(client, f"/mounts/{_mount_id(state, 'paperless')}/delete")
|
||||
post(client, f"/keys/{key_id}/delete")
|
||||
page = client.get("/keys").text
|
||||
post(client, f"/credentials/{key_id}/delete")
|
||||
page = client.get("/credentials").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
|
||||
post(
|
||||
client,
|
||||
"/credentials/keys/import",
|
||||
{"name": "x", "private_key": "kein schlüssel", "passphrase": ""},
|
||||
)
|
||||
assert "Kein lesbarer privater Schlüssel" in client.get("/credentials").text
|
||||
|
||||
|
||||
def test_no_escaped_attributes_in_pages(client: TestClient, state: NetMover) -> None:
|
||||
"""Attribute in {{ … }} werden von Jinja maskiert – dann landet " im HTML."""
|
||||
|
||||
post(client, "/keys/generate", {"name": "k"})
|
||||
post(client, "/credentials/keys/generate", {"name": "k"})
|
||||
for url in (
|
||||
"/",
|
||||
"/mounts",
|
||||
@@ -581,3 +589,35 @@ def test_no_escaped_attributes_in_pages(client: TestClient, state: NetMover) ->
|
||||
html = client.get(url).text
|
||||
broken = re.findall(r"<[a-z][^<>]*="[^<>]*>", html) # nur in Tags, nicht im Text
|
||||
assert not broken, (url, broken[:2])
|
||||
|
||||
|
||||
def test_credentials_page_passwords(
|
||||
client: TestClient, state: NetMover, backend: FakeMountBackend
|
||||
) -> None:
|
||||
r = post(
|
||||
client,
|
||||
"/credentials/passwords",
|
||||
{"name": "NAS-Scanner", "username": "scanner", "password": "Geheim,123", "domain": ""},
|
||||
)
|
||||
assert r.status_code == 303
|
||||
page = client.get("/credentials").text
|
||||
assert "NAS-Scanner" in page and "scanner" in page and "Geheim,123" not in page
|
||||
with state.session_factory() as s:
|
||||
cid = s.scalar(select(Credential.id).where(Credential.name == "NAS-Scanner"))
|
||||
form = client.get("/mounts/new").text
|
||||
assert f'<option value="{cid}"' in form and "eigene Angaben für diese Anbindung" in form
|
||||
backend.export("nas", "scans")
|
||||
r = post(
|
||||
client, "/mounts/new", {**MOUNT_FORM, "username": "", "password": "", "cred_id": str(cid)}
|
||||
)
|
||||
assert r.status_code == 303, r.text
|
||||
assert "gespeicherten Zugangsdaten" in client.get("/mounts").text
|
||||
mid = _mount_id(state, "Synology Scans")
|
||||
assert "Quelle mit Löschen" in post(client, f"/mounts/{mid}/test").text
|
||||
post(
|
||||
client,
|
||||
f"/credentials/{cid}/password",
|
||||
{"username": "scanner2", "domain": "", "password": ""},
|
||||
)
|
||||
assert "gilt ab sofort für alle" in client.get("/credentials").text
|
||||
assert client.get("/keys").headers["location"] == "/credentials"
|
||||
|
||||
Reference in New Issue
Block a user