Files
bambuddy/backend/tests/unit/test_printer_scope.py
T
maziggy 45921b7a56 Limit groups to selected printers (issue #1727)
A group can now be limited to a set of printers. Its members see and
control only those printers. Every other printer answers 404, as if it
didn't exist.

- Groups gain restrict_printers and a group_printers table (migration
  for SQLite and Postgres). A user's printers are the union of their
  limited groups. Groups without the flag don't limit anything, a user
  in no limited group keeps every printer, and admins see all of them.
- core/printer_scope.py holds the scope. RequestPrinterScope and
  RequirePrinterPermissionIfAuthEnabled apply it to routes: printer
  routes, camera, queue and batches, archives, projects, stats, print
  log, pipeline runs, inventory and Spoolman assignments, maintenance,
  smart plugs, scheduled drying, firmware and Obico status.
- API keys, camera stream, Cam Wall, overlay and WebSocket tokens carry
  the printers of whoever created them. WebSocket broadcasts are
  filtered per connection, and the filtering fails closed.
- Scheduler: "Any <model>" jobs stay on their owner's printers. A job
  pinned to a printer its owner lost waits with a reason. Callers with
  no user identity and limited printers must queue to a specific printer.
- Group editor: new Printer access section, translated into all 15
  locales. Saving a system group no longer resends unchanged
  permissions, which the backend refused.
2026-10-01 14:47:40 +02:00

129 lines
4.6 KiB
Python

"""PrinterScope semantics and WebSocket fan-out filtering (#1727)."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
from backend.app.core.websocket import ConnectionManager
class TestPrinterScope:
def test_unrestricted_allows_everything(self):
assert ALL_PRINTERS.is_unrestricted
assert ALL_PRINTERS.allows(1)
assert ALL_PRINTERS.where(None) is None
def test_restricted_allows_only_its_printers(self):
scope = PrinterScope(frozenset({1, 2}))
assert scope.allows(1)
assert not scope.allows(3)
assert scope.filter_ids([3, 2, 1]) == [2, 1]
def test_no_printer_is_always_in_scope(self):
# Rows not bound to a printer (orphaned archives, model-based jobs)
assert PrinterScope(frozenset()).allows(None)
def test_ensure_reports_missing_not_forbidden(self):
with pytest.raises(HTTPException) as exc:
PrinterScope(frozenset({1})).ensure(2)
assert exc.value.status_code == 404
assert exc.value.detail == "Printer not found"
def test_intersect(self):
team = PrinterScope(frozenset({1, 2}))
assert ALL_PRINTERS.intersect(team) == team
assert team.intersect(ALL_PRINTERS) == team
assert team.intersect(PrinterScope(frozenset({2, 3}))) == PrinterScope(frozenset({2}))
def _socket(scope: PrinterScope | None):
state = SimpleNamespace()
if scope is not None:
state.bambuddy_printer_scope = scope
return SimpleNamespace(state=state, send_text=AsyncMock())
class TestBroadcastFiltering:
@pytest.mark.asyncio
async def test_printer_events_reach_only_sockets_that_may_see_the_printer(self):
mgr = ConnectionManager()
team = _socket(PrinterScope(frozenset({1})))
everyone = _socket(ALL_PRINTERS)
mgr.active_connections = [team, everyone]
await mgr.send_printer_status(2, {})
team.send_text.assert_not_awaited()
everyone.send_text.assert_awaited_once()
@pytest.mark.asyncio
async def test_printer_id_inside_data_is_honoured(self):
mgr = ConnectionManager()
team = _socket(PrinterScope(frozenset({1})))
mgr.active_connections = [team]
await mgr.send_archive_created({"id": 9, "printer_id": 2})
team.send_text.assert_not_awaited()
await mgr.send_archive_created({"id": 10, "printer_id": 1})
team.send_text.assert_awaited_once()
@pytest.mark.asyncio
async def test_messages_about_no_printer_reach_everyone(self):
mgr = ConnectionManager()
team = _socket(PrinterScope(frozenset()))
mgr.active_connections = [team]
await mgr.broadcast({"type": "inventory_changed"})
team.send_text.assert_awaited_once()
@pytest.mark.asyncio
async def test_socket_without_a_scope_gets_no_printer_events(self):
"""Fail closed: a socket that missed the connect-time stamp hears nothing printer-bound."""
mgr = ConnectionManager()
unstamped = _socket(None)
mgr.active_connections = [unstamped]
await mgr.send_printer_status(1, {})
unstamped.send_text.assert_not_awaited()
await mgr.broadcast({"type": "inventory_changed"})
unstamped.send_text.assert_awaited_once()
@pytest.mark.asyncio
async def test_targeted_broadcast_is_filtered_too(self):
mgr = ConnectionManager()
own = _socket(PrinterScope(frozenset({1})))
own.state.bambuddy_principal_user_id = 7
mgr.active_connections = [own]
await mgr.send_queue_item_acked(7, queue_item_id=1, printer_id=2)
own.send_text.assert_not_awaited()
class TestScopeRefresh:
@pytest.mark.asyncio
async def test_a_failed_refresh_fails_closed(self):
"""Old scopes may be wider than what was just granted, so they can't be kept."""
from unittest.mock import patch
mgr = ConnectionManager()
socket = _socket(ALL_PRINTERS)
socket.state.bambuddy_scope_principal = ("member", None)
socket.close = AsyncMock()
mgr.active_connections = [socket]
with (
patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=True)),
patch("backend.app.core.auth.principal_printer_scope", AsyncMock(side_effect=RuntimeError("db down"))),
):
await mgr.refresh_printer_scopes()
assert socket.state.bambuddy_printer_scope == PrinterScope(frozenset())
socket.close.assert_awaited_once_with(code=4401)