test_slice_external_folder_output.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. """Slicing a file on an external mount writes the result to that mount (#2810).
  2. Uploads learned to respect external folders in #1112 and moves in its
  3. follow-up; ``slice_and_persist`` was the last write path that still sent
  4. everything to managed storage. It kept giving the new row the external
  5. folder's ``folder_id``, so the sliced file appeared in the right folder in the
  6. File Manager while the share it was supposed to land on stayed empty -- which
  7. is why the bug could not be reproduced from the web UI at all.
  8. The fallback cases matter as much as the happy path. A slice costs minutes of
  9. CPU, so an unwritable mount must not throw the bytes away; it stores them in
  10. the managed library and *says so*, because filing the output somewhere the user
  11. is not looking with no signal is the failure this issue was made of.
  12. """
  13. import os
  14. from pathlib import Path
  15. from unittest.mock import AsyncMock, patch
  16. import pytest
  17. from backend.app.api.routes.library import (
  18. _resolve_slice_destination,
  19. _unique_external_name,
  20. slice_and_persist,
  21. )
  22. from backend.app.models.library import LibraryFile, LibraryFolder
  23. from backend.app.schemas.slicer import SliceRequest
  24. from backend.app.services.slicer_api import SliceResult
  25. def _external_folder(path: Path, *, readonly: bool = False) -> LibraryFolder:
  26. return LibraryFolder(
  27. name="NAS",
  28. parent_id=None,
  29. is_external=True,
  30. external_path=str(path),
  31. external_readonly=readonly,
  32. )
  33. class TestResolveSliceDestination:
  34. def test_managed_folder_keeps_the_uuid_name(self, tmp_path):
  35. folder = LibraryFolder(name="Models", parent_id=None, is_external=False)
  36. path, is_external, fallback = _resolve_slice_destination(folder, "Bidoof.gcode.3mf")
  37. assert is_external is False
  38. assert fallback is None
  39. # Managed storage is content-addressed by uuid: the display name lives
  40. # on the DB row, so two files of the same name can coexist.
  41. assert path.name.endswith(".gcode.3mf")
  42. assert path.name != "Bidoof.gcode.3mf"
  43. def test_no_folder_at_all_is_managed(self):
  44. path, is_external, fallback = _resolve_slice_destination(None, "Bidoof.gcode.3mf")
  45. assert is_external is False
  46. assert fallback is None
  47. assert path.name.endswith(".gcode.3mf")
  48. def test_writable_external_folder_gets_the_real_filename(self, tmp_path):
  49. mount = tmp_path / "share"
  50. mount.mkdir()
  51. path, is_external, fallback = _resolve_slice_destination(_external_folder(mount), "Bidoof.gcode.3mf")
  52. assert is_external is True
  53. assert fallback is None
  54. # The point of the whole fix: next to the source, under a name a human
  55. # can find on the share.
  56. assert path == mount / "Bidoof.gcode.3mf"
  57. def test_read_only_mount_falls_back_instead_of_failing(self, tmp_path):
  58. mount = tmp_path / "share"
  59. mount.mkdir()
  60. path, is_external, fallback = _resolve_slice_destination(
  61. _external_folder(mount, readonly=True), "Bidoof.gcode.3mf"
  62. )
  63. assert is_external is False
  64. assert fallback == "external_readonly"
  65. assert path.parent != mount
  66. def test_vanished_mount_falls_back(self, tmp_path):
  67. missing = tmp_path / "unplugged-nas" # deliberately not created
  68. _path, is_external, fallback = _resolve_slice_destination(_external_folder(missing), "Bidoof.gcode.3mf")
  69. assert is_external is False
  70. assert fallback == "external_unreachable"
  71. def test_folder_with_no_path_configured_falls_back(self):
  72. folder = LibraryFolder(name="NAS", parent_id=None, is_external=True, external_path=None)
  73. _path, is_external, fallback = _resolve_slice_destination(folder, "Bidoof.gcode.3mf")
  74. assert is_external is False
  75. assert fallback == "external_no_path"
  76. @pytest.mark.skipif(os.geteuid() == 0, reason="root ignores the write bit")
  77. def test_unwritable_mount_falls_back(self, tmp_path):
  78. mount = tmp_path / "share"
  79. mount.mkdir()
  80. mount.chmod(0o500)
  81. try:
  82. _path, is_external, fallback = _resolve_slice_destination(_external_folder(mount), "Bidoof.gcode.3mf")
  83. finally:
  84. mount.chmod(0o700)
  85. assert is_external is False
  86. assert fallback == "external_not_writable"
  87. def test_a_name_that_escapes_the_mount_lands_in_managed_storage(self, tmp_path):
  88. mount = tmp_path / "share"
  89. mount.mkdir()
  90. path, is_external, fallback = _resolve_slice_destination(_external_folder(mount), "../escaped.gcode.3mf")
  91. # Never write outside the configured mount, whatever the name claims.
  92. assert is_external is False
  93. assert fallback == "external_invalid_name"
  94. assert path.parent.resolve() != tmp_path.resolve()
  95. class TestUniqueExternalName:
  96. def test_free_name_is_used_as_is(self, tmp_path):
  97. assert _unique_external_name(tmp_path, "Bidoof.gcode.3mf") == "Bidoof.gcode.3mf"
  98. def test_collision_suffixes_before_the_compound_extension(self, tmp_path):
  99. (tmp_path / "Bidoof.gcode.3mf").write_bytes(b"first slice")
  100. # Not "Bidoof.gcode (2).3mf" -- the whole ".gcode.3mf" is the extension
  101. # and splitting it would produce a name the printer path won't accept.
  102. assert _unique_external_name(tmp_path, "Bidoof.gcode.3mf") == "Bidoof (2).gcode.3mf"
  103. def test_it_keeps_counting_past_the_first_collision(self, tmp_path):
  104. (tmp_path / "Bidoof.gcode.3mf").write_bytes(b"first")
  105. (tmp_path / "Bidoof (2).gcode.3mf").write_bytes(b"second")
  106. assert _unique_external_name(tmp_path, "Bidoof.gcode.3mf") == "Bidoof (3).gcode.3mf"
  107. def test_re_slicing_never_overwrites_what_is_already_on_the_share(self, tmp_path):
  108. (tmp_path / "Bidoof.gcode.3mf").write_bytes(b"do not lose me")
  109. chosen = _unique_external_name(tmp_path, "Bidoof.gcode.3mf")
  110. assert (tmp_path / chosen).exists() is False
  111. assert (tmp_path / "Bidoof.gcode.3mf").read_bytes() == b"do not lose me"
  112. class TestSliceAndPersistWritesToTheMount:
  113. """End to end through ``slice_and_persist`` with the slicer stubbed out."""
  114. @staticmethod
  115. def _patched_slicer(content: bytes = b"PK\x03\x04 not-a-real-3mf"):
  116. return patch(
  117. "backend.app.api.routes.library._run_slicer_with_fallback",
  118. AsyncMock(return_value=(SliceResult(content, 3600, 12.5, 4200.0), False)),
  119. )
  120. async def _slice_into(self, db_session, folder: LibraryFolder):
  121. db_session.add(folder)
  122. await db_session.commit()
  123. await db_session.refresh(folder)
  124. with self._patched_slicer():
  125. response = await slice_and_persist(
  126. db_session,
  127. model_bytes=b"source model",
  128. model_filename="Bidoof.3mf",
  129. folder_id=folder.id,
  130. extra_metadata=None,
  131. request=SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3),
  132. current_user_id=None,
  133. )
  134. file_row = await db_session.get(LibraryFile, response.library_file_id)
  135. return response, file_row
  136. @pytest.mark.asyncio
  137. async def test_the_bytes_land_on_the_share(self, db_session, tmp_path):
  138. mount = tmp_path / "share"
  139. mount.mkdir()
  140. response, file_row = await self._slice_into(db_session, _external_folder(mount))
  141. assert (mount / "Bidoof.gcode.3mf").exists()
  142. assert response.external_write_fallback is None
  143. # The row has to agree with the disk, or the next move/scan/delete
  144. # works on a path that isn't there.
  145. assert file_row.is_external is True
  146. assert file_row.file_path == str(mount / "Bidoof.gcode.3mf")
  147. assert file_row.filename == "Bidoof.gcode.3mf"
  148. @pytest.mark.asyncio
  149. async def test_the_row_records_the_suffixed_name_on_a_collision(self, db_session, tmp_path):
  150. mount = tmp_path / "share"
  151. mount.mkdir()
  152. (mount / "Bidoof.gcode.3mf").write_bytes(b"an earlier slice")
  153. _response, file_row = await self._slice_into(db_session, _external_folder(mount))
  154. assert file_row.filename == "Bidoof (2).gcode.3mf"
  155. assert file_row.file_path == str(mount / "Bidoof (2).gcode.3mf")
  156. assert (mount / "Bidoof.gcode.3mf").read_bytes() == b"an earlier slice"
  157. @pytest.mark.asyncio
  158. async def test_a_managed_folder_is_unaffected(self, db_session, tmp_path):
  159. folder = LibraryFolder(name="Models", parent_id=None, is_external=False)
  160. response, file_row = await self._slice_into(db_session, folder)
  161. assert response.external_write_fallback is None
  162. assert file_row.is_external is False
  163. # Managed rows stay relative to base_dir so the install stays portable.
  164. assert not Path(file_row.file_path).is_absolute()
  165. @pytest.mark.asyncio
  166. async def test_a_read_only_mount_still_yields_a_usable_file_and_says_why(self, db_session, tmp_path):
  167. mount = tmp_path / "share"
  168. mount.mkdir()
  169. response, file_row = await self._slice_into(db_session, _external_folder(mount, readonly=True))
  170. # Minutes of slicing must not be discarded because the mount is
  171. # read-only -- but the user has to learn where the file went.
  172. assert response.external_write_fallback == "external_readonly"
  173. assert file_row.is_external is False
  174. assert (file_row.file_metadata or {}).get("external_write_fallback") == "external_readonly"
  175. assert list(mount.iterdir()) == []