"""Integration tests for the token-authenticated streaming-overlay feed (#2613). Like the Cam Wall feed, the overlay endpoint exists as its own scope-gated route because a kiosk/OBS URL is not a secret. But it is deliberately *wider* than the Cam Wall: it names the file being printed (the overlay draws the part on screen). So the tests that matter are the scope boundaries — an overlay token must not reach the Cam Wall feed and vice versa, a camwall token must not reach the overlay feed (that would leak the filename it is trusted to hide) — plus the positive path and the disconnected-printer shape. """ from __future__ import annotations import pytest from httpx import AsyncClient pytestmark = [pytest.mark.asyncio, pytest.mark.integration] async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str: await async_client.post( "/api/v1/auth/setup", json={ "auth_enabled": True, "admin_username": f"overlayadmin{suffix}", "admin_password": "AdminPass1!", }, ) login = await async_client.post( "/api/v1/auth/login", json={"username": f"overlayadmin{suffix}", "password": "AdminPass1!"}, ) return login.json()["access_token"] async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "obs") -> str: response = await async_client.post( "/api/v1/auth/tokens", headers={"Authorization": f"Bearer {jwt}"}, json={"name": name, "expires_in_days": 30, "scope": scope}, ) assert response.status_code == 201, response.text assert response.json()["scope"] == scope return response.json()["token"] @pytest.fixture async def printer_row(db_session): """Insert the printer straight into the DB. POST /printers probes the real device before it will store a row, and there is no printer on the other end of a test run. """ from backend.app.models.printer import Printer printer = Printer( name="Stream P1S", ip_address="192.168.1.88", access_code="12345678", serial_number="01P00A000000002", model="P1S", ) db_session.add(printer) await db_session.commit() return printer class TestOverlayFeedAuth: async def test_no_token_is_rejected(self, async_client: AsyncClient, printer_row): await _setup_admin(async_client, suffix="_notoken") response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status") assert response.status_code == 401 async def test_garbage_token_is_rejected(self, async_client: AsyncClient, printer_row): await _setup_admin(async_client, suffix="_garbage") response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token=bblt_aaaaaaaa_nope") assert response.status_code == 401 async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row): """A ``camera_stream`` token was handed out for video alone — it must not acquire the live print status (and filename) just because a new feature shipped. """ jwt = await _setup_admin(async_client, suffix="_streamscope") stream_token = await _mint(async_client, jwt, scope="camera_stream") response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={stream_token}") assert response.status_code == 401 async def test_camwall_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row): """The crux of a *separate* scope from camwall. A Cam Wall token is trusted precisely because it can never name the part being printed. The overlay feed does name it, so a camwall token must be rejected here — otherwise every wall token silently gains filename visibility. """ jwt = await _setup_admin(async_client, suffix="_camwallscope") camwall_token = await _mint(async_client, jwt, scope="camwall") response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={camwall_token}") assert response.status_code == 401 async def test_overlay_token_reaches_the_feed(self, async_client: AsyncClient, printer_row): jwt = await _setup_admin(async_client, suffix="_rightscope") overlay_token = await _mint(async_client, jwt, scope="overlay") response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}") assert response.status_code == 200, response.text assert response.json()["name"] == "Stream P1S" async def test_revoked_overlay_token_is_rejected(self, async_client: AsyncClient, printer_row): jwt = await _setup_admin(async_client, suffix="_revoked") created = await async_client.post( "/api/v1/auth/tokens", headers={"Authorization": f"Bearer {jwt}"}, json={"name": "obs", "expires_in_days": 30, "scope": "overlay"}, ) overlay_token = created.json()["token"] await async_client.delete( f"/api/v1/auth/tokens/{created.json()['id']}", headers={"Authorization": f"Bearer {jwt}"}, ) response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}") assert response.status_code == 401 class TestOverlayFeedPayload: async def test_payload_shape_includes_filename_fields(self, async_client: AsyncClient, printer_row): """Unlike the Cam Wall, the overlay *does* carry the filename fields — that is what distinguishes the scope. Assert the exact key set so the payload can't silently grow to leak more than the overlay draws. """ jwt = await _setup_admin(async_client, suffix="_payload") overlay_token = await _mint(async_client, jwt, scope="overlay") response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}") assert response.status_code == 200 entry = response.json() # Never the secrets — the URL is on a public stream. for leaked in ("serial_number", "ip_address", "access_code"): assert leaked not in entry, f"{leaked} must not be served to an overlay token" assert set(entry) == { "id", "name", "camera_rotation", "connected", "state", "current_print", "gcode_file", "progress", "remaining_time", "layer_num", "total_layers", "stg_cur_name", "time_format", } async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row): """No MQTT client runs in tests, so the printer has no state — the overlay must render its offline state rather than erroring. """ jwt = await _setup_admin(async_client, suffix="_offline") overlay_token = await _mint(async_client, jwt, scope="overlay") response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}") entry = response.json() assert entry["connected"] is False assert entry["state"] is None assert entry["current_print"] is None async def test_unknown_printer_is_404_not_401(self, async_client: AsyncClient): """A valid token for a printer id that doesn't exist is a 404 — the token passed the gate, the resource simply isn't there. """ jwt = await _setup_admin(async_client, suffix="_404") overlay_token = await _mint(async_client, jwt, scope="overlay") response = await async_client.get(f"/api/v1/printers/99999/overlay-status?token={overlay_token}") assert response.status_code == 404 class TestOverlayTokenReachesTheVideo: """The overlay draws the camera feed, so the same token has to satisfy the camera-stream gate. """ async def test_overlay_token_passes_the_camera_stream_gate(self, async_client: AsyncClient): from backend.app.core.auth import verify_camera_stream_token jwt = await _setup_admin(async_client, suffix="_video") overlay_token = await _mint(async_client, jwt, scope="overlay") assert await verify_camera_stream_token(overlay_token) is True async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient): from backend.app.core.auth import verify_overlay_token jwt = await _setup_admin(async_client, suffix="_gate") stream_token = await _mint(async_client, jwt, scope="camera_stream") camwall_token = await _mint(async_client, jwt, scope="camwall", name="wall") assert await verify_overlay_token(stream_token) is False assert await verify_overlay_token(camwall_token) is False async def test_camwall_gate_rejects_an_overlay_token(self, async_client: AsyncClient): """Symmetric guard: the new scope must not widen the Cam Wall either.""" from backend.app.core.auth import verify_camwall_token jwt = await _setup_admin(async_client, suffix="_gate_camwall") overlay_token = await _mint(async_client, jwt, scope="overlay") assert await verify_camwall_token(overlay_token) is False