Files
bambuddy/backend/tests/integration/test_media_token_3025.py
T
maziggy 816f073a9e fix(auth): decouple media routes from the camera stream token (issue #3025)
Thirteen routes with nothing to do with a camera took the camera stream
token as their credential -- library and archive thumbnails, plate
previews and plate thumbnails, timelapses, print photos, archive QR
codes, project covers, print-log thumbnails, printer covers and
external-link icons. A browser cannot put an Authorization header on an
<img src>, so these need a credential that fits in the URL, and the
camera token was the only one that existed. Minting one costs
camera:view, so a user granted library access to their own files got a
grid of broken images until they were also handed the live camera.

Adds a media token: minted by POST /auth/media-token behind plain
authentication, and identified -- it records the principal the way the
websocket token does rather than being anonymous the way the camera
token is. Each route now gates on the permission and ownership rules of
the resource it serves, through the same _ensure_*_visible helpers its
header-authenticated siblings already use. The three camera routes keep
the camera token, and require_camera_stream_token_if_auth_enabled now
documents that it is for those only.

The media dependencies accept ordinary Authorization / X-API-Key headers
as well as ?token=, delegating that path to the existing checkers, so
API-key scope rules and the per-printer allowlist are unchanged.

Long-lived camera_stream, camwall and overlay tokens are deliberately
not accepted on the media routes -- those are handed to kiosks, walls
and Home Assistant to display video. The cam wall, streaming overlay and
kiosk views use only the three camera routes and are unaffected.

Frontend: withMediaToken alongside withStreamToken, and
useStreamTokenSync fetches a media token for every signed-in user while
asking for a camera token only when the user can mint one, which also
stops the 403 that fired on every page load for everyone else.

Also fixed, same class:
- /printers/{id}/files/plate-thumbnail/{i} is rendered in an <img> but
  had a header-only guard, so the file manager's plate thumbnails 401'd
  whenever auth was enabled. It now takes a media token too.
- getProjectCoverImageUrl returned a URL ending in ?token=, and the
  project edit dialog appended its own ?v= cache-buster after it, so the
  second ? landed inside the token value. The version is now a parameter
  applied before the token.

Tests: 15 integration tests for the token boundary, permission
enforcement and per-row scoping; 10 frontend tests for the URL split and
the two-query hook. test_cover_image_get_uses_stream_token_gate is
renamed and repointed at the media gate -- what it pins, that the
credential has to fit in a URL, is unchanged.
2026-09-07 13:38:15 +02:00

426 lines
20 KiB
Python

"""Integration tests for the media token (#3025).
Thirteen non-camera media routes -- library and archive thumbnails, plate
previews, timelapses, print photos, QR codes, project covers, link icons --
were gated by the *camera stream* token. That had two consequences, and these
tests pin both fixes:
1. ``camera:view`` was a prerequisite for every image in the app. A user given
library access to their own job folder saw broken thumbnails until they were
also handed the live feed of the room the printer is in.
2. A camera stream token records no principal, so those routes had no identity
to scope by and returned any row to any holder.
The media token is the replacement: minted behind plain authentication, and
identified, so each route applies the same permission and ownership rules as
its header-authenticated siblings.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from httpx import AsyncClient
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
# library:read_own + archives:read_own, and deliberately NOT camera:view --
# the reporter's exact group in #3025.
NO_CAMERA_PERMISSIONS = [
"library:read_own",
"library:upload",
"archives:read_own",
"projects:read",
"external_links:read",
"printers:read",
]
async def _admin_token(async_client: AsyncClient, suffix: str) -> str:
await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": f"mediaadmin{suffix}",
"admin_password": "AdminPass1!",
},
)
login = await async_client.post(
"/api/v1/auth/login",
json={"username": f"mediaadmin{suffix}", "password": "AdminPass1!"},
)
assert login.status_code == 200, login.text
return login.json()["access_token"]
async def _make_user(
async_client: AsyncClient,
admin_jwt: str,
*,
username: str,
permissions: list[str],
) -> tuple[str, int]:
"""Create a user in a fresh group holding exactly *permissions*."""
group = await async_client.post(
"/api/v1/groups/",
headers={"Authorization": f"Bearer {admin_jwt}"},
json={"name": f"grp_{username}", "permissions": permissions},
)
assert group.status_code in (200, 201), group.text
created = await async_client.post(
"/api/v1/users/",
headers={"Authorization": f"Bearer {admin_jwt}"},
json={
"username": username,
"password": "UserPass1!",
"group_ids": [group.json()["id"]],
},
)
assert created.status_code in (200, 201), created.text
login = await async_client.post(
"/api/v1/auth/login",
json={"username": username, "password": "UserPass1!"},
)
assert login.status_code == 200, login.text
return login.json()["access_token"], created.json()["id"]
async def _mint_media_token(async_client: AsyncClient, jwt: str) -> str:
response = await async_client.post(
"/api/v1/auth/media-token",
headers={"Authorization": f"Bearer {jwt}"},
)
assert response.status_code == 200, response.text
return response.json()["token"]
async def _mint_camera_token(async_client: AsyncClient, jwt: str) -> str:
response = await async_client.post(
"/api/v1/printers/camera/stream-token",
headers={"Authorization": f"Bearer {jwt}"},
)
assert response.status_code == 200, response.text
return response.json()["token"]
# The routes resolve thumbnails relative to ``settings.base_dir``, so the
# fixtures have to write there rather than into tmp_path. Keep them in one
# subdirectory and delete it after every test so a run leaves the tree clean.
_THUMB_DIR = "test_thumbs_3025"
@pytest.fixture(autouse=True)
def _clean_thumbs():
from backend.app.core.config import settings
yield
shutil.rmtree(Path(settings.base_dir) / _THUMB_DIR, ignore_errors=True)
async def _library_file(db_session, owner_id: int | None, name: str) -> int:
"""Insert a library row with a real thumbnail on disk."""
from backend.app.core.config import settings
from backend.app.models.library import LibraryFile
thumb = Path(settings.base_dir) / _THUMB_DIR / f"{name}.png"
thumb.parent.mkdir(parents=True, exist_ok=True)
thumb.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
row = LibraryFile(
filename=f"{name}.3mf",
file_path=f"library/files/{name}.3mf",
thumbnail_path=f"{_THUMB_DIR}/{thumb.name}",
file_type="3mf",
file_size=1234,
created_by_id=owner_id,
)
db_session.add(row)
await db_session.commit()
await db_session.refresh(row)
return row.id
class TestTheUserWhoCouldNotSeeTheirOwnThumbnails:
"""The reported fault: camera:view was load-bearing for every image."""
async def test_a_user_without_camera_view_can_mint_a_media_token(self, async_client: AsyncClient):
admin = await _admin_token(async_client, "_mint")
jwt, _ = await _make_user(async_client, admin, username="nocamera_mint", permissions=NO_CAMERA_PERMISSIONS)
response = await async_client.post("/api/v1/auth/media-token", headers={"Authorization": f"Bearer {jwt}"})
assert response.status_code == 200, response.text
assert response.json()["token"]
async def test_the_camera_token_is_still_out_of_reach_for_them(self, async_client: AsyncClient):
"""The permission split is real, not cosmetic: the media token does not
smuggle in camera access, and minting a camera token still costs
camera:view."""
admin = await _admin_token(async_client, "_nocam")
jwt, _ = await _make_user(async_client, admin, username="nocamera_still", permissions=NO_CAMERA_PERMISSIONS)
response = await async_client.post(
"/api/v1/printers/camera/stream-token", headers={"Authorization": f"Bearer {jwt}"}
)
assert response.status_code == 403
async def test_they_can_load_their_own_library_thumbnail(self, async_client: AsyncClient, db_session):
admin = await _admin_token(async_client, "_own")
jwt, user_id = await _make_user(async_client, admin, username="nocamera_own", permissions=NO_CAMERA_PERMISSIONS)
file_id = await _library_file(db_session, user_id, "own")
token = await _mint_media_token(async_client, jwt)
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
assert response.status_code == 200, response.text
assert response.content.startswith(b"\x89PNG")
class TestTheBoundaryBetweenTheTwoTokens:
"""Neither token is accepted where the other belongs."""
async def test_a_camera_stream_token_is_refused_on_a_media_route(self, async_client: AsyncClient, db_session):
"""The inverse of verify_camwall_token's rule. A camera-stream token is
anonymous, so honouring it here would reinstate the unowned read."""
admin = await _admin_token(async_client, "_xcam")
file_id = await _library_file(db_session, None, "xcam")
camera_token = await _mint_camera_token(async_client, admin)
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={camera_token}")
assert response.status_code == 401
async def test_a_media_token_is_refused_on_the_live_camera(self, async_client: AsyncClient):
admin = await _admin_token(async_client, "_xmedia")
media_token = await _mint_media_token(async_client, admin)
response = await async_client.get(f"/api/v1/printers/1/camera/snapshot?token={media_token}")
assert response.status_code == 401
async def test_no_token_at_all_is_refused(self, async_client: AsyncClient, db_session):
await _admin_token(async_client, "_notok")
file_id = await _library_file(db_session, None, "notok")
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail")
assert response.status_code == 401
async def test_a_garbage_token_is_refused(self, async_client: AsyncClient, db_session):
await _admin_token(async_client, "_garbage")
file_id = await _library_file(db_session, None, "garbage")
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token=not-a-real-token")
assert response.status_code == 401
class TestWhoseRowsAMediaTokenCanRead:
"""The unreported half: the old guard had no principal, so it had nothing
to scope by. These fail against the camera-token implementation."""
async def test_it_cannot_read_another_users_library_thumbnail(self, async_client: AsyncClient, db_session):
admin = await _admin_token(async_client, "_cross")
_, alice_id = await _make_user(async_client, admin, username="alice_lib", permissions=NO_CAMERA_PERMISSIONS)
bob_jwt, _ = await _make_user(async_client, admin, username="bob_lib", permissions=NO_CAMERA_PERMISSIONS)
alice_file = await _library_file(db_session, alice_id, "alice")
bob_token = await _mint_media_token(async_client, bob_jwt)
response = await async_client.get(f"/api/v1/library/files/{alice_file}/thumbnail?token={bob_token}")
# 404 rather than 403 -- the same id-enumeration-proof answer
# _ensure_library_file_visible gives on every other library route.
assert response.status_code == 404
async def test_an_ownerless_file_needs_read_all(self, async_client: AsyncClient, db_session):
"""Fail-closed, matching _ensure_library_file_visible."""
admin = await _admin_token(async_client, "_orphan")
jwt, _ = await _make_user(async_client, admin, username="orphan_reader", permissions=NO_CAMERA_PERMISSIONS)
file_id = await _library_file(db_session, None, "orphan")
token = await _mint_media_token(async_client, jwt)
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
assert response.status_code == 404
async def test_an_admin_with_read_all_still_sees_everything(self, async_client: AsyncClient, db_session):
"""The gate must not over-correct into breaking legitimate access."""
admin = await _admin_token(async_client, "_readall")
_, alice_id = await _make_user(async_client, admin, username="alice_readall", permissions=NO_CAMERA_PERMISSIONS)
alice_file = await _library_file(db_session, alice_id, "readall")
admin_token = await _mint_media_token(async_client, admin)
response = await async_client.get(f"/api/v1/library/files/{alice_file}/thumbnail?token={admin_token}")
assert response.status_code == 200
class TestWhatTheTokenStillRequires:
"""A media token is authentication, not authorisation -- each route keeps
asking for the permission its resource is governed by."""
async def test_a_user_without_library_permission_is_refused(self, async_client: AsyncClient, db_session):
admin = await _admin_token(async_client, "_noperm")
jwt, user_id = await _make_user(async_client, admin, username="noperm_user", permissions=["printers:read"])
file_id = await _library_file(db_session, user_id, "noperm")
token = await _mint_media_token(async_client, jwt)
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
assert response.status_code == 403
async def test_a_deactivated_users_token_stops_working(self, async_client: AsyncClient, db_session):
"""The token outlives the session it was minted in, so the principal is
re-resolved on every request rather than trusted from mint time."""
admin = await _admin_token(async_client, "_deact")
jwt, user_id = await _make_user(async_client, admin, username="deact_user", permissions=NO_CAMERA_PERMISSIONS)
file_id = await _library_file(db_session, user_id, "deact")
token = await _mint_media_token(async_client, jwt)
assert (await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")).status_code == 200
deactivate = await async_client.patch(
f"/api/v1/users/{user_id}",
headers={"Authorization": f"Bearer {admin}"},
json={"is_active": False},
)
assert deactivate.status_code == 200, deactivate.text
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
assert response.status_code == 401
class TestTheHeaderPathStillWorks:
"""A media route is reachable with ordinary credentials too, so a fetch()
or an API-keyed integration does not need a token at all."""
async def test_a_bearer_jwt_reaches_a_media_route_without_any_token(self, async_client: AsyncClient, db_session):
admin = await _admin_token(async_client, "_bearer")
jwt, user_id = await _make_user(async_client, admin, username="bearer_user", permissions=NO_CAMERA_PERMISSIONS)
file_id = await _library_file(db_session, user_id, "bearer")
response = await async_client.get(
f"/api/v1/library/files/{file_id}/thumbnail",
headers={"Authorization": f"Bearer {jwt}"},
)
assert response.status_code == 200
async def test_the_header_path_is_ownership_scoped_too(self, async_client: AsyncClient, db_session):
admin = await _admin_token(async_client, "_bearerx")
_, alice_id = await _make_user(async_client, admin, username="alice_bearer", permissions=NO_CAMERA_PERMISSIONS)
bob_jwt, _ = await _make_user(async_client, admin, username="bob_bearer", permissions=NO_CAMERA_PERMISSIONS)
alice_file = await _library_file(db_session, alice_id, "alicebearer")
response = await async_client.get(
f"/api/v1/library/files/{alice_file}/thumbnail",
headers={"Authorization": f"Bearer {bob_jwt}"},
)
assert response.status_code == 404
class TestAuthDisabled:
async def test_media_routes_stay_open_when_auth_is_off(self, async_client: AsyncClient, db_session):
"""No setup call -- auth is off, and the routes must not start
demanding a token that an unauthenticated install cannot mint."""
file_id = await _library_file(db_session, None, "authoff")
response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail")
assert response.status_code == 200
async def _archive(db_session, owner_id: int | None, name: str) -> int:
"""Insert an archive with a real thumbnail and timelapse on disk."""
from backend.app.core.config import settings
from backend.app.models.archive import PrintArchive
base = Path(settings.base_dir) / _THUMB_DIR
base.mkdir(parents=True, exist_ok=True)
(base / f"{name}_thumb.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
(base / f"{name}_tl.mp4").write_bytes(b"\x00\x00\x00 ftypisom" + b"0" * 32)
row = PrintArchive(
filename=f"{name}.3mf",
file_path=f"archives/{name}.3mf",
file_size=1234,
thumbnail_path=f"{_THUMB_DIR}/{name}_thumb.png",
timelapse_path=f"{_THUMB_DIR}/{name}_tl.mp4",
created_by_id=owner_id,
)
db_session.add(row)
await db_session.commit()
await db_session.refresh(row)
return row.id
class TestTheArchiveMediaRoutes:
"""The seven archive routes are where the sensitive content lives -- a
timelapse and the finish photos are a video of someone's room. They are
covered separately from library because the existing integration suite runs
with auth disabled, so nothing else exercises them with auth on."""
async def test_an_owner_can_load_their_archive_thumbnail(self, async_client: AsyncClient, db_session):
admin = await _admin_token(async_client, "_arcown")
jwt, uid = await _make_user(async_client, admin, username="arc_owner", permissions=NO_CAMERA_PERMISSIONS)
archive_id = await _archive(db_session, uid, "arcown")
token = await _mint_media_token(async_client, jwt)
response = await async_client.get(f"/api/v1/archives/{archive_id}/thumbnail?token={token}")
assert response.status_code == 200, response.text
async def test_another_user_cannot_load_that_thumbnail(self, async_client: AsyncClient, db_session):
admin = await _admin_token(async_client, "_arcx")
_, alice_id = await _make_user(async_client, admin, username="alice_arc", permissions=NO_CAMERA_PERMISSIONS)
bob_jwt, _ = await _make_user(async_client, admin, username="bob_arc", permissions=NO_CAMERA_PERMISSIONS)
archive_id = await _archive(db_session, alice_id, "arcx")
bob_token = await _mint_media_token(async_client, bob_jwt)
response = await async_client.get(f"/api/v1/archives/{archive_id}/thumbnail?token={bob_token}")
assert response.status_code == 404
async def test_another_user_cannot_load_that_timelapse(self, async_client: AsyncClient, db_session):
"""The one that matters most: a timelapse is footage of the room the
printer is in."""
admin = await _admin_token(async_client, "_arctl")
_, alice_id = await _make_user(async_client, admin, username="alice_tl", permissions=NO_CAMERA_PERMISSIONS)
bob_jwt, _ = await _make_user(async_client, admin, username="bob_tl", permissions=NO_CAMERA_PERMISSIONS)
archive_id = await _archive(db_session, alice_id, "arctl")
bob_token = await _mint_media_token(async_client, bob_jwt)
assert (await async_client.get(f"/api/v1/archives/{archive_id}/timelapse?token={bob_token}")).status_code == 404
async def test_a_camera_token_reaches_no_archive_media(self, async_client: AsyncClient, db_session):
admin = await _admin_token(async_client, "_arccam")
archive_id = await _archive(db_session, None, "arccam")
camera_token = await _mint_camera_token(async_client, admin)
for path in ("thumbnail", "timelapse", "plate-preview", "qrcode"):
response = await async_client.get(f"/api/v1/archives/{archive_id}/{path}?token={camera_token}")
assert response.status_code == 401, f"{path} accepted a camera token: {response.status_code}"
class TestTheFlatPermissionMediaRoutes:
"""printers/{id}/cover, external-links/{id}/icon and projects/{id}/cover-image
have no per-row owner, so they gate on the resource's read permission."""
async def test_the_link_icon_needs_external_links_read(self, async_client: AsyncClient):
admin = await _admin_token(async_client, "_icon")
jwt, _ = await _make_user(async_client, admin, username="icon_user", permissions=["printers:read"])
token = await _mint_media_token(async_client, jwt)
response = await async_client.get(f"/api/v1/external-links/1/icon?token={token}")
assert response.status_code == 403
async def test_the_link_icon_is_reachable_with_that_permission(self, async_client: AsyncClient):
admin = await _admin_token(async_client, "_icon2")
jwt, _ = await _make_user(async_client, admin, username="icon_user2", permissions=NO_CAMERA_PERMISSIONS)
token = await _mint_media_token(async_client, jwt)
# 404 because no such link exists -- the point is that it is not 401/403.
response = await async_client.get(f"/api/v1/external-links/1/icon?token={token}")
assert response.status_code == 404
async def test_the_printer_cover_needs_printers_read(self, async_client: AsyncClient):
admin = await _admin_token(async_client, "_cover")
jwt, _ = await _make_user(async_client, admin, username="cover_user", permissions=["external_links:read"])
token = await _mint_media_token(async_client, jwt)
response = await async_client.get(f"/api/v1/printers/1/cover?token={token}")
assert response.status_code == 403
async def test_the_project_cover_needs_projects_read(self, async_client: AsyncClient):
admin = await _admin_token(async_client, "_pcover")
jwt, _ = await _make_user(async_client, admin, username="pcover_user", permissions=["printers:read"])
token = await _mint_media_token(async_client, jwt)
response = await async_client.get(f"/api/v1/projects/1/cover-image?token={token}")
assert response.status_code == 403