mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
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.
145 lines
4.4 KiB
Python
145 lines
4.4 KiB
Python
"""WebSocket dispatch-toast routing (#1625 follow-up).
|
|
|
|
Two contracts pinned here:
|
|
|
|
1. ``broadcast_to_user(uid, msg)`` only delivers to connections whose
|
|
``websocket.state.bambuddy_principal_user_id`` matches the target,
|
|
and fans out to all when the target is None (auth-disabled path).
|
|
2. The six ``send_queue_item_*`` helpers serialize the right payload
|
|
shape — the frontend toast reads exact field names + types.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from backend.app.core.printer_scope import ALL_PRINTERS
|
|
from backend.app.core.websocket import ConnectionManager
|
|
|
|
|
|
def _mock_conn(user_id: int | None):
|
|
"""Build a stand-in WebSocket-shaped object with the principal stamp.
|
|
|
|
Every real connection also gets a printer scope at connect (#1727); these
|
|
tests are about user routing, so it is the unrestricted one.
|
|
"""
|
|
conn = SimpleNamespace()
|
|
conn.state = SimpleNamespace()
|
|
conn.state.bambuddy_principal_user_id = user_id
|
|
conn.state.bambuddy_printer_scope = ALL_PRINTERS
|
|
conn.send_text = AsyncMock()
|
|
return conn
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_to_user_filters_by_principal_user_id():
|
|
"""A targeted broadcast only reaches the principal's connections."""
|
|
mgr = ConnectionManager()
|
|
alice = _mock_conn(7)
|
|
bob = _mock_conn(8)
|
|
anon = _mock_conn(None) # auth-disabled session — skipped on targeted path
|
|
mgr.active_connections = [alice, bob, anon]
|
|
|
|
await mgr.broadcast_to_user(7, {"type": "queue_item_uploading", "queue_item_id": 1})
|
|
|
|
alice.send_text.assert_awaited_once()
|
|
bob.send_text.assert_not_awaited()
|
|
anon.send_text.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_to_user_none_fans_out_to_all():
|
|
"""Auth-disabled installs route ``user_id=None`` to every connection
|
|
via the regular broadcast — matches the legacy single-user toast
|
|
behaviour where there was no per-user routing at all."""
|
|
mgr = ConnectionManager()
|
|
a = _mock_conn(None)
|
|
b = _mock_conn(None)
|
|
mgr.active_connections = [a, b]
|
|
|
|
await mgr.broadcast_to_user(None, {"type": "queue_item_uploading", "queue_item_id": 1})
|
|
|
|
a.send_text.assert_awaited_once()
|
|
b.send_text.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_queue_item_uploading_carries_total_bytes():
|
|
mgr = ConnectionManager()
|
|
target = _mock_conn(42)
|
|
mgr.active_connections = [target]
|
|
|
|
await mgr.send_queue_item_uploading(
|
|
user_id=42,
|
|
queue_item_id=11,
|
|
printer_id=1,
|
|
printer_name="H2D-1",
|
|
file_name="cube.3mf",
|
|
total_bytes=12345,
|
|
)
|
|
|
|
payload = json.loads(target.send_text.await_args.args[0])
|
|
assert payload == {
|
|
"type": "queue_item_uploading",
|
|
"queue_item_id": 11,
|
|
"printer_id": 1,
|
|
"printer_name": "H2D-1",
|
|
"file_name": "cube.3mf",
|
|
"total_bytes": 12345,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_queue_item_upload_progress_computes_pct_server_side():
|
|
"""The toast renders the pct field verbatim — the backend has to
|
|
compute it. Avoid divide-by-zero on a zero-byte upload."""
|
|
mgr = ConnectionManager()
|
|
target = _mock_conn(5)
|
|
mgr.active_connections = [target]
|
|
|
|
await mgr.send_queue_item_upload_progress(
|
|
user_id=5,
|
|
queue_item_id=3,
|
|
bytes_transferred=50,
|
|
total_bytes=200,
|
|
)
|
|
payload = json.loads(target.send_text.await_args.args[0])
|
|
assert payload["pct"] == 25
|
|
|
|
target.send_text.reset_mock()
|
|
await mgr.send_queue_item_upload_progress(
|
|
user_id=5,
|
|
queue_item_id=3,
|
|
bytes_transferred=0,
|
|
total_bytes=0,
|
|
)
|
|
payload = json.loads(target.send_text.await_args.args[0])
|
|
assert payload["pct"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_queue_item_failed_carries_reason_key():
|
|
"""The frontend looks up ``dispatchToast.failed.{reason}`` — so the
|
|
backend must hand the toast a reason string the i18n can match."""
|
|
mgr = ConnectionManager()
|
|
target = _mock_conn(99)
|
|
mgr.active_connections = [target]
|
|
|
|
await mgr.send_queue_item_failed(
|
|
user_id=99,
|
|
queue_item_id=8,
|
|
printer_id=2,
|
|
reason="upload_failed",
|
|
)
|
|
payload = json.loads(target.send_text.await_args.args[0])
|
|
assert payload == {
|
|
"type": "queue_item_failed",
|
|
"queue_item_id": 8,
|
|
"printer_id": 2,
|
|
"reason": "upload_failed",
|
|
}
|