arm-scan-gateway/api/tests/unit/test_local_hotfolder.py
Andrey Epifancev ad5cb7f0f0 Срез 0 [4.2] T7.4: общие проверки имени и job_id в hotfolder.py, запрет символов Windows/SMB
validate_name/validate_job_id вынесены из LocalHotFolder для повторного использования в SmbHotFolder.
Дополнительно отвергаются `: < > " | ? *` и управляющие символы 0x00-0x1F (в put, answer_path и inbox_name);
пробелы и кириллица в именах допустимы.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 02:53:48 +04:00

240 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton).
Быстрые тесты на `tmp_path`: атомарная запись входящего файла, опрос ответа ИС, проверка здоровья, валидация имён.
"""
import uuid
from pathlib import Path
import pytest
from src.gateway.hotfolder import HotFolder, LocalHotFolder, inbox_name
def _job_id() -> str:
return str(uuid.uuid4())
def _write_answer(hf: LocalHotFolder, job_id: str, data: bytes) -> Path:
path = hf.answer_path(job_id)
path.parent.mkdir(parents=True)
path.write_bytes(data)
return path
class TestInboxName:
def test_joins_job_id_and_name(self) -> None:
job_id = _job_id()
assert inbox_name(job_id, "aep_5.PDF") == f"{job_id}_aep_5.PDF"
@pytest.mark.parametrize(
("original", "expected"),
[
("../../etc/passwd", "passwd"),
("dir/sub/report.pdf", "report.pdf"),
("C:\\scans\\report.pdf", "report.pdf"),
("/abs/report.pdf", "report.pdf"),
("..\\..\\report.pdf", "report.pdf"),
],
)
def test_sanitizes_to_basename(self, original: str, expected: str) -> None:
assert inbox_name("job", original) == f"job_{expected}"
@pytest.mark.parametrize("original", ["", "/", "..", ".", "a/..", "dir/", "\\", "bad\x00name.pdf"])
def test_rejects_unusable_names(self, original: str) -> None:
with pytest.raises(ValueError, match="имя файла"):
inbox_name("job", original)
@pytest.mark.parametrize(
"original", ["a:b.pdf", "отчёт?.pdf", "a*b.pdf", 'a"b.pdf', "a|b.pdf", "a<b>.pdf", "a\x01b.pdf"]
)
def test_rejects_names_illegal_on_windows(self, original: str) -> None:
with pytest.raises(ValueError, match="имя файла"):
inbox_name("job", original)
def test_keeps_spaces_and_cyrillic(self) -> None:
job_id = _job_id()
assert inbox_name(job_id, "Отчёт за 2024 год (копия).PDF") == f"{job_id}_Отчёт за 2024 год (копия).PDF"
def test_result_is_accepted_by_put(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
name = inbox_name(_job_id(), "../../scan.pdf")
hf.put(name, b"x")
assert (hf.input_dir / name).read_bytes() == b"x"
class TestPaths:
def test_default_layout(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
job_id = _job_id()
assert hf.root == tmp_path
assert hf.input_dir == tmp_path / "Input"
assert hf.output_dir == tmp_path / "Output"
assert hf.answer_path(job_id) == tmp_path / "Output" / job_id / f"{job_id}.json"
def test_custom_dir_names_and_str_root(self, tmp_path: Path) -> None:
hf = LocalHotFolder(str(tmp_path), input_dir="Входящие", output_dir="Исходящие")
assert hf.root == tmp_path
assert hf.input_dir == tmp_path / "Входящие"
assert hf.output_dir == tmp_path / "Исходящие"
assert hf.answer_path("abc") == tmp_path / "Исходящие" / "abc" / "abc.json"
@pytest.mark.parametrize("job_id", ["", ".", "..", "../x", "a/b", "a\\b", "a:b", "a?b", "a*b", "a\x00b", "a\nb"])
def test_answer_path_rejects_unsafe_job_id(self, tmp_path: Path, job_id: str) -> None:
with pytest.raises(ValueError, match="job_id"):
LocalHotFolder(tmp_path).answer_path(job_id)
def test_implements_protocol(self, tmp_path: Path) -> None:
hf: HotFolder = LocalHotFolder(tmp_path)
assert hf.healthy() is True
class TestPut:
def test_writes_bytes_under_inbox_name(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
name = inbox_name(_job_id(), "aep_5.pdf")
data = bytes(range(256)) * 10
hf.put(name, data)
assert (tmp_path / "Input" / name).read_bytes() == data
def test_leaves_no_part_files(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
name = inbox_name(_job_id(), "aep_5.pdf")
hf.put(name, b"data")
assert [p.name for p in hf.input_dir.iterdir()] == [name]
def test_creates_input_dir_only(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
assert not hf.input_dir.exists()
hf.put("a.pdf", b"1")
assert hf.input_dir.is_dir()
assert not hf.output_dir.exists()
def test_existing_input_dir_is_fine(self, tmp_path: Path) -> None:
(tmp_path / "Input").mkdir()
LocalHotFolder(tmp_path).put("a.pdf", b"1")
assert (tmp_path / "Input" / "a.pdf").read_bytes() == b"1"
def test_missing_root_raises_and_creates_nothing(self, tmp_path: Path) -> None:
root = tmp_path / "absent"
with pytest.raises(FileNotFoundError):
LocalHotFolder(root).put("a.pdf", b"1")
assert not root.exists()
assert list(tmp_path.iterdir()) == []
def test_overwrites_existing_file(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
hf.put("a.pdf", b"old")
hf.put("a.pdf", b"new")
assert (hf.input_dir / "a.pdf").read_bytes() == b"new"
assert [p.name for p in hf.input_dir.iterdir()] == ["a.pdf"]
def test_empty_data_is_written(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
hf.put("empty.pdf", b"")
assert (hf.input_dir / "empty.pdf").read_bytes() == b""
@pytest.mark.parametrize(
"name",
["", "../escape.pdf", "sub/a.pdf", "sub\\a.pdf", "/abs.pdf", ".hidden.pdf", ".a.pdf.part", "..", ".", "a\x00b"],
)
def test_rejects_invalid_names(self, tmp_path: Path, name: str) -> None:
hf = LocalHotFolder(tmp_path)
with pytest.raises(ValueError, match="имя файла"):
hf.put(name, b"x")
assert not hf.input_dir.exists()
assert not (tmp_path.parent / "escape.pdf").exists()
@pytest.mark.parametrize("name", ["a:b.pdf", "a<b.pdf", "a>b.pdf", 'a"b.pdf', "a|b.pdf", "a?b.pdf", "a*b.pdf"])
def test_rejects_names_illegal_on_windows(self, tmp_path: Path, name: str) -> None:
hf = LocalHotFolder(tmp_path)
with pytest.raises(ValueError, match="имя файла"):
hf.put(name, b"x")
assert not hf.input_dir.exists()
@pytest.mark.parametrize("name", ["a\x01b.pdf", "a\tb.pdf", "a\nb.pdf", "a\x1fb.pdf"])
def test_rejects_control_chars(self, tmp_path: Path, name: str) -> None:
with pytest.raises(ValueError, match="имя файла"):
LocalHotFolder(tmp_path).put(name, b"x")
def test_accepts_spaces_cyrillic_and_inbox_names(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
names = [inbox_name(_job_id(), "Отчёт за 2024 год (копия).PDF"), inbox_name(_job_id(), "aep_8.pdf"), "a b.pdf"]
for name in names:
hf.put(name, b"x")
assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names)
def test_cleans_part_file_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
hf = LocalHotFolder(tmp_path)
def boom(src: object, dst: object) -> None:
raise OSError("диск недоступен")
monkeypatch.setattr("src.gateway.hotfolder.os.replace", boom)
with pytest.raises(OSError, match="диск недоступен"):
hf.put("a.pdf", b"data")
assert list(hf.input_dir.iterdir()) == []
class TestPoll:
def test_none_until_answer_exists(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
job_id = _job_id()
assert hf.poll(job_id) is None
(tmp_path / "Output").mkdir()
assert hf.poll(job_id) is None
(tmp_path / "Output" / job_id).mkdir()
assert hf.poll(job_id) is None
def test_returns_bytes_when_answer_appears(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
job_id = _job_id()
payload = b'{"file_uid": "x", "data_ocr": []}'
assert hf.poll(job_id) is None
_write_answer(hf, job_id, payload)
assert hf.poll(job_id) == payload
def test_other_job_is_unaffected(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
job_id, other = _job_id(), _job_id()
_write_answer(hf, other, b"{}")
assert hf.poll(job_id) is None
assert hf.poll(other) == b"{}"
def test_missing_root_is_none(self, tmp_path: Path) -> None:
assert LocalHotFolder(tmp_path / "absent").poll(_job_id()) is None
def test_file_removed_between_exists_and_read_is_none(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
hf = LocalHotFolder(tmp_path)
job_id = _job_id()
path = _write_answer(hf, job_id, b"{}")
def vanished(self: Path) -> bytes:
path.unlink()
raise FileNotFoundError(str(self))
monkeypatch.setattr(Path, "read_bytes", vanished)
assert hf.poll(job_id) is None
class TestHealthy:
def test_true_for_existing_dir(self, tmp_path: Path) -> None:
assert LocalHotFolder(tmp_path).healthy() is True
def test_false_for_missing_root(self, tmp_path: Path) -> None:
assert LocalHotFolder(tmp_path / "absent").healthy() is False
def test_false_for_file_root(self, tmp_path: Path) -> None:
file_root = tmp_path / "file"
file_root.write_bytes(b"x")
assert LocalHotFolder(file_root).healthy() is False
def test_never_raises(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
def boom(self: Path) -> bool:
raise PermissionError("нет доступа")
monkeypatch.setattr(Path, "is_dir", boom)
assert LocalHotFolder(tmp_path).healthy() is False