| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346 |
- """Integration tests for the /gcode-viewer static-file routes.
- Covers two behaviours added by the GCode viewer PR:
- 1. Route ordering — /gcode-viewer/* is served by explicit @app.get routes
- that are registered before the /{full_path:path} SPA catch-all, so the
- GCode viewer is never accidentally served the React app HTML.
- 2. Path-traversal guard — requests for paths that escape gcode_viewer/
- (e.g. /gcode-viewer/../main.py) must return 403, not the file contents.
- Plus tests for the archive G-code endpoint behaviour the viewer depends on:
- ``?plate=N`` resolution including zero-padded filenames, and the ``has_gcode``
- flag on the plates endpoint that gates the frontend plate picker.
- """
- import zipfile
- from pathlib import Path
- import pytest
- from httpx import AsyncClient
- class TestGCodeViewerRouteOrdering:
- """Verify the /gcode-viewer routes are reachable and distinct from the SPA."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_gcode_viewer_index_does_not_fall_through_to_spa(self, async_client: AsyncClient):
- """GET /gcode-viewer/ must not return the React SPA index.html.
- If route ordering is broken the SPA catch-all returns 200 with
- Content-Type: text/html and a <div id="root"> body. The correct
- response is either 200 (gcode_viewer/index.html present) or 404
- (directory absent in CI) — never the SPA shell.
- """
- response = await async_client.get("/gcode-viewer/")
- # 200 or 404 are both acceptable depending on whether gcode_viewer/
- # exists in the test environment; the SPA catch-all always returns 200.
- assert response.status_code in (200, 404)
- # If a body came back it must NOT be the React SPA shell.
- assert b'<div id="root">' not in response.content
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_gcode_viewer_index_served_when_assets_present(self, async_client: AsyncClient):
- """GET /gcode-viewer/ must return the PrettyGCode index when the
- vendored assets are on disk.
- Regression guard for #1218: the production Dockerfile has to copy
- ``gcode_viewer/`` into the image alongside ``static/``. The previous
- ``test_gcode_viewer_index_does_not_fall_through_to_spa`` accepted
- 404 unconditionally so a missing COPY never failed CI. This test
- only runs when the directory is actually present (so it stays a
- no-op in unit-test environments where the assets are intentionally
- absent), but when it does run it asserts 200 + a non-empty HTML
- body so a future packaging regression fails loudly.
- """
- from backend.app.main import _gcode_viewer_dir
- index = _gcode_viewer_dir / "index.html"
- if not index.is_file():
- pytest.skip(f"gcode_viewer/index.html not present at {index} — skipping packaging assertion")
- response = await async_client.get("/gcode-viewer/")
- assert response.status_code == 200, (
- f"gcode_viewer/index.html exists at {index} but /gcode-viewer/ returned "
- f"{response.status_code} — route or response wiring is broken."
- )
- assert b"PrettyGCode" in response.content or b"<!doctype html>" in response.content.lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_gcode_viewer_no_trailing_slash_falls_through_to_spa(self, async_client: AsyncClient):
- """GET /gcode-viewer (no trailing slash) must fall through to the SPA.
- Only /gcode-viewer/ (trailing slash) should serve the raw viewer — that
- form is what the iframe in GCodeViewerPage requests. The bare path is
- the SPA route the user navigates to; reloading it must re-enter the
- React layout rather than serve the iframe contents standalone.
- """
- response = await async_client.get("/gcode-viewer", follow_redirects=False)
- # SPA catch-all serves 200 with the React index.html (which contains
- # <div id="root">). If the build output isn't present the catch-all
- # may 404 — both outcomes are acceptable here; the key invariant is
- # that we do NOT serve the standalone PrettyGCode index.html (which
- # starts with <!doctype html> and contains "PrettyGCode").
- assert response.status_code in (200, 404)
- if response.status_code == 200:
- assert b"PrettyGCode" not in response.content
- class TestGCodeViewerPathTraversal:
- """Verify the path-traversal guard on /gcode-viewer/{file_path:path}.
- HTTP clients (and servers) normalise plain `..` segments before the
- request reaches a route handler, so `/gcode-viewer/../x` becomes `/x`
- and hits the SPA catch-all rather than our guard — that normalisation is
- itself a defence layer. The actual at-risk form is URL-encoded dots
- (`%2E%2E`) which survive normalisation and land in {file_path:path} as
- the literal string `../x`. We test that form here.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_encoded_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
- """GET /gcode-viewer/%2E%2E/main.py must return 403.
- %2E%2E URL-decodes to .. which is not normalised away by httpx/
- Starlette, so it reaches _gcode_viewer_response as '../main.py'.
- Path.is_relative_to(gcode_viewer_dir) then blocks it with 403.
- """
- response = await async_client.get("/gcode-viewer/%2E%2E/main.py")
- assert response.status_code == 403
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_encoded_nested_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
- """GET /gcode-viewer/js/%2E%2E/%2E%2E/main.py must return 403."""
- response = await async_client.get("/gcode-viewer/js/%2E%2E/%2E%2E/main.py")
- assert response.status_code == 403
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_nonexistent_safe_path_returns_404(self, async_client: AsyncClient):
- """A safe but nonexistent path returns 404, not 403."""
- response = await async_client.get("/gcode-viewer/does-not-exist.js")
- assert response.status_code == 404
- def _write_3mf(
- path: Path,
- plate_gcode: dict[int, str] | None = None,
- plate_filenames: dict[int, str] | None = None,
- include_png_for: list[int] | None = None,
- ) -> None:
- """Write a synthetic Bambu-style 3MF zip at *path*.
- Parameters let a single test pin one specific shape:
- - ``plate_gcode`` — {plate_index: gcode_text} written at
- ``Metadata/plate_{index}.gcode``. Use for the normal (sliced) case.
- - ``plate_filenames`` — {plate_index: custom_filename} written with the
- raw filename verbatim. Use for zero-padded names (plate_01.gcode) etc.
- - ``include_png_for`` — plate indices to add PNG stubs for. Use to
- simulate source-only archives (PNG/JSON present, no .gcode).
- Leaving all three empty produces an archive that the plates endpoint
- will parse as empty (no plates).
- """
- plate_gcode = plate_gcode or {}
- plate_filenames = plate_filenames or {}
- include_png_for = include_png_for or []
- with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
- for idx, text in plate_gcode.items():
- zf.writestr(f"Metadata/plate_{idx}.gcode", text)
- for idx, filename in plate_filenames.items():
- zf.writestr(f"Metadata/{filename}", f"; stub for plate {idx}\n")
- for idx in include_png_for:
- zf.writestr(f"Metadata/plate_{idx}.png", b"\x89PNG\r\n\x1a\n")
- zf.writestr(f"Metadata/plate_{idx}.json", b'{"bbox_objects": []}')
- @pytest.fixture
- def _patch_archive_base_dir(monkeypatch, tmp_path):
- """Point archive file_path resolution at *tmp_path* for this test."""
- from backend.app.core.config import settings
- monkeypatch.setattr(settings, "base_dir", tmp_path)
- return tmp_path
- class TestArchiveGcodePlateParam:
- """The viewer passes ``?plate=N`` for multi-plate archives."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_plate_param_returns_that_plate(
- self,
- async_client: AsyncClient,
- archive_factory,
- printer_factory,
- _patch_archive_base_dir,
- ):
- """GET /archives/{id}/gcode?plate=2 returns Metadata/plate_2.gcode."""
- tmp = _patch_archive_base_dir
- threemf = tmp / "multi.3mf"
- _write_3mf(
- threemf,
- plate_gcode={1: "G0 ; plate 1\n", 2: "G1 X0 Y0 ; plate 2\n"},
- )
- printer = await printer_factory()
- archive = await archive_factory(printer.id, filename="multi.3mf", file_path="multi.3mf")
- response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=2")
- assert response.status_code == 200
- assert "plate 2" in response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_plate_param_zero_padded_filename_resolves(
- self,
- async_client: AsyncClient,
- archive_factory,
- printer_factory,
- _patch_archive_base_dir,
- ):
- """plate_01.gcode reports as plate 1 from /plates — /gcode?plate=1 must find it.
- Regression: the original exact-string match on ``Metadata/plate_1.gcode``
- missed zero-padded filenames exported by some slicers, so the picker
- showed plate 1 as selectable but the viewer 404'd on selection.
- """
- tmp = _patch_archive_base_dir
- threemf = tmp / "padded.3mf"
- with zipfile.ZipFile(threemf, "w", zipfile.ZIP_DEFLATED) as zf:
- zf.writestr("Metadata/plate_01.gcode", "G0 ; padded plate\n")
- printer = await printer_factory()
- archive = await archive_factory(printer.id, filename="padded.3mf", file_path="padded.3mf")
- response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
- assert response.status_code == 200
- assert "padded plate" in response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_missing_plate_returns_404(
- self,
- async_client: AsyncClient,
- archive_factory,
- printer_factory,
- _patch_archive_base_dir,
- ):
- """Requesting a plate index the archive doesn't contain returns 404."""
- tmp = _patch_archive_base_dir
- threemf = tmp / "only_plate_2.3mf"
- _write_3mf(threemf, plate_gcode={2: "G0\n"})
- printer = await printer_factory()
- archive = await archive_factory(printer.id, filename="only_plate_2.3mf", file_path="only_plate_2.3mf")
- response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
- assert response.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_no_plate_param_returns_first_plate(
- self,
- async_client: AsyncClient,
- archive_factory,
- printer_factory,
- _patch_archive_base_dir,
- ):
- """Omitting ?plate falls back to the first gcode in the archive.
- Preserves the pre-plate-param behaviour — existing callers that don't
- know about plates still get something sensible back.
- """
- tmp = _patch_archive_base_dir
- threemf = tmp / "single.3mf"
- _write_3mf(threemf, plate_gcode={1: "G0 ; only plate\n"})
- printer = await printer_factory()
- archive = await archive_factory(printer.id, filename="single.3mf", file_path="single.3mf")
- response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode")
- assert response.status_code == 200
- assert "only plate" in response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_plate_param_rejects_zero_and_negative(
- self,
- async_client: AsyncClient,
- archive_factory,
- printer_factory,
- _patch_archive_base_dir,
- ):
- """``?plate=0`` or negative must 400 — not silently fall through."""
- tmp = _patch_archive_base_dir
- threemf = tmp / "any.3mf"
- _write_3mf(threemf, plate_gcode={1: "G0\n"})
- printer = await printer_factory()
- archive = await archive_factory(printer.id, filename="any.3mf", file_path="any.3mf")
- response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=0")
- assert response.status_code == 400
- class TestArchivePlatesHasGcode:
- """The ``has_gcode`` flag on /plates gates the frontend plate picker."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_has_gcode_true_when_gcode_files_present(
- self,
- async_client: AsyncClient,
- archive_factory,
- printer_factory,
- _patch_archive_base_dir,
- ):
- """Sliced multi-plate 3MF → has_gcode=true."""
- tmp = _patch_archive_base_dir
- threemf = tmp / "sliced.3mf"
- _write_3mf(threemf, plate_gcode={1: "G0\n", 2: "G1\n"})
- printer = await printer_factory()
- archive = await archive_factory(printer.id, filename="sliced.3mf", file_path="sliced.3mf")
- response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
- assert response.status_code == 200
- data = response.json()
- assert data["has_gcode"] is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_has_gcode_false_for_source_only_archive(
- self,
- async_client: AsyncClient,
- archive_factory,
- printer_factory,
- _patch_archive_base_dir,
- ):
- """Source-only 3MF (PNG/JSON only, no gcode) → has_gcode=false.
- Regression for the archive-69 bug: the PNG/JSON fallback path made the
- plates endpoint report plate indices that the gcode endpoint couldn't
- actually serve, so every viewer preview 404'd. The frontend now uses
- has_gcode to suppress the picker + show a toast instead.
- """
- tmp = _patch_archive_base_dir
- threemf = tmp / "project.3mf"
- _write_3mf(threemf, include_png_for=[1, 2, 3]) # no .gcode at all
- printer = await printer_factory()
- archive = await archive_factory(printer.id, filename="project.3mf", file_path="project.3mf")
- response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
- assert response.status_code == 200
- data = response.json()
- assert data["has_gcode"] is False
- # The endpoint still reports plates (from JSON/PNG) — the flag is what
- # the frontend keys on, not an empty plate list.
- assert len(data["plates"]) == 3
|