test_fallback_archive_recovery_2957.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. """A fallback archive is filled in when the 3MF finally turns up (#2957).
  2. The reporter's P1S started a print while Bambuddy was inside the five-minute
  3. FTPS cool-off armed by an earlier failed TLS handshake. The archive flow checks
  4. that cool-off at the top of its path loop and breaks before opening a single
  5. connection, so it gave up 13 ms after print start and wrote an empty fallback
  6. archive. Four minutes later the cool-off cleared and the cover endpoint
  7. downloaded the very same file -- all 8,956,942 bytes of it -- read a thumbnail
  8. out of it, and published it to the shared 3MF cache under the exact key the
  9. archive flow looks up.
  10. Nothing ever looked. Every ``get_cached_3mf`` caller runs before or during the
  11. print-start handler that had already given up, and ``on_print_complete`` drops
  12. the cache as its first statement, deleting the file. The archive stayed an empty
  13. shell for a print whose source Bambuddy had held, parsed and indexed.
  14. These tests pin the recovery: the row is filled in place (its id is load-bearing
  15. -- the energy reading, the timelapse session and the start notification were all
  16. written against it), it is only ever offered a readable 3MF, and it is left
  17. alone once it has a real file.
  18. """
  19. from __future__ import annotations
  20. import uuid
  21. import zipfile
  22. from pathlib import Path
  23. from unittest.mock import patch
  24. import pytest
  25. from sqlalchemy import select
  26. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  27. from backend.app.models.archive import PrintArchive
  28. from backend.app.models.printer import Printer
  29. pytestmark = pytest.mark.asyncio
  30. PRINT_NAME = "Desktop_Goose"
  31. DISPATCH_FILENAME = "Desktop_Goose.gcode.3mf"
  32. def _write_3mf(path: Path, print_name: str = PRINT_NAME) -> Path:
  33. """A 3MF the archive parser can read metadata out of."""
  34. path.parent.mkdir(parents=True, exist_ok=True)
  35. with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
  36. zf.writestr(
  37. "Metadata/slice_info.config",
  38. "<?xml version='1.0' encoding='UTF-8'?>"
  39. "<config><plate>"
  40. "<metadata key='index' value='1'/>"
  41. "<metadata key='prediction' value='3600'/>"
  42. "<metadata key='weight' value='42.5'/>"
  43. "<filament id='1' type='PLA' color='#00AE42' used_g='42.5' used_m='14.2'/>"
  44. "</plate></config>",
  45. )
  46. zf.writestr(
  47. "Metadata/model_settings.config",
  48. f"<config><plate><metadata key='name' value='{print_name}'/></plate></config>",
  49. )
  50. zf.writestr("3D/3dmodel.model", "<model/>")
  51. return path
  52. async def _seed(engine, tmp_path: Path) -> tuple[async_sessionmaker, int, int]:
  53. """A printer plus the empty fallback archive the cool-off produced."""
  54. maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  55. async with maker() as db:
  56. printer = Printer(
  57. name="P1S",
  58. serial_number="01P00A3B1200579",
  59. ip_address="172.25.12.149",
  60. access_code="12345678",
  61. model="P1S",
  62. )
  63. db.add(printer)
  64. await db.commit()
  65. await db.refresh(printer)
  66. archive = PrintArchive(
  67. printer_id=printer.id,
  68. filename=DISPATCH_FILENAME,
  69. file_path="", # the shell
  70. file_size=0,
  71. print_name=PRINT_NAME,
  72. status="printing",
  73. subtask_id="4242",
  74. extra_data={
  75. "no_3mf_available": True,
  76. "no_3mf_reason": "ftps_cooloff",
  77. "original_subtask": PRINT_NAME,
  78. "_print_data": {"filename": DISPATCH_FILENAME},
  79. },
  80. )
  81. db.add(archive)
  82. await db.commit()
  83. await db.refresh(archive)
  84. return maker, printer.id, archive.id
  85. class TestRecoveryFillsTheExistingRow:
  86. async def test_the_cover_endpoints_download_recovers_the_archive(self, test_engine, tmp_path):
  87. """The reporter's case, end to end from the download onwards."""
  88. from backend.app import main as main_module
  89. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  90. # The cover endpoint's own temp name, as it appears in the report:
  91. # /app/data/archive/temp/cover_1_Desktop_Goose.gcode.3mf
  92. source = _write_3mf(tmp_path / "temp" / f"cover_{printer_id}_{DISPATCH_FILENAME}")
  93. with (
  94. patch.object(main_module, "async_session", maker),
  95. patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
  96. ):
  97. recovered = await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source)
  98. assert recovered is True
  99. async with maker() as db:
  100. archive = await db.get(PrintArchive, archive_id)
  101. # Same row. A second archive would orphan the energy reading, the
  102. # timelapse session and the notification already sent against it.
  103. assert archive.id == archive_id
  104. assert archive.file_path
  105. assert archive.file_size == source.stat().st_size
  106. assert archive.subtask_id == "4242"
  107. assert archive.status == "printing"
  108. # No longer a fallback, so the Archives banner stops counting it.
  109. assert not archive.extra_data.get("no_3mf_available")
  110. assert archive.extra_data.get("recovered_no_3mf") is True
  111. # The start payload is diagnostic history and survives.
  112. assert archive.extra_data["_print_data"]["filename"] == DISPATCH_FILENAME
  113. # And exactly one archive, not the original shell plus a new one.
  114. async with maker() as db:
  115. rows = (await db.execute(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).scalars().all()
  116. assert [row.id for row in rows] == [archive_id]
  117. async def test_metadata_from_the_3mf_lands_on_the_row(self, test_engine, tmp_path):
  118. from backend.app import main as main_module
  119. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  120. source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
  121. with (
  122. patch.object(main_module, "async_session", maker),
  123. patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
  124. ):
  125. assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is True
  126. async with maker() as db:
  127. archive = await db.get(PrintArchive, archive_id)
  128. # The empty shell had none of these.
  129. assert archive.filament_used_grams == pytest.approx(42.5)
  130. assert archive.filament_type == "PLA"
  131. assert archive.print_time_seconds == 3600
  132. async def test_a_name_variant_still_finds_the_archive(self, test_engine, tmp_path):
  133. """The cover endpoint arrives with whichever spelling its own path built.
  134. `_active_prints` is keyed on the raw names seen at print start, so an
  135. exact-string lookup would miss "Desktop_Goose.gcode.3mf" against an
  136. archive registered under "Desktop_Goose".
  137. """
  138. from backend.app import main as main_module
  139. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  140. source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
  141. with (
  142. patch.object(main_module, "async_session", maker),
  143. patch.dict(main_module._active_prints, {(printer_id, PRINT_NAME): archive_id}, clear=True),
  144. ):
  145. assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is True
  146. class TestRecoveryRefusesTheWrongInput:
  147. async def test_a_truncated_download_is_refused(self, test_engine, tmp_path):
  148. """Half a file would replace an honest empty archive with wrong metadata."""
  149. from backend.app import main as main_module
  150. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  151. source = tmp_path / "temp" / DISPATCH_FILENAME
  152. source.parent.mkdir(parents=True, exist_ok=True)
  153. source.write_bytes(b"PK\x03\x04 truncated, not a readable zip")
  154. with (
  155. patch.object(main_module, "async_session", maker),
  156. patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
  157. ):
  158. assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is False
  159. async with maker() as db:
  160. assert (await db.get(PrintArchive, archive_id)).file_path == ""
  161. async def test_an_empty_file_is_refused(self, test_engine, tmp_path):
  162. from backend.app import main as main_module
  163. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  164. source = tmp_path / "temp" / DISPATCH_FILENAME
  165. source.parent.mkdir(parents=True, exist_ok=True)
  166. source.write_bytes(b"")
  167. with (
  168. patch.object(main_module, "async_session", maker),
  169. patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
  170. ):
  171. assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is False
  172. async def test_an_archive_that_already_has_a_3mf_is_left_alone(self, test_engine, tmp_path):
  173. """The normal case: every cover request during a healthy print hits this."""
  174. from backend.app import main as main_module
  175. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  176. async with maker() as db:
  177. archive = await db.get(PrintArchive, archive_id)
  178. archive.file_path = "archives/1/real/Desktop_Goose.gcode.3mf"
  179. archive.file_size = 8956942
  180. await db.commit()
  181. source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
  182. with (
  183. patch.object(main_module, "async_session", maker),
  184. patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
  185. ):
  186. assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is False
  187. async with maker() as db:
  188. archive = await db.get(PrintArchive, archive_id)
  189. assert archive.file_path == "archives/1/real/Desktop_Goose.gcode.3mf"
  190. assert archive.file_size == 8956942
  191. async def test_no_running_print_for_this_printer_is_a_no_op(self, test_engine, tmp_path):
  192. from backend.app import main as main_module
  193. maker, printer_id, _archive_id = await _seed(test_engine, tmp_path)
  194. source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
  195. with (
  196. patch.object(main_module, "async_session", maker),
  197. patch.dict(main_module._active_prints, {}, clear=True),
  198. ):
  199. assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is False
  200. async def test_a_deleted_archive_is_not_resurrected(self, test_engine, tmp_path):
  201. from datetime import datetime, timezone
  202. from backend.app import main as main_module
  203. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  204. async with maker() as db:
  205. archive = await db.get(PrintArchive, archive_id)
  206. archive.deleted_at = datetime.now(timezone.utc)
  207. await db.commit()
  208. source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
  209. with (
  210. patch.object(main_module, "async_session", maker),
  211. patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
  212. ):
  213. assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is False
  214. class TestTheGiveUpReasonIsRecorded:
  215. @pytest.mark.filterwarnings("ignore::pytest.PytestWarning")
  216. async def test_the_cooloff_slug_is_distinct_from_the_storage_verdicts(self):
  217. """The retry decision keys off it: a cool-off clears in minutes with the
  218. file still on the printer, while an eMMC job never appears at any FTPS
  219. path and retrying it is the sweep #2780 removed."""
  220. from backend.app.services.print_storage import (
  221. REASON_FTPS_COOLOFF,
  222. REASON_INTERNAL_STORAGE,
  223. REASON_NO_EXTERNAL_STORAGE,
  224. )
  225. assert REASON_FTPS_COOLOFF not in (REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE)
  226. async def test_the_banner_endpoint_does_not_leak_the_new_slug(self):
  227. """The two storage slugs are a UI contract; a cool-off is not one of them
  228. and must degrade to the generic banner rather than a missing string."""
  229. from backend.app.api.routes.archives import REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE
  230. from backend.app.services.print_storage import REASON_FTPS_COOLOFF
  231. assert REASON_FTPS_COOLOFF not in (REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE)
  232. class TestTheCooloffRetry:
  233. """The other half: nothing may ever download the file on its own."""
  234. async def test_the_retry_recovers_from_the_shared_cache(self, test_engine, tmp_path, monkeypatch):
  235. """The cover endpoint's copy is the same bytes, so the retry spends no
  236. FTP connection when the cache already holds it."""
  237. import asyncio
  238. from backend.app import main as main_module
  239. from backend.app.services import bambu_ftp
  240. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  241. source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
  242. monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (0.01,))
  243. bambu_ftp.cache_3mf_download(printer_id, DISPATCH_FILENAME, source)
  244. try:
  245. with patch.object(main_module, "async_session", maker):
  246. main_module._schedule_fallback_3mf_retry(
  247. printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
  248. )
  249. task = main_module._fallback_3mf_retry_tasks[printer_id]
  250. await asyncio.wait_for(task, timeout=5)
  251. finally:
  252. bambu_ftp.clear_3mf_cache(printer_id, delete_files=False)
  253. async with maker() as db:
  254. archive = await db.get(PrintArchive, archive_id)
  255. assert archive.file_path
  256. assert not archive.extra_data.get("no_3mf_available")
  257. async def test_the_retry_stops_once_the_archive_has_a_3mf(self, test_engine, tmp_path, monkeypatch):
  258. """Something else recovered it first — usually the cover endpoint."""
  259. import asyncio
  260. from backend.app import main as main_module
  261. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  262. async with maker() as db:
  263. archive = await db.get(PrintArchive, archive_id)
  264. archive.file_path = "archives/1/real/Desktop_Goose.gcode.3mf"
  265. await db.commit()
  266. monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (0.01, 0.01))
  267. downloads = []
  268. async def _never(*args, **kwargs):
  269. downloads.append(args)
  270. return False
  271. with (
  272. patch.object(main_module, "async_session", maker),
  273. patch.object(main_module, "download_file_try_paths_async", _never),
  274. ):
  275. main_module._schedule_fallback_3mf_retry(
  276. printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
  277. )
  278. await asyncio.wait_for(main_module._fallback_3mf_retry_tasks[printer_id], timeout=5)
  279. assert downloads == []
  280. async def test_a_second_schedule_replaces_the_first(self, test_engine, tmp_path, monkeypatch):
  281. """One printer prints one job at a time; two live retry tasks would race
  282. to write the same row."""
  283. import asyncio
  284. from backend.app import main as main_module
  285. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  286. monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (30.0,))
  287. with patch.object(main_module, "async_session", maker):
  288. main_module._schedule_fallback_3mf_retry(
  289. printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
  290. )
  291. first = main_module._fallback_3mf_retry_tasks[printer_id]
  292. main_module._schedule_fallback_3mf_retry(
  293. printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
  294. )
  295. second = main_module._fallback_3mf_retry_tasks[printer_id]
  296. assert first is not second
  297. await asyncio.sleep(0)
  298. assert first.cancelled() or first.done()
  299. second.cancel()
  300. with pytest.raises(asyncio.CancelledError):
  301. await second
  302. main_module._fallback_3mf_retry_tasks.pop(printer_id, None)
  303. async def test_the_retry_downloads_from_the_printer_when_the_cache_is_empty(
  304. self, test_engine, tmp_path, monkeypatch
  305. ):
  306. """Nothing else fetched the file, so the retry has to go and get it —
  307. the branch the reporter would have hit had they never opened the card."""
  308. import asyncio
  309. from backend.app import main as main_module
  310. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  311. monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (0.01,))
  312. # Left on the real archive dir: ArchiveService stores the destination
  313. # relative to settings.base_dir, so a temp path outside it cannot be
  314. # archived at all.
  315. asked: list[list[str]] = []
  316. async def _serve(ip, code, paths, dest, **kwargs):
  317. asked.append(list(paths))
  318. _write_3mf(Path(dest))
  319. return paths[0]
  320. with (
  321. patch.object(main_module, "async_session", maker),
  322. patch.object(main_module, "ftps_handshake_blocked", return_value=False),
  323. patch.object(main_module, "get_ftp_retry_settings", return_value=(True, 3, 2.0, 30.0)),
  324. patch.object(main_module, "download_file_try_paths_async", _serve),
  325. ):
  326. main_module._schedule_fallback_3mf_retry(
  327. printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
  328. )
  329. await asyncio.wait_for(main_module._fallback_3mf_retry_tasks[printer_id], timeout=5)
  330. assert asked, "the retry never asked the printer for the file"
  331. async with maker() as db:
  332. archive = await db.get(PrintArchive, archive_id)
  333. assert archive.file_path
  334. assert archive.id == archive_id
  335. async def test_a_printer_still_in_cool_off_is_not_contacted(self, test_engine, tmp_path, monkeypatch):
  336. """Retrying into a live cool-off is the failure that created the fallback."""
  337. import asyncio
  338. from backend.app import main as main_module
  339. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  340. monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (0.01,))
  341. downloads = []
  342. async def _never(*args, **kwargs):
  343. downloads.append(args)
  344. return False
  345. with (
  346. patch.object(main_module, "async_session", maker),
  347. patch.object(main_module, "ftps_handshake_blocked", return_value=True),
  348. patch.object(main_module, "download_file_try_paths_async", _never),
  349. ):
  350. main_module._schedule_fallback_3mf_retry(
  351. printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
  352. )
  353. await asyncio.wait_for(main_module._fallback_3mf_retry_tasks[printer_id], timeout=5)
  354. assert downloads == []
  355. class TestConcurrentRecoveryIsSerialised:
  356. async def test_two_racing_callers_produce_one_archive_directory(self, test_engine, tmp_path):
  357. """The cover endpoint coalesces by view, so two views race each other —
  358. and the cool-off retry can land on top of either. Unserialised, each
  359. caller reads file_path == "" and runs its own copy, leaving the row
  360. pointing at one timestamped directory with the others orphaned."""
  361. import asyncio
  362. from backend.app import main as main_module
  363. from backend.app.core.config import settings as app_config
  364. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  365. # A name unique to this run. `archive_print` builds its directory as
  366. # "<second-resolution timestamp>_<file stem>" with exist_ok=True, so a
  367. # shared stem collides with the directory another test in this file made
  368. # a moment ago, and the count below would measure that instead.
  369. unique = f"Racing_{uuid.uuid4().hex[:12]}.gcode.3mf"
  370. source = _write_3mf(tmp_path / "temp" / unique)
  371. printer_root = app_config.archive_dir / str(printer_id)
  372. before = set(printer_root.iterdir()) if printer_root.exists() else set()
  373. with (
  374. patch.object(main_module, "async_session", maker),
  375. patch.dict(main_module._active_prints, {(printer_id, unique): archive_id}, clear=True),
  376. ):
  377. results = await asyncio.gather(
  378. *(main_module.try_recover_fallback_archive(printer_id, unique, source) for _ in range(4))
  379. )
  380. # Exactly one caller did the work; the rest saw a recovered archive.
  381. assert results.count(True) == 1
  382. created = (set(printer_root.iterdir()) if printer_root.exists() else set()) - before
  383. assert len(created) == 1, f"expected one archive directory, got {sorted(p.name for p in created)}"
  384. async with maker() as db:
  385. rows = (await db.execute(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).scalars().all()
  386. assert [row.id for row in rows] == [archive_id]
  387. assert (app_config.base_dir / rows[0].file_path).is_file()
  388. class TestTheRetryWritesInsideTheDataVolume:
  389. async def test_a_path_shaped_name_cannot_escape_the_temp_directory(self, test_engine, tmp_path, monkeypatch):
  390. """MQTT hands `filename` over as a path on some firmware — the print-start
  391. log shows "/data/Metadata/plate_1.gcode". Joining that onto a directory
  392. with `/` yields the absolute path itself, so the temp write has to reduce
  393. every candidate to a bare name of its own accord."""
  394. import asyncio
  395. from backend.app import main as main_module
  396. from backend.app.core.config import settings as app_config
  397. maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
  398. monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (0.01,))
  399. temp_root = (app_config.archive_dir / "temp").resolve()
  400. written: list[Path] = []
  401. async def _record(ip, code, paths, dest, **kwargs):
  402. written.append(Path(dest))
  403. return None # a miss, so the loop walks every candidate
  404. with (
  405. patch.object(main_module, "async_session", maker),
  406. patch.object(main_module, "ftps_handshake_blocked", return_value=False),
  407. patch.object(main_module, "get_ftp_retry_settings", return_value=(True, 3, 2.0, 30.0)),
  408. patch.object(main_module, "download_file_try_paths_async", _record),
  409. ):
  410. main_module._schedule_fallback_3mf_retry(
  411. printer_id=printer_id,
  412. archive_id=archive_id,
  413. filenames=[
  414. "/data/Metadata/plate_1.gcode",
  415. "../../../../etc/passwd",
  416. "/etc/cron.d/evil.3mf",
  417. "..",
  418. ],
  419. )
  420. await asyncio.wait_for(main_module._fallback_3mf_retry_tasks[printer_id], timeout=5)
  421. assert written, "the retry never attempted a download"
  422. for dest in written:
  423. assert dest.resolve().parent == temp_root, f"{dest} escaped {temp_root}"
  424. class TestPhotosSurviveRecovery:
  425. """Recovery moves the archive's directory, because `archive_dir` derives it
  426. from `file_path` and that goes from empty to a real path. A photo uploaded
  427. to the empty card while the print ran is still where it was put."""
  428. async def test_a_photo_written_before_recovery_is_still_found_after(self, tmp_path, monkeypatch):
  429. from types import SimpleNamespace
  430. from backend.app.core.config import settings as app_config
  431. from backend.app.utils.archive_paths import find_archive_photo
  432. monkeypatch.setattr(app_config, "archive_dir", tmp_path / "archive")
  433. monkeypatch.setattr(app_config, "base_dir", tmp_path)
  434. archive = SimpleNamespace(id=83, file_path="")
  435. # Uploaded while the archive was still an empty fallback.
  436. before_dir = tmp_path / "archive" / "83" / "photos"
  437. before_dir.mkdir(parents=True)
  438. (before_dir / "snap.jpg").write_bytes(b"jpeg")
  439. assert find_archive_photo(archive, "snap.jpg") == before_dir / "snap.jpg"
  440. # The 3MF turns up and the row gains a file_path in a new directory.
  441. archive.file_path = "archive/1/20260825_000000_Desktop_Goose/Desktop_Goose.gcode.3mf"
  442. (tmp_path / "archive/1/20260825_000000_Desktop_Goose").mkdir(parents=True)
  443. assert find_archive_photo(archive, "snap.jpg") == before_dir / "snap.jpg"
  444. async def test_the_current_directory_still_wins(self, tmp_path, monkeypatch):
  445. from types import SimpleNamespace
  446. from backend.app.core.config import settings as app_config
  447. from backend.app.utils.archive_paths import find_archive_photo
  448. monkeypatch.setattr(app_config, "archive_dir", tmp_path / "archive")
  449. monkeypatch.setattr(app_config, "base_dir", tmp_path)
  450. archive = SimpleNamespace(id=83, file_path="archive/1/run/Desktop_Goose.gcode.3mf")
  451. current = tmp_path / "archive/1/run/photos"
  452. current.mkdir(parents=True)
  453. (current / "snap.jpg").write_bytes(b"new")
  454. stale = tmp_path / "archive" / "83" / "photos"
  455. stale.mkdir(parents=True)
  456. (stale / "snap.jpg").write_bytes(b"old")
  457. assert find_archive_photo(archive, "snap.jpg") == current / "snap.jpg"