test_gcode_viewer.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. """Integration tests for the /gcode-viewer static-file routes.
  2. Covers two behaviours added by the GCode viewer PR:
  3. 1. Route ordering — /gcode-viewer/* is served by explicit @app.get routes
  4. that are registered before the /{full_path:path} SPA catch-all, so the
  5. GCode viewer is never accidentally served the React app HTML.
  6. 2. Path-traversal guard — requests for paths that escape gcode_viewer/
  7. (e.g. /gcode-viewer/../main.py) must return 403, not the file contents.
  8. Plus tests for the archive G-code endpoint behaviour the viewer depends on:
  9. ``?plate=N`` resolution including zero-padded filenames, and the ``has_gcode``
  10. flag on the plates endpoint that gates the frontend plate picker.
  11. """
  12. import zipfile
  13. from pathlib import Path
  14. import pytest
  15. from httpx import AsyncClient
  16. class TestGCodeViewerRouteOrdering:
  17. """Verify the /gcode-viewer routes are reachable and distinct from the SPA."""
  18. @pytest.mark.asyncio
  19. @pytest.mark.integration
  20. async def test_gcode_viewer_index_does_not_fall_through_to_spa(self, async_client: AsyncClient):
  21. """GET /gcode-viewer/ must not return the React SPA index.html.
  22. If route ordering is broken the SPA catch-all returns 200 with
  23. Content-Type: text/html and a <div id="root"> body. The correct
  24. response is either 200 (gcode_viewer/index.html present) or 404
  25. (directory absent in CI) — never the SPA shell.
  26. """
  27. response = await async_client.get("/gcode-viewer/")
  28. # 200 or 404 are both acceptable depending on whether gcode_viewer/
  29. # exists in the test environment; the SPA catch-all always returns 200.
  30. assert response.status_code in (200, 404)
  31. # If a body came back it must NOT be the React SPA shell.
  32. assert b'<div id="root">' not in response.content
  33. @pytest.mark.asyncio
  34. @pytest.mark.integration
  35. async def test_gcode_viewer_no_trailing_slash_falls_through_to_spa(self, async_client: AsyncClient):
  36. """GET /gcode-viewer (no trailing slash) must fall through to the SPA.
  37. Only /gcode-viewer/ (trailing slash) should serve the raw viewer — that
  38. form is what the iframe in GCodeViewerPage requests. The bare path is
  39. the SPA route the user navigates to; reloading it must re-enter the
  40. React layout rather than serve the iframe contents standalone.
  41. """
  42. response = await async_client.get("/gcode-viewer", follow_redirects=False)
  43. # SPA catch-all serves 200 with the React index.html (which contains
  44. # <div id="root">). If the build output isn't present the catch-all
  45. # may 404 — both outcomes are acceptable here; the key invariant is
  46. # that we do NOT serve the standalone PrettyGCode index.html (which
  47. # starts with <!doctype html> and contains "PrettyGCode").
  48. assert response.status_code in (200, 404)
  49. if response.status_code == 200:
  50. assert b"PrettyGCode" not in response.content
  51. class TestGCodeViewerPathTraversal:
  52. """Verify the path-traversal guard on /gcode-viewer/{file_path:path}.
  53. HTTP clients (and servers) normalise plain `..` segments before the
  54. request reaches a route handler, so `/gcode-viewer/../x` becomes `/x`
  55. and hits the SPA catch-all rather than our guard — that normalisation is
  56. itself a defence layer. The actual at-risk form is URL-encoded dots
  57. (`%2E%2E`) which survive normalisation and land in {file_path:path} as
  58. the literal string `../x`. We test that form here.
  59. """
  60. @pytest.mark.asyncio
  61. @pytest.mark.integration
  62. async def test_encoded_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
  63. """GET /gcode-viewer/%2E%2E/main.py must return 403.
  64. %2E%2E URL-decodes to .. which is not normalised away by httpx/
  65. Starlette, so it reaches _gcode_viewer_response as '../main.py'.
  66. Path.is_relative_to(gcode_viewer_dir) then blocks it with 403.
  67. """
  68. response = await async_client.get("/gcode-viewer/%2E%2E/main.py")
  69. assert response.status_code == 403
  70. @pytest.mark.asyncio
  71. @pytest.mark.integration
  72. async def test_encoded_nested_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
  73. """GET /gcode-viewer/js/%2E%2E/%2E%2E/main.py must return 403."""
  74. response = await async_client.get("/gcode-viewer/js/%2E%2E/%2E%2E/main.py")
  75. assert response.status_code == 403
  76. @pytest.mark.asyncio
  77. @pytest.mark.integration
  78. async def test_nonexistent_safe_path_returns_404(self, async_client: AsyncClient):
  79. """A safe but nonexistent path returns 404, not 403."""
  80. response = await async_client.get("/gcode-viewer/does-not-exist.js")
  81. assert response.status_code == 404
  82. def _write_3mf(
  83. path: Path,
  84. plate_gcode: dict[int, str] | None = None,
  85. plate_filenames: dict[int, str] | None = None,
  86. include_png_for: list[int] | None = None,
  87. ) -> None:
  88. """Write a synthetic Bambu-style 3MF zip at *path*.
  89. Parameters let a single test pin one specific shape:
  90. - ``plate_gcode`` — {plate_index: gcode_text} written at
  91. ``Metadata/plate_{index}.gcode``. Use for the normal (sliced) case.
  92. - ``plate_filenames`` — {plate_index: custom_filename} written with the
  93. raw filename verbatim. Use for zero-padded names (plate_01.gcode) etc.
  94. - ``include_png_for`` — plate indices to add PNG stubs for. Use to
  95. simulate source-only archives (PNG/JSON present, no .gcode).
  96. Leaving all three empty produces an archive that the plates endpoint
  97. will parse as empty (no plates).
  98. """
  99. plate_gcode = plate_gcode or {}
  100. plate_filenames = plate_filenames or {}
  101. include_png_for = include_png_for or []
  102. with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
  103. for idx, text in plate_gcode.items():
  104. zf.writestr(f"Metadata/plate_{idx}.gcode", text)
  105. for idx, filename in plate_filenames.items():
  106. zf.writestr(f"Metadata/{filename}", f"; stub for plate {idx}\n")
  107. for idx in include_png_for:
  108. zf.writestr(f"Metadata/plate_{idx}.png", b"\x89PNG\r\n\x1a\n")
  109. zf.writestr(f"Metadata/plate_{idx}.json", b'{"bbox_objects": []}')
  110. @pytest.fixture
  111. def _patch_archive_base_dir(monkeypatch, tmp_path):
  112. """Point archive file_path resolution at *tmp_path* for this test."""
  113. from backend.app.core.config import settings
  114. monkeypatch.setattr(settings, "base_dir", tmp_path)
  115. return tmp_path
  116. class TestArchiveGcodePlateParam:
  117. """The viewer passes ``?plate=N`` for multi-plate archives."""
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_plate_param_returns_that_plate(
  121. self,
  122. async_client: AsyncClient,
  123. archive_factory,
  124. printer_factory,
  125. _patch_archive_base_dir,
  126. ):
  127. """GET /archives/{id}/gcode?plate=2 returns Metadata/plate_2.gcode."""
  128. tmp = _patch_archive_base_dir
  129. threemf = tmp / "multi.3mf"
  130. _write_3mf(
  131. threemf,
  132. plate_gcode={1: "G0 ; plate 1\n", 2: "G1 X0 Y0 ; plate 2\n"},
  133. )
  134. printer = await printer_factory()
  135. archive = await archive_factory(printer.id, filename="multi.3mf", file_path="multi.3mf")
  136. response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=2")
  137. assert response.status_code == 200
  138. assert "plate 2" in response.text
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_plate_param_zero_padded_filename_resolves(
  142. self,
  143. async_client: AsyncClient,
  144. archive_factory,
  145. printer_factory,
  146. _patch_archive_base_dir,
  147. ):
  148. """plate_01.gcode reports as plate 1 from /plates — /gcode?plate=1 must find it.
  149. Regression: the original exact-string match on ``Metadata/plate_1.gcode``
  150. missed zero-padded filenames exported by some slicers, so the picker
  151. showed plate 1 as selectable but the viewer 404'd on selection.
  152. """
  153. tmp = _patch_archive_base_dir
  154. threemf = tmp / "padded.3mf"
  155. with zipfile.ZipFile(threemf, "w", zipfile.ZIP_DEFLATED) as zf:
  156. zf.writestr("Metadata/plate_01.gcode", "G0 ; padded plate\n")
  157. printer = await printer_factory()
  158. archive = await archive_factory(printer.id, filename="padded.3mf", file_path="padded.3mf")
  159. response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
  160. assert response.status_code == 200
  161. assert "padded plate" in response.text
  162. @pytest.mark.asyncio
  163. @pytest.mark.integration
  164. async def test_missing_plate_returns_404(
  165. self,
  166. async_client: AsyncClient,
  167. archive_factory,
  168. printer_factory,
  169. _patch_archive_base_dir,
  170. ):
  171. """Requesting a plate index the archive doesn't contain returns 404."""
  172. tmp = _patch_archive_base_dir
  173. threemf = tmp / "only_plate_2.3mf"
  174. _write_3mf(threemf, plate_gcode={2: "G0\n"})
  175. printer = await printer_factory()
  176. archive = await archive_factory(printer.id, filename="only_plate_2.3mf", file_path="only_plate_2.3mf")
  177. response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
  178. assert response.status_code == 404
  179. @pytest.mark.asyncio
  180. @pytest.mark.integration
  181. async def test_no_plate_param_returns_first_plate(
  182. self,
  183. async_client: AsyncClient,
  184. archive_factory,
  185. printer_factory,
  186. _patch_archive_base_dir,
  187. ):
  188. """Omitting ?plate falls back to the first gcode in the archive.
  189. Preserves the pre-plate-param behaviour — existing callers that don't
  190. know about plates still get something sensible back.
  191. """
  192. tmp = _patch_archive_base_dir
  193. threemf = tmp / "single.3mf"
  194. _write_3mf(threemf, plate_gcode={1: "G0 ; only plate\n"})
  195. printer = await printer_factory()
  196. archive = await archive_factory(printer.id, filename="single.3mf", file_path="single.3mf")
  197. response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode")
  198. assert response.status_code == 200
  199. assert "only plate" in response.text
  200. @pytest.mark.asyncio
  201. @pytest.mark.integration
  202. async def test_plate_param_rejects_zero_and_negative(
  203. self,
  204. async_client: AsyncClient,
  205. archive_factory,
  206. printer_factory,
  207. _patch_archive_base_dir,
  208. ):
  209. """``?plate=0`` or negative must 400 — not silently fall through."""
  210. tmp = _patch_archive_base_dir
  211. threemf = tmp / "any.3mf"
  212. _write_3mf(threemf, plate_gcode={1: "G0\n"})
  213. printer = await printer_factory()
  214. archive = await archive_factory(printer.id, filename="any.3mf", file_path="any.3mf")
  215. response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=0")
  216. assert response.status_code == 400
  217. class TestArchivePlatesHasGcode:
  218. """The ``has_gcode`` flag on /plates gates the frontend plate picker."""
  219. @pytest.mark.asyncio
  220. @pytest.mark.integration
  221. async def test_has_gcode_true_when_gcode_files_present(
  222. self,
  223. async_client: AsyncClient,
  224. archive_factory,
  225. printer_factory,
  226. _patch_archive_base_dir,
  227. ):
  228. """Sliced multi-plate 3MF → has_gcode=true."""
  229. tmp = _patch_archive_base_dir
  230. threemf = tmp / "sliced.3mf"
  231. _write_3mf(threemf, plate_gcode={1: "G0\n", 2: "G1\n"})
  232. printer = await printer_factory()
  233. archive = await archive_factory(printer.id, filename="sliced.3mf", file_path="sliced.3mf")
  234. response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
  235. assert response.status_code == 200
  236. data = response.json()
  237. assert data["has_gcode"] is True
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. async def test_has_gcode_false_for_source_only_archive(
  241. self,
  242. async_client: AsyncClient,
  243. archive_factory,
  244. printer_factory,
  245. _patch_archive_base_dir,
  246. ):
  247. """Source-only 3MF (PNG/JSON only, no gcode) → has_gcode=false.
  248. Regression for the archive-69 bug: the PNG/JSON fallback path made the
  249. plates endpoint report plate indices that the gcode endpoint couldn't
  250. actually serve, so every viewer preview 404'd. The frontend now uses
  251. has_gcode to suppress the picker + show a toast instead.
  252. """
  253. tmp = _patch_archive_base_dir
  254. threemf = tmp / "project.3mf"
  255. _write_3mf(threemf, include_png_for=[1, 2, 3]) # no .gcode at all
  256. printer = await printer_factory()
  257. archive = await archive_factory(printer.id, filename="project.3mf", file_path="project.3mf")
  258. response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
  259. assert response.status_code == 200
  260. data = response.json()
  261. assert data["has_gcode"] is False
  262. # The endpoint still reports plates (from JSON/PNG) — the flag is what
  263. # the frontend keys on, not an empty plate list.
  264. assert len(data["plates"]) == 3