test_slicer_token_reuse_3029.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. """Integration tests for reusable slicer download tokens (#3029).
  2. The "Slice" action hands a URL to a *separate process* -- Bambu Studio or
  3. OrcaSlicer, launched through a protocol handler that cannot carry an
  4. ``Authorization`` header. Until this fix the token in that URL was consumed by
  5. the first request that reached the endpoint, which made the handoff dependent
  6. on the slicer fetching the URL exactly once. Nothing guarantees that: Bambu
  7. Studio's downloader retries three times after a failed attempt, transfers get
  8. resumed, on-access scanners fetch. Whichever party arrived first won, and the
  9. slicer was handed a 403.
  10. So the three protocol-handler downloads now accept their token for the rest of
  11. its five-minute TTL. Everything else about the token is unchanged, and these
  12. tests pin the difference in both directions: the second fetch works, and the
  13. token is still refused for the wrong resource, after expiry, and when unknown.
  14. The two *browser* downloads that share the same primitive stay one-shot, and
  15. are pinned here too -- the prepared printer bundle is deleted once streamed, so
  16. reuse there could only ever mean a 404 with a misleading cause.
  17. The second half covers a fault found while checking the first: the auth
  18. middleware matches ``PUBLIC_API_PATTERNS`` by substring, and the source-3MF
  19. route's segment is ``source-dl`` -- which does not contain ``/dl/``. With auth
  20. enabled the middleware rejected the slicer's header-less request before the
  21. route's own token check ever ran.
  22. """
  23. from __future__ import annotations
  24. import os
  25. import shutil
  26. from datetime import datetime, timedelta, timezone
  27. from pathlib import Path
  28. import pytest
  29. from httpx import AsyncClient
  30. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  31. # Same reasoning as #3025's fixtures: the routes resolve paths relative to
  32. # ``settings.base_dir``, which under test is the project root, so everything
  33. # goes in one subdirectory that is removed after each test. Per process for the
  34. # same reason too: every xdist worker shares ``base_dir``, and with one shared
  35. # name a worker's teardown deleted files another worker's test was serving.
  36. _FILE_DIR = f"test_files_3029_{os.getpid()}"
  37. @pytest.fixture(autouse=True)
  38. def _clean_files():
  39. from backend.app.core.config import settings
  40. yield
  41. shutil.rmtree(Path(settings.base_dir) / _FILE_DIR, ignore_errors=True)
  42. def _write(name: str, body: bytes) -> str:
  43. """Write a file under the scratch dir and return its base_dir-relative path."""
  44. from backend.app.core.config import settings
  45. path = Path(settings.base_dir) / _FILE_DIR / name
  46. path.parent.mkdir(parents=True, exist_ok=True)
  47. path.write_bytes(body)
  48. return f"{_FILE_DIR}/{name}"
  49. async def _library_file(db_session, name: str, body: bytes = b"solid test\nendsolid test\n") -> int:
  50. from backend.app.models.library import LibraryFile
  51. row = LibraryFile(
  52. filename=f"{name}.stl",
  53. file_path=_write(f"{name}.stl", body),
  54. file_type="stl",
  55. file_size=len(body),
  56. )
  57. db_session.add(row)
  58. await db_session.commit()
  59. await db_session.refresh(row)
  60. return row.id
  61. async def _archive(db_session, name: str, *, with_source: bool = False) -> int:
  62. from backend.app.models.archive import PrintArchive
  63. row = PrintArchive(
  64. filename=f"{name}.3mf",
  65. file_path=_write(f"{name}.3mf", b"PK\x03\x04sliced"),
  66. file_size=13,
  67. source_3mf_path=_write(f"{name}_source.3mf", b"PK\x03\x04source") if with_source else None,
  68. )
  69. db_session.add(row)
  70. await db_session.commit()
  71. await db_session.refresh(row)
  72. return row.id
  73. async def _stored_token(resource_type: str, resource_id: int, *, expires_in_minutes: int = 5) -> str:
  74. """Insert a slicer token directly, so expiry can be set to the past."""
  75. import secrets
  76. from backend.app.core.database import async_session
  77. from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
  78. token = secrets.token_urlsafe(24)
  79. async with async_session() as db:
  80. db.add(
  81. AuthEphemeralToken(
  82. token=token,
  83. token_type=TokenType.SLICER_DOWNLOAD,
  84. nonce=f"{resource_type}:{resource_id}",
  85. expires_at=datetime.now(timezone.utc) + timedelta(minutes=expires_in_minutes),
  86. )
  87. )
  88. await db.commit()
  89. return token
  90. class TestTheSlicerThatFetchesTwice:
  91. """The reported fault: the second fetch of the same URL got a 403, and the
  92. slicer wrote that JSON body out as the model."""
  93. async def test_a_library_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
  94. file_id = await _library_file(db_session, "reused")
  95. minted = await async_client.post(f"/api/v1/library/files/{file_id}/slicer-token")
  96. assert minted.status_code == 200, minted.text
  97. token = minted.json()["token"]
  98. url = f"/api/v1/library/files/{file_id}/dl/{token}/reused.stl"
  99. first = await async_client.get(url)
  100. assert first.status_code == 200, first.text
  101. assert first.content.startswith(b"solid test")
  102. second = await async_client.get(url)
  103. assert second.status_code == 200, second.text
  104. assert second.content == first.content
  105. third = await async_client.get(url)
  106. assert third.status_code == 200
  107. async def test_an_archive_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
  108. archive_id = await _archive(db_session, "arc_reused")
  109. minted = await async_client.post(f"/api/v1/archives/{archive_id}/slicer-token")
  110. assert minted.status_code == 200, minted.text
  111. token = minted.json()["token"]
  112. url = f"/api/v1/archives/{archive_id}/dl/{token}/arc_reused.3mf"
  113. assert (await async_client.get(url)).status_code == 200
  114. assert (await async_client.get(url)).status_code == 200
  115. async def test_a_source_3mf_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
  116. archive_id = await _archive(db_session, "src_reused", with_source=True)
  117. minted = await async_client.post(f"/api/v1/archives/{archive_id}/source-slicer-token")
  118. assert minted.status_code == 200, minted.text
  119. token = minted.json()["token"]
  120. url = f"/api/v1/archives/{archive_id}/source-dl/{token}/src_reused.3mf"
  121. first = await async_client.get(url)
  122. assert first.status_code == 200, first.text
  123. assert (await async_client.get(url)).status_code == 200
  124. class TestWhatTheReusableTokenStillRefuses:
  125. """Reuse is the only thing that changed. Resource binding and expiry are
  126. what make these URLs safe to hand out, so each is checked explicitly."""
  127. async def test_it_is_still_bound_to_one_file(self, async_client: AsyncClient, db_session):
  128. mine = await _library_file(db_session, "bound_mine")
  129. theirs = await _library_file(db_session, "bound_theirs")
  130. token = (await async_client.post(f"/api/v1/library/files/{mine}/slicer-token")).json()["token"]
  131. wrong = await async_client.get(f"/api/v1/library/files/{theirs}/dl/{token}/bound_theirs.stl")
  132. assert wrong.status_code == 403
  133. # And the rejected attempt must not have burned the token for its own file.
  134. right = await async_client.get(f"/api/v1/library/files/{mine}/dl/{token}/bound_mine.stl")
  135. assert right.status_code == 200
  136. async def test_an_archive_token_does_not_open_the_source_3mf(self, async_client: AsyncClient, db_session):
  137. """The two archive downloads are separate resource keys on the same id."""
  138. archive_id = await _archive(db_session, "cross_key", with_source=True)
  139. token = (await async_client.post(f"/api/v1/archives/{archive_id}/slicer-token")).json()["token"]
  140. crossed = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/{token}/cross_key.3mf")
  141. assert crossed.status_code == 403
  142. async def test_an_expired_token_is_refused(self, async_client: AsyncClient, db_session):
  143. file_id = await _library_file(db_session, "stale")
  144. token = await _stored_token("library", file_id, expires_in_minutes=-1)
  145. response = await async_client.get(f"/api/v1/library/files/{file_id}/dl/{token}/stale.stl")
  146. assert response.status_code == 403
  147. async def test_an_unknown_token_is_refused(self, async_client: AsyncClient, db_session):
  148. file_id = await _library_file(db_session, "unknown")
  149. response = await async_client.get(f"/api/v1/library/files/{file_id}/dl/not-a-token/unknown.stl")
  150. assert response.status_code == 403
  151. class TestTheOneShotDownloadsStayOneShot:
  152. """Reuse was granted per endpoint, not to the primitive. The two browser
  153. downloads keep consuming their token, and the default is still to consume
  154. -- a new caller has to ask for reuse deliberately."""
  155. async def test_the_primitive_still_consumes_by_default(self, async_client: AsyncClient, db_session):
  156. from backend.app.core.auth import verify_slicer_download_token
  157. token = await _stored_token("printer-files", 7)
  158. assert await verify_slicer_download_token(token, "printer-files", 7) is True
  159. assert await verify_slicer_download_token(token, "printer-files", 7) is False
  160. async def test_a_reusable_check_does_not_consume(self, async_client: AsyncClient, db_session):
  161. from backend.app.core.auth import verify_slicer_download_token
  162. token = await _stored_token("library", 7)
  163. assert await verify_slicer_download_token(token, "library", 7, single_use=False) is True
  164. assert await verify_slicer_download_token(token, "library", 7, single_use=False) is True
  165. # ...and a consuming check on the same row still works, so the row is
  166. # not a different kind of token -- only the redemption differs.
  167. assert await verify_slicer_download_token(token, "library", 7) is True
  168. assert await verify_slicer_download_token(token, "library", 7, single_use=False) is False
  169. async def test_the_archive_timelapse_download_is_still_single_use(self, async_client: AsyncClient, db_session):
  170. from backend.app.models.archive import PrintArchive
  171. row = PrintArchive(
  172. filename="tl.3mf",
  173. file_path=_write("tl.3mf", b"PK\x03\x04"),
  174. file_size=4,
  175. timelapse_path=_write("tl.mp4", b"\x00\x00\x00 ftypisom"),
  176. )
  177. db_session.add(row)
  178. await db_session.commit()
  179. await db_session.refresh(row)
  180. token = (await async_client.post(f"/api/v1/archives/{row.id}/media-download-token")).json()["token"]
  181. url = f"/api/v1/archives/{row.id}/media/dl/{token}/tl.mp4"
  182. assert (await async_client.get(url)).status_code == 200
  183. assert (await async_client.get(url)).status_code == 403
  184. class TestTheSourceDownloadReachesItsHandler:
  185. """``PUBLIC_API_PATTERNS`` is matched with ``in path``, and ``source-dl/``
  186. does not contain ``/dl/``. With auth enabled the middleware answered 401
  187. before the route's token check ran, so "Open source 3MF in slicer" could
  188. never work -- the slicer has no header to send."""
  189. async def test_the_pattern_list_covers_the_source_route(self):
  190. from backend.app.main import PUBLIC_API_PATTERNS
  191. path = "/api/v1/archives/5/source-dl/tok/model.3mf"
  192. assert not any(p in path for p in ["/dl/"]), "guard: /dl/ must not cover source-dl"
  193. assert any(p in path for p in PUBLIC_API_PATTERNS)
  194. async def test_the_source_download_works_with_auth_enabled(self, async_client: AsyncClient, db_session):
  195. setup = await async_client.post(
  196. "/api/v1/auth/setup",
  197. json={"auth_enabled": True, "admin_username": "slicer3029", "admin_password": "AdminPass1!"},
  198. )
  199. assert setup.status_code in (200, 201), setup.text
  200. login = await async_client.post(
  201. "/api/v1/auth/login",
  202. json={"username": "slicer3029", "password": "AdminPass1!"},
  203. )
  204. assert login.status_code == 200, login.text
  205. jwt = login.json()["access_token"]
  206. archive_id = await _archive(db_session, "authed_source", with_source=True)
  207. minted = await async_client.post(
  208. f"/api/v1/archives/{archive_id}/source-slicer-token",
  209. headers={"Authorization": f"Bearer {jwt}"},
  210. )
  211. assert minted.status_code == 200, minted.text
  212. token = minted.json()["token"]
  213. # No Authorization header -- exactly what the protocol handler sends.
  214. response = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/{token}/authed_source.3mf")
  215. assert response.status_code == 200, response.text
  216. assert response.content == b"PK\x03\x04source"
  217. async def test_a_bad_token_is_refused_by_the_handler_not_the_middleware(
  218. self, async_client: AsyncClient, db_session
  219. ):
  220. """403, not 401: the middleware stepping aside must not make the route
  221. public, and the distinction is what proves the handler ran."""
  222. setup = await async_client.post(
  223. "/api/v1/auth/setup",
  224. json={"auth_enabled": True, "admin_username": "slicer3029b", "admin_password": "AdminPass1!"},
  225. )
  226. assert setup.status_code in (200, 201), setup.text
  227. archive_id = await _archive(db_session, "refused_source", with_source=True)
  228. response = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/nope/refused_source.3mf")
  229. assert response.status_code == 403