test_slicer_token_reuse_3029.py 13 KB

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