test_gcode_viewer.py 14 KB

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