fix(hotfolder): пустой тест, неоспоримая публикация при сбое удаления .part, validate_name в cleanup (T11.1, T11.2, T11.4)

- T11.1: добавлен test_keeps_part_file_on_failure_when_cleanup_disabled (cleanup=False);
  переименован test_cleanup_failure_does_not_mask_original_error (cleanup=True).
- T11.2 (R-8): завершающий part.unlink после успешного link обёрнут в contextlib.suppress(OSError);
  файл уже доставлен, ошибка удаления .part её не отменяет.
  Добавлен test_successful_put_despite_final_unlink_failure: успешный link,
  затем PermissionError в unlink → put НЕ падает, целевой файл на месте.
- T11.4: cleanup вызывает validate_name(name) перед удалениями (защита от выхода за пределы).
  Добавлен test_rejects_unsafe_names в TestCleanup.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 01:09:29 +04:00
parent 6b2087f57f
commit 5c2d89bef3
2 changed files with 54 additions and 5 deletions

View File

@ -203,8 +203,9 @@ class LocalHotFolder:
def put(self, name: str, data: bytes) -> None: def put(self, name: str, data: bytes) -> None:
"""Атомарная запись без замены: `.<name>.part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/<name>` и """Атомарная запись без замены: `.<name>.part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/<name>` и
удаление `.part`. Назначение уже есть — файл считается доставленным (повтор задания), `.part` остаётся, если удаление `.part` как часть успешной публикации (R-8). Назначение уже есть — файл считается доставленным
очистка выключена. Корень не создаётся (нет корня — `FileNotFoundError`).""" (повтор задания), `.part` остаётся при ошибке линковки, если очистка выключена. Корень не создаётся
(нет корня — `FileNotFoundError`)."""
validate_name(name) validate_name(name)
self.tmp_dir.mkdir(exist_ok=True) self.tmp_dir.mkdir(exist_ok=True)
self.input_dir.mkdir(exist_ok=True) self.input_dir.mkdir(exist_ok=True)
@ -227,7 +228,10 @@ class LocalHotFolder:
except BaseException: except BaseException:
self._drop_part(part) self._drop_part(part)
raise raise
part.unlink(missing_ok=True) # завершение успешной записи, а не «очистка следов»: флаг не действует # завершение успешной записи (как rename в старой версии), не управляется флагом очистки:
# файл уже доставлен, ошибка удаления .part её не должна отменить (R-8)
with contextlib.suppress(OSError):
part.unlink(missing_ok=True)
def _drop_part(self, part: Path) -> None: def _drop_part(self, part: Path) -> None:
if self.cleanup_enabled: if self.cleanup_enabled:
@ -237,6 +241,7 @@ class LocalHotFolder:
def cleanup(self, job_id: str, name: str) -> bool: def cleanup(self, job_id: str, name: str) -> bool:
if not self.cleanup_enabled: if not self.cleanup_enabled:
return False return False
validate_name(name) # защита от выхода за пределы папки (T11.4)
folder = self.answer_dir(job_id) folder = self.answer_dir(job_id)
if folder.is_dir(): if folder.is_dir():
shutil.rmtree(folder) shutil.rmtree(folder)

View File

@ -122,6 +122,14 @@ class TestCleanup:
with pytest.raises(PermissionError): with pytest.raises(PermissionError):
hf.cleanup(job_id, f"{job_id}.pdf") hf.cleanup(job_id, f"{job_id}.pdf")
def test_rejects_unsafe_names(self, tmp_path: Path) -> None:
"""cleanup: validate_name защищает от выхода за пределы папки (T11.4)."""
hf = LocalHotFolder(tmp_path, cleanup=True)
with pytest.raises(ValueError, match="имя файла"):
hf.cleanup(_job_id(), "../x.pdf")
# ничего не удалено
assert not hf.input_dir.exists()
class TestPaths: class TestPaths:
def test_default_layout(self, tmp_path: Path) -> None: def test_default_layout(self, tmp_path: Path) -> None:
@ -276,6 +284,7 @@ class TestPut:
assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names) 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: def test_cleans_part_file_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""При cleanup=True и ошибке link: .part удаляется."""
hf = LocalHotFolder(tmp_path, cleanup=True) hf = LocalHotFolder(tmp_path, cleanup=True)
def boom(src: Any, dst: Any) -> None: def boom(src: Any, dst: Any) -> None:
@ -287,11 +296,46 @@ class TestPut:
assert list(hf.input_dir.iterdir()) == [] assert list(hf.input_dir.iterdir()) == []
assert list(hf.tmp_dir.iterdir()) == [] assert list(hf.tmp_dir.iterdir()) == []
def test_keeps_part_file_on_failure_when_cleanup_disabled(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""При cleanup=False и ошибке link: .part остаётся в Tmp (T11.1)."""
hf = LocalHotFolder(tmp_path, cleanup=False)
def boom(src: Any, dst: Any) -> None:
raise OSError("диск недоступен")
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
with pytest.raises(OSError, match="диск недоступен"):
hf.put("a.pdf", b"data")
assert list(hf.input_dir.iterdir()) == []
assert [p.name for p in hf.tmp_dir.iterdir()] == [".a.pdf.part"]
def test_successful_put_despite_final_unlink_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Успешный link, но ошибка в завершающем unlink .part (R-8): put НЕ падает, файл доставлен (T11.2)."""
hf = LocalHotFolder(tmp_path)
unlink_calls = []
real_unlink = os.unlink
def unlink_with_failure(path: Any) -> None:
unlink_calls.append(path)
if str(path).endswith(".part"):
raise PermissionError("удаление запрещено")
real_unlink(path)
monkeypatch.setattr("src.gateway.hotfolder.os.unlink", unlink_with_failure)
hf.put("a.pdf", b"data") # НЕ падает несмотря на ошибку unlink
assert (hf.input_dir / "a.pdf").read_bytes() == b"data"
assert (hf.tmp_dir / ".a.pdf.part").exists() # .part не удалён из-за ошибки
assert unlink_calls # попытка удаления была
def test_cleanup_failure_does_not_mask_original_error( def test_cleanup_failure_does_not_mask_original_error(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
"""Ошибка удаления `.part` подавляется, наружу выходит исходное исключение записи.""" """Ошибка удаления `.part` на пути ошибки link подавляется (T11.1); исходное исключение проходит."""
hf = LocalHotFolder(tmp_path) hf = LocalHotFolder(tmp_path, cleanup=True)
def boom(src: Any, dst: Any) -> None: def boom(src: Any, dst: Any) -> None:
raise OSError("диск недоступен") raise OSError("диск недоступен")