| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- """Integration tests for reusable slicer download tokens (#3029).
- The "Slice" action hands a URL to a *separate process* -- Bambu Studio or
- OrcaSlicer, launched through a protocol handler that cannot carry an
- ``Authorization`` header. Until this fix the token in that URL was consumed by
- the first request that reached the endpoint, which made the handoff dependent
- on the slicer fetching the URL exactly once. Nothing guarantees that: Bambu
- Studio's downloader retries three times after a failed attempt, transfers get
- resumed, on-access scanners fetch. Whichever party arrived first won, and the
- slicer was handed a 403.
- So the three protocol-handler downloads now accept their token for the rest of
- its five-minute TTL. Everything else about the token is unchanged, and these
- tests pin the difference in both directions: the second fetch works, and the
- token is still refused for the wrong resource, after expiry, and when unknown.
- The two *browser* downloads that share the same primitive stay one-shot, and
- are pinned here too -- the prepared printer bundle is deleted once streamed, so
- reuse there could only ever mean a 404 with a misleading cause.
- The second half covers a fault found while checking the first: the auth
- middleware matches ``PUBLIC_API_PATTERNS`` by substring, and the source-3MF
- route's segment is ``source-dl`` -- which does not contain ``/dl/``. With auth
- enabled the middleware rejected the slicer's header-less request before the
- route's own token check ever ran.
- """
- from __future__ import annotations
- import shutil
- from datetime import datetime, timedelta, timezone
- from pathlib import Path
- import pytest
- from httpx import AsyncClient
- pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
- # Same reasoning as #3025's fixtures: the routes resolve paths relative to
- # ``settings.base_dir``, which under test is the project root, so everything
- # goes in one subdirectory that is removed after each test.
- _FILE_DIR = "test_files_3029"
- @pytest.fixture(autouse=True)
- def _clean_files():
- from backend.app.core.config import settings
- yield
- shutil.rmtree(Path(settings.base_dir) / _FILE_DIR, ignore_errors=True)
- def _write(name: str, body: bytes) -> str:
- """Write a file under the scratch dir and return its base_dir-relative path."""
- from backend.app.core.config import settings
- path = Path(settings.base_dir) / _FILE_DIR / name
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_bytes(body)
- return f"{_FILE_DIR}/{name}"
- async def _library_file(db_session, name: str, body: bytes = b"solid test\nendsolid test\n") -> int:
- from backend.app.models.library import LibraryFile
- row = LibraryFile(
- filename=f"{name}.stl",
- file_path=_write(f"{name}.stl", body),
- file_type="stl",
- file_size=len(body),
- )
- db_session.add(row)
- await db_session.commit()
- await db_session.refresh(row)
- return row.id
- async def _archive(db_session, name: str, *, with_source: bool = False) -> int:
- from backend.app.models.archive import PrintArchive
- row = PrintArchive(
- filename=f"{name}.3mf",
- file_path=_write(f"{name}.3mf", b"PK\x03\x04sliced"),
- file_size=13,
- source_3mf_path=_write(f"{name}_source.3mf", b"PK\x03\x04source") if with_source else None,
- )
- db_session.add(row)
- await db_session.commit()
- await db_session.refresh(row)
- return row.id
- async def _stored_token(resource_type: str, resource_id: int, *, expires_in_minutes: int = 5) -> str:
- """Insert a slicer token directly, so expiry can be set to the past."""
- import secrets
- from backend.app.core.database import async_session
- from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
- token = secrets.token_urlsafe(24)
- async with async_session() as db:
- db.add(
- AuthEphemeralToken(
- token=token,
- token_type=TokenType.SLICER_DOWNLOAD,
- nonce=f"{resource_type}:{resource_id}",
- expires_at=datetime.now(timezone.utc) + timedelta(minutes=expires_in_minutes),
- )
- )
- await db.commit()
- return token
- class TestTheSlicerThatFetchesTwice:
- """The reported fault: the second fetch of the same URL got a 403, and the
- slicer wrote that JSON body out as the model."""
- async def test_a_library_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
- file_id = await _library_file(db_session, "reused")
- minted = await async_client.post(f"/api/v1/library/files/{file_id}/slicer-token")
- assert minted.status_code == 200, minted.text
- token = minted.json()["token"]
- url = f"/api/v1/library/files/{file_id}/dl/{token}/reused.stl"
- first = await async_client.get(url)
- assert first.status_code == 200, first.text
- assert first.content.startswith(b"solid test")
- second = await async_client.get(url)
- assert second.status_code == 200, second.text
- assert second.content == first.content
- third = await async_client.get(url)
- assert third.status_code == 200
- async def test_an_archive_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
- archive_id = await _archive(db_session, "arc_reused")
- minted = await async_client.post(f"/api/v1/archives/{archive_id}/slicer-token")
- assert minted.status_code == 200, minted.text
- token = minted.json()["token"]
- url = f"/api/v1/archives/{archive_id}/dl/{token}/arc_reused.3mf"
- assert (await async_client.get(url)).status_code == 200
- assert (await async_client.get(url)).status_code == 200
- async def test_a_source_3mf_download_survives_a_second_fetch(self, async_client: AsyncClient, db_session):
- archive_id = await _archive(db_session, "src_reused", with_source=True)
- minted = await async_client.post(f"/api/v1/archives/{archive_id}/source-slicer-token")
- assert minted.status_code == 200, minted.text
- token = minted.json()["token"]
- url = f"/api/v1/archives/{archive_id}/source-dl/{token}/src_reused.3mf"
- first = await async_client.get(url)
- assert first.status_code == 200, first.text
- assert (await async_client.get(url)).status_code == 200
- class TestWhatTheReusableTokenStillRefuses:
- """Reuse is the only thing that changed. Resource binding and expiry are
- what make these URLs safe to hand out, so each is checked explicitly."""
- async def test_it_is_still_bound_to_one_file(self, async_client: AsyncClient, db_session):
- mine = await _library_file(db_session, "bound_mine")
- theirs = await _library_file(db_session, "bound_theirs")
- token = (await async_client.post(f"/api/v1/library/files/{mine}/slicer-token")).json()["token"]
- wrong = await async_client.get(f"/api/v1/library/files/{theirs}/dl/{token}/bound_theirs.stl")
- assert wrong.status_code == 403
- # And the rejected attempt must not have burned the token for its own file.
- right = await async_client.get(f"/api/v1/library/files/{mine}/dl/{token}/bound_mine.stl")
- assert right.status_code == 200
- async def test_an_archive_token_does_not_open_the_source_3mf(self, async_client: AsyncClient, db_session):
- """The two archive downloads are separate resource keys on the same id."""
- archive_id = await _archive(db_session, "cross_key", with_source=True)
- token = (await async_client.post(f"/api/v1/archives/{archive_id}/slicer-token")).json()["token"]
- crossed = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/{token}/cross_key.3mf")
- assert crossed.status_code == 403
- async def test_an_expired_token_is_refused(self, async_client: AsyncClient, db_session):
- file_id = await _library_file(db_session, "stale")
- token = await _stored_token("library", file_id, expires_in_minutes=-1)
- response = await async_client.get(f"/api/v1/library/files/{file_id}/dl/{token}/stale.stl")
- assert response.status_code == 403
- async def test_an_unknown_token_is_refused(self, async_client: AsyncClient, db_session):
- file_id = await _library_file(db_session, "unknown")
- response = await async_client.get(f"/api/v1/library/files/{file_id}/dl/not-a-token/unknown.stl")
- assert response.status_code == 403
- class TestTheOneShotDownloadsStayOneShot:
- """Reuse was granted per endpoint, not to the primitive. The two browser
- downloads keep consuming their token, and the default is still to consume
- -- a new caller has to ask for reuse deliberately."""
- async def test_the_primitive_still_consumes_by_default(self, async_client: AsyncClient, db_session):
- from backend.app.core.auth import verify_slicer_download_token
- token = await _stored_token("printer-files", 7)
- assert await verify_slicer_download_token(token, "printer-files", 7) is True
- assert await verify_slicer_download_token(token, "printer-files", 7) is False
- async def test_a_reusable_check_does_not_consume(self, async_client: AsyncClient, db_session):
- from backend.app.core.auth import verify_slicer_download_token
- token = await _stored_token("library", 7)
- assert await verify_slicer_download_token(token, "library", 7, single_use=False) is True
- assert await verify_slicer_download_token(token, "library", 7, single_use=False) is True
- # ...and a consuming check on the same row still works, so the row is
- # not a different kind of token -- only the redemption differs.
- assert await verify_slicer_download_token(token, "library", 7) is True
- assert await verify_slicer_download_token(token, "library", 7, single_use=False) is False
- async def test_the_archive_timelapse_download_is_still_single_use(self, async_client: AsyncClient, db_session):
- from backend.app.models.archive import PrintArchive
- row = PrintArchive(
- filename="tl.3mf",
- file_path=_write("tl.3mf", b"PK\x03\x04"),
- file_size=4,
- timelapse_path=_write("tl.mp4", b"\x00\x00\x00 ftypisom"),
- )
- db_session.add(row)
- await db_session.commit()
- await db_session.refresh(row)
- token = (await async_client.post(f"/api/v1/archives/{row.id}/media-download-token")).json()["token"]
- url = f"/api/v1/archives/{row.id}/media/dl/{token}/tl.mp4"
- assert (await async_client.get(url)).status_code == 200
- assert (await async_client.get(url)).status_code == 403
- class TestTheSourceDownloadReachesItsHandler:
- """``PUBLIC_API_PATTERNS`` is matched with ``in path``, and ``source-dl/``
- does not contain ``/dl/``. With auth enabled the middleware answered 401
- before the route's token check ran, so "Open source 3MF in slicer" could
- never work -- the slicer has no header to send."""
- async def test_the_pattern_list_covers_the_source_route(self):
- from backend.app.main import PUBLIC_API_PATTERNS
- path = "/api/v1/archives/5/source-dl/tok/model.3mf"
- assert not any(p in path for p in ["/dl/"]), "guard: /dl/ must not cover source-dl"
- assert any(p in path for p in PUBLIC_API_PATTERNS)
- async def test_the_source_download_works_with_auth_enabled(self, async_client: AsyncClient, db_session):
- setup = await async_client.post(
- "/api/v1/auth/setup",
- json={"auth_enabled": True, "admin_username": "slicer3029", "admin_password": "AdminPass1!"},
- )
- assert setup.status_code in (200, 201), setup.text
- login = await async_client.post(
- "/api/v1/auth/login",
- json={"username": "slicer3029", "password": "AdminPass1!"},
- )
- assert login.status_code == 200, login.text
- jwt = login.json()["access_token"]
- archive_id = await _archive(db_session, "authed_source", with_source=True)
- minted = await async_client.post(
- f"/api/v1/archives/{archive_id}/source-slicer-token",
- headers={"Authorization": f"Bearer {jwt}"},
- )
- assert minted.status_code == 200, minted.text
- token = minted.json()["token"]
- # No Authorization header -- exactly what the protocol handler sends.
- response = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/{token}/authed_source.3mf")
- assert response.status_code == 200, response.text
- assert response.content == b"PK\x03\x04source"
- async def test_a_bad_token_is_refused_by_the_handler_not_the_middleware(
- self, async_client: AsyncClient, db_session
- ):
- """403, not 401: the middleware stepping aside must not make the route
- public, and the distinction is what proves the handler ran."""
- setup = await async_client.post(
- "/api/v1/auth/setup",
- json={"auth_enabled": True, "admin_username": "slicer3029b", "admin_password": "AdminPass1!"},
- )
- assert setup.status_code in (200, 201), setup.text
- archive_id = await _archive(db_session, "refused_source", with_source=True)
- response = await async_client.get(f"/api/v1/archives/{archive_id}/source-dl/nope/refused_source.3mf")
- assert response.status_code == 403
|