"""Горячая папка на 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 types import SimpleNamespace 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] = {} # имя функции -> исключение, которое она бросит self.path = SimpleNamespace(isdir=self._isdir) # подмодуль smbclient.path def _isdir(self, path: str, **kwargs: Any) -> bool: self._record("isdir", (path,), kwargs) return self._local(path).is_dir() 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_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: """Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно.""" (share_root / "Input").mkdir() fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) _hotfolder().put("a.pdf", b"1") assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" assert fake.funcs() == ["mkdir", "isdir", "open_file", "replace"] assert fake.calls[1] == ("isdir", (INPUT,), {}) def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) fake.fail["mkdir"] = denied with pytest.raises(SMBOSError) as excinfo: _hotfolder().put("a.pdf", b"1") assert excinfo.value is denied assert fake.funcs() == ["mkdir", "isdir"] def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) fake.fail["mkdir"] = denied fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) with pytest.raises(SMBOSError) as excinfo: _hotfolder().put("a.pdf", b"1") assert excinfo.value is denied assert fake.funcs() == ["mkdir", "isdir"] 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 @pytest.mark.parametrize("status", [NtStatus.STATUS_SHARING_VIOLATION, NtStatus.STATUS_DELETE_PENDING]) def test_answer_being_written_or_deleted_is_none( self, fake: FakeSmbClient, share_root: Path, status: NtStatus ) -> None: """ИС ещё держит файл (монопольное открытие не проходит) — ответа пока нет, опросим позже.""" job_id = _job_id() _write_answer(share_root, job_id, b"{}") fake.fail["open_file"] = SMBOSError(status, OUTPUT) 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"})]