MountBackend-Interface mit LinuxMountBackend (Argumentliste, kurzlebige Credentials-Datei) und FakeMountBackend; MountManager für Einhängen, Aushängen, Start-Wiederherstellung, Drei-Schritt-Test mit Root-Squash- Erkennung, Lebenszeichen und umount -l/Neu-Einhängen; SFTP-Anbindung mit festgehaltenem Hostschlüssel. Tests inkl. echtem SFTP-Server im Prozess. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
155 lines
4.9 KiB
Python
155 lines
4.9 KiB
Python
"""SFTP-Ziel gegen einen echten (im Prozess laufenden) SFTP-Server."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from netmover.crypto import SecretBox
|
|
from netmover.db import session_scope
|
|
from netmover.models import Credential, CredentialKind, Mount, MountState, MountType
|
|
from netmover.mounting.manager import MountManager, MountUnavailable
|
|
from netmover.mounting.sftp import (
|
|
HostKeyMismatch,
|
|
HostKeyUnknown,
|
|
SftpConfig,
|
|
SftpError,
|
|
check_connection,
|
|
connect,
|
|
)
|
|
from tests.sftp_stub import StubSFTPServer
|
|
|
|
|
|
@pytest.fixture
|
|
def server(tmp_path: Path) -> Iterator[StubSFTPServer]:
|
|
root = tmp_path / "sftp-root"
|
|
(root / "consume").mkdir(parents=True)
|
|
srv = StubSFTPServer(root, password="pw").start()
|
|
yield srv
|
|
srv.stop()
|
|
|
|
|
|
def _cfg(server: StubSFTPServer, **kw: object) -> SftpConfig:
|
|
data: dict[str, object] = {
|
|
"host": "127.0.0.1",
|
|
"port": server.port,
|
|
"username": server.username,
|
|
"base_path": "/consume",
|
|
"private_key": server.client_private_key,
|
|
}
|
|
data.update(kw)
|
|
return SftpConfig(**data) # type: ignore[arg-type]
|
|
|
|
|
|
def test_check_connection_with_key_tofu(server: StubSFTPServer) -> None:
|
|
report = check_connection(_cfg(server))
|
|
assert [(s.name, s.ok) for s in report.steps] == [
|
|
("connect", True),
|
|
("read", True),
|
|
("write", True),
|
|
]
|
|
assert report.host_key == server.host_key_string
|
|
assert report.host_key_fingerprint and report.host_key_fingerprint.startswith("SHA256:")
|
|
assert "neu" in report.steps[0].message
|
|
assert report.can_write and report.can_delete
|
|
# Stub kennt kein statvfs → Hinweis statt Fehler
|
|
assert report.free_bytes is None
|
|
assert any("statvfs" in h for h in report.hints)
|
|
assert list((server.root / "consume").iterdir()) == []
|
|
|
|
|
|
def test_password_auth(server: StubSFTPServer) -> None:
|
|
report = check_connection(
|
|
_cfg(server, private_key=None, password="pw", host_key=server.host_key_string)
|
|
)
|
|
assert report.ok
|
|
|
|
|
|
def test_wrong_credentials(server: StubSFTPServer) -> None:
|
|
report = check_connection(_cfg(server, private_key=None, password="falsch"))
|
|
assert report.steps[0].ok is False
|
|
assert "authorized_keys" in report.steps[0].message
|
|
assert "falsch" not in report.steps[0].message
|
|
|
|
|
|
def test_unknown_host_key_refused_outside_test(server: StubSFTPServer) -> None:
|
|
with pytest.raises(HostKeyUnknown):
|
|
connect(_cfg(server))
|
|
|
|
|
|
def test_host_key_mismatch(server: StubSFTPServer) -> None:
|
|
other = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOmvG9t0hJ7oC2Y3Fq6m0eKZ1x2y3z4A5B6C7D8E9F0G"
|
|
with pytest.raises(HostKeyMismatch, match="geändert"):
|
|
connect(_cfg(server, host_key=other))
|
|
report = check_connection(_cfg(server, host_key=other))
|
|
assert report.steps[0].ok is False
|
|
|
|
|
|
def test_missing_base_path(server: StubSFTPServer) -> None:
|
|
report = check_connection(_cfg(server, base_path="/fehlt"))
|
|
assert [(s.name, s.ok) for s in report.steps] == [
|
|
("connect", True),
|
|
("read", False),
|
|
("write", None),
|
|
]
|
|
|
|
|
|
def test_unreachable() -> None:
|
|
with pytest.raises(SftpError, match="fehlgeschlagen"):
|
|
connect(SftpConfig("127.0.0.1", 1, "x", "/", password="x"), timeout=2)
|
|
|
|
|
|
def test_manager_pins_host_key_and_checks_alive(
|
|
server: StubSFTPServer,
|
|
manager: MountManager,
|
|
session_factory: sessionmaker[Session],
|
|
secretbox: SecretBox,
|
|
) -> None:
|
|
with session_scope(session_factory) as s:
|
|
c = Credential(
|
|
name="paperless-key",
|
|
kind=CredentialKind.SSH_KEY,
|
|
username=server.username,
|
|
secret_enc=secretbox.encrypt(server.client_private_key),
|
|
public_key=server.client_public_key,
|
|
)
|
|
s.add(c)
|
|
s.flush()
|
|
m = Mount(
|
|
name="paperless",
|
|
slug="paperless",
|
|
type=MountType.SFTP,
|
|
server="127.0.0.1",
|
|
share="/consume",
|
|
port=server.port,
|
|
cred_id=c.id,
|
|
)
|
|
s.add(m)
|
|
s.flush()
|
|
mid = m.id
|
|
|
|
# vor dem ersten Test: Hostschlüssel unbekannt → kein Lauf möglich
|
|
with pytest.raises(MountUnavailable, match="Hostschlüssel"):
|
|
manager.check_alive(mid)
|
|
|
|
from netmover.mounting.manager import CredentialData, MountConfig
|
|
|
|
with session_factory() as s:
|
|
mm = s.get(Mount, mid)
|
|
assert mm is not None
|
|
cfg = MountConfig.from_model(mm)
|
|
cred = CredentialData(CredentialKind.SSH_KEY, server.username, server.client_private_key)
|
|
assert manager.check(cfg, cred, mount_id=mid).ok
|
|
|
|
with session_factory() as s:
|
|
mm = s.get(Mount, mid)
|
|
assert mm is not None and mm.host_key == server.host_key_string
|
|
|
|
assert manager.check_alive(mid) is None
|
|
with session_factory() as s:
|
|
mm = s.get(Mount, mid)
|
|
assert mm is not None and mm.state is MountState.MOUNTED
|