"""Printer-scoped access (#1727).
A group with ``restrict_printers`` set limits its members to the printers it
lists. These tests pin the contract end to end:
* a member sees and acts only on the team's printers; any other printer reads
as missing (404), never as forbidden, so its id isn't confirmed;
* groups without the flag narrow nothing, so a user in no restricted group
keeps every printer (upgrades are a no-op) and a permission group combined
with a team group doesn't widen the team;
* a restricted group with no printers grants none -- it does not fall back to
every printer;
* API keys, camera-stream, Cam Wall and WebSocket tokens all carry the scope of
whoever created them.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
TEAM_PERMISSIONS = [
"printers:read",
"printers:control",
"camera:view",
"queue:read_all",
"queue:create",
"archives:read_all",
"archives:reprint_all",
"archives:delete_all",
"websocket:connect",
"api_keys:create",
]
def _auth(jwt: str) -> dict[str, str]:
return {"Authorization": f"Bearer {jwt}"}
async def _admin_token(async_client: AsyncClient) -> str:
await async_client.post(
"/api/v1/auth/setup",
json={"auth_enabled": True, "admin_username": "scopeadmin", "admin_password": "AdminPass1!"},
)
login = await async_client.post("/api/v1/auth/login", json={"username": "scopeadmin", "password": "AdminPass1!"})
assert login.status_code == 200, login.text
return login.json()["access_token"]
async def _group(
async_client: AsyncClient,
admin_jwt: str,
name: str,
*,
permissions: list[str] | None = None,
printer_ids: list[int] | None = None,
) -> int:
body: dict = {"name": name, "permissions": permissions or []}
if printer_ids is not None:
body.update(restrict_printers=True, printer_ids=printer_ids)
response = await async_client.post("/api/v1/groups/", headers=_auth(admin_jwt), json=body)
assert response.status_code == 201, response.text
return response.json()["id"]
async def _user(async_client: AsyncClient, admin_jwt: str, username: str, group_ids: list[int]) -> tuple[str, int]:
created = await async_client.post(
"/api/v1/users/",
headers=_auth(admin_jwt),
json={"username": username, "password": "UserPass1!", "group_ids": group_ids},
)
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 _team_member(async_client: AsyncClient, admin_jwt: str, username: str, printer_ids: list[int]):
"""A user in a permission group plus a team group restricted to *printer_ids*."""
perms = await _group(async_client, admin_jwt, f"perms_{username}", permissions=TEAM_PERMISSIONS)
team = await _group(async_client, admin_jwt, f"team_{username}", printer_ids=printer_ids)
return await _user(async_client, admin_jwt, username, [perms, team])
async def _listed_ids(async_client: AsyncClient, headers: dict[str, str]) -> set[int]:
response = await async_client.get("/api/v1/printers/", headers=headers)
assert response.status_code == 200, response.text
return {p["id"] for p in response.json()}
class TestVisibility:
async def test_user_in_no_restricted_group_sees_every_printer(self, async_client, printer_factory):
a, b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
perms = await _group(async_client, admin, "plain", permissions=TEAM_PERMISSIONS)
jwt, _ = await _user(async_client, admin, "plain_user", [perms])
assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
async def test_team_member_sees_only_team_printers(self, async_client, printer_factory):
a, _b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "member", [a.id])
# The permission group (no flag) must not widen the team group
assert await _listed_ids(async_client, _auth(jwt)) == {a.id}
async def test_hidden_printer_reads_as_missing(self, async_client, printer_factory, mock_printer_manager):
a, b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "member", [a.id])
headers = _auth(jwt)
assert (await async_client.get(f"/api/v1/printers/{b.id}", headers=headers)).status_code == 404
assert (await async_client.get(f"/api/v1/printers/{b.id}/status", headers=headers)).status_code == 404
with patch("backend.app.api.routes.printers.printer_manager") as manager:
stop = await async_client.post(f"/api/v1/printers/{b.id}/print/stop", headers=headers)
assert stop.status_code == 404
manager.stop_print.assert_not_called()
# The same 404 a printer id that doesn't exist gets
missing = await async_client.get("/api/v1/printers/999999", headers=headers)
assert missing.status_code == 404
assert (await async_client.get(f"/api/v1/printers/{a.id}", headers=headers)).status_code == 200
async def test_scope_is_the_union_of_restricted_groups(self, async_client, printer_factory):
a = await printer_factory(name="A")
b = await printer_factory(name="B")
await printer_factory(name="C")
admin = await _admin_token(async_client)
perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
team_a = await _group(async_client, admin, "team_a", printer_ids=[a.id])
team_b = await _group(async_client, admin, "team_b", printer_ids=[b.id])
jwt, _ = await _user(async_client, admin, "both", [perms, team_a, team_b])
assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
async def test_restricted_group_without_printers_grants_none(self, async_client, printer_factory):
await printer_factory(name="A")
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "locked_out", [])
assert await _listed_ids(async_client, _auth(jwt)) == set()
async def test_admin_sees_every_printer(self, async_client, printer_factory):
a, b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
await _team_member(async_client, admin, "member", [a.id])
assert await _listed_ids(async_client, _auth(admin)) == {a.id, b.id}
class TestGroupApi:
async def test_round_trip(self, async_client, printer_factory):
a, b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
group_id = await _group(async_client, admin, "team", printer_ids=[a.id])
detail = (await async_client.get(f"/api/v1/groups/{group_id}", headers=_auth(admin))).json()
assert detail["restrict_printers"] is True
assert detail["printer_ids"] == [a.id]
patched = await async_client.patch(
f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"printer_ids": [a.id, b.id]}
)
assert patched.status_code == 200, patched.text
assert patched.json()["printer_ids"] == [a.id, b.id]
listed = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
assert next(g for g in listed if g["id"] == group_id)["printer_ids"] == [a.id, b.id]
# Turning the flag off keeps the selection
off = await async_client.patch(
f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"restrict_printers": False}
)
assert off.json()["restrict_printers"] is False
assert off.json()["printer_ids"] == [a.id, b.id]
async def test_unknown_printer_is_rejected(self, async_client, printer_factory):
await printer_factory(name="A")
admin = await _admin_token(async_client)
response = await async_client.post(
"/api/v1/groups/",
headers=_auth(admin),
json={"name": "team", "restrict_printers": True, "printer_ids": [424242]},
)
assert response.status_code == 400
assert "424242" in response.json()["detail"]
async def test_administrators_cannot_be_restricted(self, async_client):
admin = await _admin_token(async_client)
groups = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
admins = next(g for g in groups if g["name"] == "Administrators")
response = await async_client.patch(
f"/api/v1/groups/{admins['id']}", headers=_auth(admin), json={"restrict_printers": True}
)
assert response.status_code == 400
async def test_deleting_a_printer_drops_it_from_groups(self, async_client, printer_factory):
a, b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
group_id = await _group(async_client, admin, "team", printer_ids=[a.id, b.id])
with patch("backend.app.api.routes.printers.printer_manager"):
deleted = await async_client.delete(f"/api/v1/printers/{b.id}", headers=_auth(admin))
assert deleted.status_code == 200, deleted.text
detail = (await async_client.get(f"/api/v1/groups/{group_id}", headers=_auth(admin))).json()
assert detail["printer_ids"] == [a.id]
async def test_deleting_a_group_drops_its_printer_rows(self, async_client, printer_factory, db_session):
from sqlalchemy import select
from backend.app.models.group import group_printers
a = await printer_factory(name="A")
admin = await _admin_token(async_client)
group_id = await _group(async_client, admin, "team", printer_ids=[a.id])
assert (await async_client.delete(f"/api/v1/groups/{group_id}", headers=_auth(admin))).status_code == 204
rows = await db_session.execute(select(group_printers).where(group_printers.c.group_id == group_id))
assert rows.first() is None
class TestApiKeys:
async def test_key_is_narrowed_to_its_owners_printers(self, async_client, printer_factory):
a, b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "member", [a.id])
# The key itself is unrestricted, but it can't out-rank its owner
created = await async_client.post(
"/api/v1/api-keys/", headers=_auth(jwt), json={"name": "k", "can_read_status": True}
)
assert created.status_code == 200, created.text
key_headers = {"X-API-Key": created.json()["key"]}
assert await _listed_ids(async_client, key_headers) == {a.id}
assert (await async_client.get(f"/api/v1/printers/{b.id}", headers=key_headers)).status_code == 404
webhook = await async_client.get(f"/api/v1/webhook/printer/{b.id}/status", headers=key_headers)
assert webhook.status_code == 404
async def test_key_printer_ids_now_bind_every_printer_route(self, async_client, printer_factory):
"""``printer_ids`` used to be checked by the file routes and webhooks only."""
a, b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
created = await async_client.post(
"/api/v1/api-keys/",
headers=_auth(admin),
json={"name": "k", "can_read_status": True, "can_control_printer": True, "printer_ids": [a.id]},
)
key_headers = {"X-API-Key": created.json()["key"]}
assert await _listed_ids(async_client, key_headers) == {a.id}
with patch("backend.app.api.routes.printers.printer_manager") as manager:
stop = await async_client.post(f"/api/v1/printers/{b.id}/print/stop", headers=key_headers)
assert stop.status_code == 404
manager.stop_print.assert_not_called()
class TestTokens:
async def test_camera_stream_token_carries_its_minters_scope(self, async_client, printer_factory):
from backend.app.core.auth import verify_camera_stream_token
a, b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "member", [a.id])
minted = await async_client.post("/api/v1/printers/camera/stream-token", headers=_auth(jwt))
token = minted.json()["token"]
scope = await verify_camera_stream_token(token)
assert scope is not None and scope.printer_ids == frozenset({a.id})
snapshot = await async_client.get(f"/api/v1/printers/{b.id}/camera/snapshot?token={token}")
assert snapshot.status_code == 404
async def test_camwall_token_lists_only_its_owners_printers(self, async_client, printer_factory):
a, _b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "member", [a.id])
created = await async_client.post(
"/api/v1/auth/tokens",
headers=_auth(jwt),
json={"name": "wall", "expires_in_days": 30, "scope": "camwall"},
)
assert created.status_code in (200, 201), created.text
token = created.json()["token"]
wall = await async_client.get(f"/api/v1/camwall/printers?token={token}")
assert wall.status_code == 200, wall.text
assert [p["id"] for p in wall.json()] == [a.id]
async def test_websocket_token_carries_its_minters_scope(self, async_client, printer_factory):
from backend.app.core.auth import principal_printer_scope, verify_websocket_token_principal
from backend.app.core.database import async_session
a, _b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "member", [a.id])
token = (await async_client.post("/api/v1/auth/ws-token", headers=_auth(jwt))).json()["token"]
principal = await verify_websocket_token_principal(token)
assert principal == ("member", None)
async with async_session() as db:
scope = await principal_printer_scope(db, *principal)
assert scope.printer_ids == frozenset({a.id})
async def test_websocket_token_minted_by_a_key_carries_the_keys_scope(self, async_client, printer_factory):
from backend.app.core.auth import principal_printer_scope, verify_websocket_token_principal
from backend.app.core.database import async_session
a, _b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
created = await async_client.post(
"/api/v1/api-keys/",
headers=_auth(admin),
json={"name": "k", "can_read_status": True, "printer_ids": [a.id]},
)
key_headers = {"X-API-Key": created.json()["key"]}
token = (await async_client.post("/api/v1/auth/ws-token", headers=key_headers)).json()["token"]
principal = await verify_websocket_token_principal(token)
assert principal == ("", created.json()["id"])
async with async_session() as db:
scope = await principal_printer_scope(db, *principal)
assert scope.printer_ids == frozenset({a.id})
class TestQueueAndHistory:
async def test_queue_hides_and_refuses_other_printers(self, async_client, printer_factory, archive_factory):
a, b = await printer_factory(name="A"), await printer_factory(name="B")
archive = await archive_factory(a.id)
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "member", [a.id])
on_b = await async_client.post(
"/api/v1/queue/", headers=_auth(admin), json={"archive_id": archive.id, "printer_id": b.id}
)
assert on_b.status_code == 200, on_b.text
listed = await async_client.get("/api/v1/queue/", headers=_auth(jwt))
assert on_b.json()["id"] not in {item["id"] for item in listed.json()}
item = await async_client.get(f"/api/v1/queue/{on_b.json()['id']}", headers=_auth(jwt))
assert item.status_code == 404
refused = await async_client.post(
"/api/v1/queue/", headers=_auth(jwt), json={"archive_id": archive.id, "printer_id": b.id}
)
assert refused.status_code == 404
async def test_limited_key_cannot_queue_to_any_printer_of_a_model(
self, async_client, printer_factory, archive_factory
):
"""A key's jobs record no creator, so "any X1C" would escape its printers."""
a = await printer_factory(name="A", model="X1C")
await printer_factory(name="B", model="X1C")
archive = await archive_factory(a.id)
admin = await _admin_token(async_client)
created = await async_client.post(
"/api/v1/api-keys/",
headers=_auth(admin),
json={"name": "k", "can_queue": True, "printer_ids": [a.id]},
)
key_headers = {"X-API-Key": created.json()["key"]}
refused = await async_client.post(
"/api/v1/queue/", headers=key_headers, json={"archive_id": archive.id, "target_model": "X1C"}
)
assert refused.status_code == 400
pinned = await async_client.post(
"/api/v1/queue/", headers=key_headers, json={"archive_id": archive.id, "printer_id": a.id}
)
assert pinned.status_code == 200, pinned.text
async def test_library_add_to_queue_keeps_to_the_scope(self, async_client, printer_factory):
a, b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
created = await async_client.post(
"/api/v1/api-keys/",
headers=_auth(admin),
json={"name": "k", "can_queue": True, "printer_ids": [a.id]},
)
key_headers = {"X-API-Key": created.json()["key"]}
# Both are refused for the whole request, before any file is looked at
hidden = await async_client.post(
"/api/v1/library/files/add-to-queue", headers=key_headers, json={"file_ids": [1], "printer_id": b.id}
)
assert hidden.status_code == 404
any_model = await async_client.post(
"/api/v1/library/files/add-to-queue", headers=key_headers, json={"file_ids": [1]}
)
assert any_model.status_code == 400
async def test_limited_user_may_queue_to_any_printer_of_a_model(
self, async_client, printer_factory, archive_factory
):
"""The job carries the user's id, so the scheduler keeps it to their printers."""
a = await printer_factory(name="A", model="X1C")
await printer_factory(name="B", model="X1C")
archive = await archive_factory(a.id)
admin = await _admin_token(async_client)
jwt, user_id = await _team_member(async_client, admin, "member", [a.id])
queued = await async_client.post(
"/api/v1/queue/", headers=_auth(jwt), json={"archive_id": archive.id, "target_model": "X1C"}
)
assert queued.status_code == 200, queued.text
assert queued.json()["created_by_id"] == user_id
async def test_archive_list_keeps_to_the_team_printers(self, async_client, printer_factory, archive_factory):
a, b = await printer_factory(name="A"), await printer_factory(name="B")
on_a = await archive_factory(a.id, print_name="on-a")
on_b = await archive_factory(b.id, print_name="on-b")
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "member", [a.id])
listed = await async_client.get("/api/v1/archives/", headers=_auth(jwt))
ids = {row["id"] for row in listed.json()}
assert on_a.id in ids
assert on_b.id not in ids
class TestScheduler:
async def test_model_matching_stays_within_the_scope(self, db_session, printer_factory):
from backend.app.core.printer_scope import PrinterScope
from backend.app.services.print_scheduler import PrintScheduler
a = await printer_factory(name="A", model="X1C")
await printer_factory(name="B", model="X1C")
printers = await PrintScheduler()._printers_for_model(
db_session, "X1C", printer_scope=PrinterScope(frozenset({a.id}))
)
assert [p.id for p in printers] == [a.id]
async def test_deactivated_creator_reaches_no_printer(self, async_client, db_session, printer_factory):
from backend.app.core.printer_scope import resolve_user_id_printer_scope
await printer_factory(name="A")
admin = await _admin_token(async_client)
perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
_jwt, user_id = await _user(async_client, admin, "gone", [perms])
await async_client.patch(f"/api/v1/users/{user_id}", headers=_auth(admin), json={"is_active": False})
scope = await resolve_user_id_printer_scope(db_session, user_id)
assert scope.printer_ids == frozenset()
class TestWebSocketRefresh:
async def test_group_change_rescopes_open_sockets(self, async_client, printer_factory):
from types import SimpleNamespace
from backend.app.core.printer_scope import ALL_PRINTERS
from backend.app.core.websocket import ws_manager
a, b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "member", [a.id])
groups = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
team_id = next(g["id"] for g in groups if g["name"] == "team_member")
socket = SimpleNamespace(
state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS, bambuddy_scope_principal=("member", None)),
send_text=AsyncMock(),
)
ws_manager.active_connections.append(socket)
try:
await async_client.patch(f"/api/v1/groups/{team_id}", headers=_auth(admin), json={"printer_ids": [b.id]})
assert socket.state.bambuddy_printer_scope.printer_ids == frozenset({b.id})
await ws_manager.send_printer_status(a.id, {})
socket.send_text.assert_not_awaited()
await ws_manager.send_printer_status(b.id, {})
socket.send_text.assert_awaited_once()
finally:
ws_manager.active_connections.remove(socket)
class TestByIdAndMedia:
"""Rows reached by id or by an ``
`` media token follow the same scope."""
async def _archive_with_thumbnail(self, archive_factory, printer_id: int, name: str):
import os
from pathlib import Path
from backend.app.core.config import settings
rel = f"test_thumbs_1727_{os.getpid()}/{name}.png"
thumb = Path(settings.base_dir) / rel
thumb.parent.mkdir(parents=True, exist_ok=True)
thumb.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
return await archive_factory(printer_id, thumbnail_path=rel)
async def test_archives_by_id_and_by_media_token(self, async_client, printer_factory, archive_factory):
import os
import shutil
from pathlib import Path
from backend.app.core.config import settings
a, b = await printer_factory(name="A"), await printer_factory(name="B")
on_a = await self._archive_with_thumbnail(archive_factory, a.id, "on_a")
on_b = await self._archive_with_thumbnail(archive_factory, b.id, "on_b")
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "member", [a.id])
try:
assert (await async_client.get(f"/api/v1/archives/{on_a.id}", headers=_auth(jwt))).status_code == 200
assert (await async_client.get(f"/api/v1/archives/{on_b.id}", headers=_auth(jwt))).status_code == 404
deleted = await async_client.delete(f"/api/v1/archives/{on_b.id}", headers=_auth(jwt))
assert deleted.status_code == 404
#
requests carry a media token and no headers
member_token = (await async_client.post("/api/v1/auth/media-token", headers=_auth(jwt))).json()["token"]
admin_token = (await async_client.post("/api/v1/auth/media-token", headers=_auth(admin))).json()["token"]
own = await async_client.get(f"/api/v1/archives/{on_a.id}/thumbnail?token={member_token}")
assert own.status_code == 200
hidden = await async_client.get(f"/api/v1/archives/{on_b.id}/thumbnail?token={member_token}")
assert hidden.status_code == 404
# An admin's thumbnails keep loading: no headers must not mean "no printers"
admin_view = await async_client.get(f"/api/v1/archives/{on_b.id}/thumbnail?token={admin_token}")
assert admin_view.status_code == 200
finally:
shutil.rmtree(Path(settings.base_dir) / f"test_thumbs_1727_{os.getpid()}", ignore_errors=True)
async def test_camera_stop_is_scoped(self, async_client, printer_factory):
_a, b = await printer_factory(name="A"), await printer_factory(name="B")
admin = await _admin_token(async_client)
jwt, _ = await _team_member(async_client, admin, "member", [_a.id])
response = await async_client.post(f"/api/v1/printers/{b.id}/camera/stop", headers=_auth(jwt))
assert response.status_code == 404
async def test_clearing_the_print_log_keeps_other_printers_entries(
self, async_client, printer_factory, archive_factory, db_session
):
from sqlalchemy import select
from backend.app.models.print_log import PrintLogEntry
a, b = await printer_factory(name="A"), await printer_factory(name="B")
await archive_factory(a.id)
await archive_factory(b.id)
admin = await _admin_token(async_client)
perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
team = await _group(async_client, admin, "team", printer_ids=[a.id])
jwt, _ = await _user(async_client, admin, "member", [perms, team])
cleared = await async_client.delete("/api/v1/print-log/", headers=_auth(jwt))
assert cleared.status_code == 200, cleared.text
left = (await db_session.execute(select(PrintLogEntry.printer_id))).scalars().all()
assert left == [b.id]