test_gcode_viewer.py 3.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  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. """
  9. import pytest
  10. from httpx import AsyncClient
  11. class TestGCodeViewerRouteOrdering:
  12. """Verify the /gcode-viewer routes are reachable and distinct from the SPA."""
  13. @pytest.mark.asyncio
  14. @pytest.mark.integration
  15. async def test_gcode_viewer_index_does_not_fall_through_to_spa(
  16. self, async_client: AsyncClient
  17. ):
  18. """GET /gcode-viewer/ must not return the React SPA index.html.
  19. If route ordering is broken the SPA catch-all returns 200 with
  20. Content-Type: text/html and a <div id="root"> body. The correct
  21. response is either 200 (gcode_viewer/index.html present) or 404
  22. (directory absent in CI) — never the SPA shell.
  23. """
  24. response = await async_client.get("/gcode-viewer/")
  25. # 200 or 404 are both acceptable depending on whether gcode_viewer/
  26. # exists in the test environment; the SPA catch-all always returns 200.
  27. assert response.status_code in (200, 404)
  28. # If a body came back it must NOT be the React SPA shell.
  29. assert b'<div id="root">' not in response.content
  30. @pytest.mark.asyncio
  31. @pytest.mark.integration
  32. async def test_gcode_viewer_no_trailing_slash_redirects_or_responds(
  33. self, async_client: AsyncClient
  34. ):
  35. """GET /gcode-viewer (no trailing slash) is handled by the explicit route."""
  36. response = await async_client.get("/gcode-viewer", follow_redirects=True)
  37. assert response.status_code in (200, 404)
  38. assert b'<div id="root">' not in response.content
  39. class TestGCodeViewerPathTraversal:
  40. """Verify the path-traversal guard on /gcode-viewer/{file_path:path}.
  41. HTTP clients (and servers) normalise plain `..` segments before the
  42. request reaches a route handler, so `/gcode-viewer/../x` becomes `/x`
  43. and hits the SPA catch-all rather than our guard — that normalisation is
  44. itself a defence layer. The actual at-risk form is URL-encoded dots
  45. (`%2E%2E`) which survive normalisation and land in {file_path:path} as
  46. the literal string `../x`. We test that form here.
  47. """
  48. @pytest.mark.asyncio
  49. @pytest.mark.integration
  50. async def test_encoded_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
  51. """GET /gcode-viewer/%2E%2E/main.py must return 403.
  52. %2E%2E URL-decodes to .. which is not normalised away by httpx/
  53. Starlette, so it reaches _gcode_viewer_response as '../main.py'.
  54. Path.is_relative_to(gcode_viewer_dir) then blocks it with 403.
  55. """
  56. response = await async_client.get("/gcode-viewer/%2E%2E/main.py")
  57. assert response.status_code == 403
  58. @pytest.mark.asyncio
  59. @pytest.mark.integration
  60. async def test_encoded_nested_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
  61. """GET /gcode-viewer/js/%2E%2E/%2E%2E/main.py must return 403."""
  62. response = await async_client.get("/gcode-viewer/js/%2E%2E/%2E%2E/main.py")
  63. assert response.status_code == 403
  64. @pytest.mark.asyncio
  65. @pytest.mark.integration
  66. async def test_nonexistent_safe_path_returns_404(self, async_client: AsyncClient):
  67. """A safe but nonexistent path returns 404, not 403."""
  68. response = await async_client.get("/gcode-viewer/does-not-exist.js")
  69. assert response.status_code == 404