"""Горячая папка на SMB-шаре (spec image-recognition-gateway, D-1, R-5, slice-0-skeleton). Модуль `smbclient` подменён фейком: UNC-пути `\\\\host\\share\\...` отображаются на `tmp_path`, ошибки — настоящие `SMBOSError` (как у smbprotocol), все вызовы записываются. Тесты проверяют сами вызовы и UNC-пути. """ import os import uuid from pathlib import Path from typing import IO, Any import pytest from smbprotocol.exceptions import NtStatus, SMBException, SMBOSError from src.gateway import smb from src.gateway.hotfolder import HotFolder, inbox_name from src.gateway.smb import SmbHotFolder HOST = "smb.example.local" ROOT = "\\\\smb.example.local\\share\\scan" INPUT = ROOT + "\\Input" OUTPUT = ROOT + "\\Output" class FakeSmbClient: """Подмена модуля `smbclient` (только используемые функции) поверх локального каталога.""" def __init__(self, base: Path) -> None: self.base = base self.calls: list[tuple[str, tuple[str, ...], dict[str, Any]]] = [] self.fail: dict[str, BaseException] = {} # имя функции -> исключение, которое она бросит def _record(self, func: str, args: tuple[str, ...], kwargs: dict[str, Any]) -> None: self.calls.append((func, args, kwargs)) if func in self.fail: raise self.fail[func] def _local(self, unc: str) -> Path: assert unc.startswith("\\\\"), f"не UNC-путь: {unc!r}" assert "/" not in unc, f"прямой слеш в UNC-пути: {unc!r}" return self.base.joinpath(*[part for part in unc.split("\\") if part]) def open_file(self, path: str, mode: str = "r", **kwargs: Any) -> IO[Any]: self._record("open_file", (path, mode), kwargs) local = self._local(path) if not local.parent.is_dir(): raise SMBOSError(NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, path) if "r" in mode and not local.is_file(): raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path) return open(local, mode) # noqa: SIM115 — возвращаем файловый объект, как smbclient.open_file def mkdir(self, path: str, **kwargs: Any) -> None: self._record("mkdir", (path,), kwargs) local = self._local(path) if local.exists(): raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_COLLISION, path) if not local.parent.is_dir(): raise SMBOSError(NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, path) local.mkdir() def replace(self, src: str, dst: str, **kwargs: Any) -> None: self._record("replace", (src, dst), kwargs) local_src = self._local(src) if not local_src.exists(): raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, src) os.replace(local_src, self._local(dst)) def remove(self, path: str, **kwargs: Any) -> None: self._record("remove", (path,), kwargs) local = self._local(path) if not local.is_file(): raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path) local.unlink() def listdir(self, path: str, **kwargs: Any) -> list[str]: self._record("listdir", (path,), kwargs) local = self._local(path) if not local.exists(): raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path) if not local.is_dir(): raise SMBOSError(NtStatus.STATUS_NOT_A_DIRECTORY, path) return [p.name for p in local.iterdir()] def funcs(self) -> list[str]: return [func for func, _, _ in self.calls] @pytest.fixture def fake(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> FakeSmbClient: fake = FakeSmbClient(tmp_path / "smb") monkeypatch.setattr(smb, "smbclient", fake) return fake @pytest.fixture def share_root(fake: FakeSmbClient) -> Path: """Существующий корень горячей папки `\\\\smb.example.local\\share\\scan`.""" root = fake.base / HOST / "share" / "scan" root.mkdir(parents=True) return root def _job_id() -> str: return str(uuid.uuid4()) def _hotfolder(**kwargs: Any) -> SmbHotFolder: return SmbHotFolder(HOST, "share/scan", **kwargs) def _write_answer(share_root: Path, job_id: str, data: bytes) -> None: folder = share_root / "Output" / job_id folder.mkdir(parents=True) (folder / f"{job_id}.json").write_bytes(data) class TestPaths: def test_default_layout(self) -> None: hf = _hotfolder() job_id = _job_id() assert hf.root == ROOT assert hf.input_dir == INPUT assert hf.output_dir == OUTPUT assert hf.answer_path(job_id) == f"{OUTPUT}\\{job_id}\\{job_id}.json" @pytest.mark.parametrize("path", ["share/scan", "share\\scan", "/share/scan/", "\\share\\scan\\"]) def test_relative_share_path_is_joined_with_host(self, path: str) -> None: assert SmbHotFolder(HOST, path).root == ROOT @pytest.mark.parametrize("path", ["\\\\srv\\share\\scan", "//srv/share/scan", "\\\\srv\\share\\scan\\"]) def test_full_unc_path_is_used_as_is(self, path: str) -> None: assert SmbHotFolder(HOST, path).root == "\\\\srv\\share\\scan" def test_share_root_only(self) -> None: assert SmbHotFolder(HOST, "share").root == "\\\\smb.example.local\\share" def test_custom_dir_names(self) -> None: hf = SmbHotFolder(HOST, "share/scan", input_dir="Входящие", output_dir="Исходящие") assert hf.input_dir == ROOT + "\\Входящие" assert hf.output_dir == ROOT + "\\Исходящие" assert hf.answer_path("abc") == ROOT + "\\Исходящие\\abc\\abc.json" @pytest.mark.parametrize(("host", "path"), [(HOST, ""), (HOST, "/"), ("", "share/scan"), ("", "\\\\srv")]) def test_rejects_incomplete_location(self, host: str, path: str) -> None: with pytest.raises(ValueError, match="SMB"): SmbHotFolder(host, path) @pytest.mark.parametrize("job_id", ["", ".", "..", "../x", "a/b", "a\\b", "a:b", "a\x00b"]) def test_answer_path_rejects_unsafe_job_id(self, job_id: str) -> None: with pytest.raises(ValueError, match="job_id"): _hotfolder().answer_path(job_id) def test_constructor_does_not_touch_share(self, fake: FakeSmbClient) -> None: _hotfolder(login="user", password="secret") assert fake.calls == [] def test_implements_protocol(self, fake: FakeSmbClient, share_root: Path) -> None: hf: HotFolder = _hotfolder() assert hf.healthy() is True class TestPut: def test_writes_part_then_replaces(self, fake: FakeSmbClient, share_root: Path) -> None: name = inbox_name(_job_id(), "aep_5.PDF") data = bytes(range(256)) * 10 _hotfolder().put(name, data) part = f"{INPUT}\\.{name}.part" assert fake.calls == [ ("mkdir", (INPUT,), {}), ("open_file", (part, "wb"), {}), ("replace", (part, f"{INPUT}\\{name}"), {}), ] assert (share_root / "Input" / name).read_bytes() == data assert [p.name for p in (share_root / "Input").iterdir()] == [name] def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: (share_root / "Input").mkdir() _hotfolder().put("a.pdf", b"1") assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" def test_missing_root_raises_and_writes_nothing(self, fake: FakeSmbClient) -> None: with pytest.raises(OSError) as excinfo: _hotfolder().put("a.pdf", b"1") assert isinstance(excinfo.value, SMBOSError) assert fake.funcs() == ["mkdir"] assert not fake.base.exists() def test_mkdir_other_error_propagates(self, fake: FakeSmbClient, share_root: Path) -> None: fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) with pytest.raises(SMBOSError): _hotfolder().put("a.pdf", b"1") assert fake.funcs() == ["mkdir"] def test_overwrites_existing_file(self, fake: FakeSmbClient, share_root: Path) -> None: hf = _hotfolder() hf.put("a.pdf", b"old") hf.put("a.pdf", b"new") assert (share_root / "Input" / "a.pdf").read_bytes() == b"new" assert [p.name for p in (share_root / "Input").iterdir()] == ["a.pdf"] def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None: _hotfolder().put("empty.pdf", b"") assert (share_root / "Input" / "empty.pdf").read_bytes() == b"" def test_spaces_and_cyrillic_are_accepted(self, fake: FakeSmbClient, share_root: Path) -> None: name = inbox_name(_job_id(), "Отчёт за 2024 год (копия).pdf") _hotfolder().put(name, b"x") assert (share_root / "Input" / name).read_bytes() == b"x" @pytest.mark.parametrize( "name", [ "", "../escape.pdf", "sub/a.pdf", "sub\\a.pdf", "/abs.pdf", ".hidden.pdf", ".a.pdf.part", "..", ".", "a\x00b", "a:b.pdf", "ab.pdf", 'a"b.pdf', "a|b.pdf", "a?b.pdf", "a*b.pdf", "a\x01b.pdf", "a\nb.pdf", ], ) def test_rejects_invalid_names_before_any_call(self, fake: FakeSmbClient, share_root: Path, name: str) -> None: with pytest.raises(ValueError, match="имя файла"): _hotfolder().put(name, b"x") assert fake.calls == [] def test_cleans_part_file_on_replace_failure(self, fake: FakeSmbClient, share_root: Path) -> None: fake.fail["replace"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) with pytest.raises(OSError): _hotfolder().put("a.pdf", b"data") part = f"{INPUT}\\.a.pdf.part" assert fake.calls[-1] == ("remove", (part,), {}) assert list((share_root / "Input").iterdir()) == [] def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None: fake.fail["replace"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) fake.fail["remove"] = SMBException("соединение потеряно") with pytest.raises(SMBOSError): _hotfolder().put("a.pdf", b"data") assert fake.funcs() == ["mkdir", "open_file", "replace", "remove"] def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None: _hotfolder(login="user", password="secret").put("a.pdf", b"1") assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 3 class TestPoll: def test_none_until_answer_exists(self, fake: FakeSmbClient, share_root: Path) -> None: hf = _hotfolder() job_id = _job_id() assert hf.poll(job_id) is None # нет Output: STATUS_OBJECT_PATH_NOT_FOUND (share_root / "Output").mkdir() assert hf.poll(job_id) is None # нет Output/ (share_root / "Output" / job_id).mkdir() assert hf.poll(job_id) is None # нет .json: STATUS_OBJECT_NAME_NOT_FOUND def test_returns_bytes_when_answer_appears(self, fake: FakeSmbClient, share_root: Path) -> None: hf = _hotfolder() job_id = _job_id() payload = b'{"file_uid": "x", "data_ocr": []}' _write_answer(share_root, job_id, payload) assert hf.poll(job_id) == payload assert fake.calls == [("open_file", (f"{OUTPUT}\\{job_id}\\{job_id}.json", "rb"), {})] def test_reads_only_own_answer(self, fake: FakeSmbClient, share_root: Path) -> None: job_id, other = _job_id(), _job_id() _write_answer(share_root, other, b"{}") (share_root / "Output" / other / "extra.json").write_bytes(b"[]") hf = _hotfolder() assert hf.poll(job_id) is None assert hf.poll(other) == b"{}" assert fake.funcs() == ["open_file", "open_file"] def test_missing_root_is_none(self, fake: FakeSmbClient) -> None: assert _hotfolder().poll(_job_id()) is None def test_plain_file_not_found_is_none(self, fake: FakeSmbClient) -> None: fake.fail["open_file"] = FileNotFoundError("нет файла") assert _hotfolder().poll(_job_id()) is None def test_other_errors_propagate(self, fake: FakeSmbClient, share_root: Path) -> None: fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, OUTPUT) with pytest.raises(SMBOSError): _hotfolder().poll(_job_id()) def test_invalid_job_id_rejected_before_any_call(self, fake: FakeSmbClient) -> None: with pytest.raises(ValueError, match="job_id"): _hotfolder().poll("../x") assert fake.calls == [] def test_passes_credentials(self, fake: FakeSmbClient, share_root: Path) -> None: _hotfolder(login="user", password="secret").poll(_job_id()) assert fake.calls[0][2] == {"username": "user", "password": "secret"} class TestHealthy: def test_true_for_existing_root(self, fake: FakeSmbClient, share_root: Path) -> None: assert _hotfolder().healthy() is True assert fake.calls == [("listdir", (ROOT,), {})] def test_false_for_missing_root(self, fake: FakeSmbClient) -> None: assert _hotfolder().healthy() is False def test_false_for_file_root(self, fake: FakeSmbClient) -> None: file_root = fake.base / HOST / "share" / "scan" file_root.parent.mkdir(parents=True) file_root.write_bytes(b"x") assert _hotfolder().healthy() is False @pytest.mark.parametrize( "error", [SMBException("сервер недоступен"), ValueError("плохой путь"), ConnectionError("сброс"), OSError()] ) def test_never_raises(self, fake: FakeSmbClient, share_root: Path, error: Exception) -> None: fake.fail["listdir"] = error assert _hotfolder().healthy() is False def test_passes_credentials(self, fake: FakeSmbClient, share_root: Path) -> None: _hotfolder(login="user", password="secret").healthy() assert fake.calls == [("listdir", (ROOT,), {"username": "user", "password": "secret"})]