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:
parent
6b2087f57f
commit
5c2d89bef3
@ -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)
|
||||||
|
|||||||
@ -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("диск недоступен")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user