test_printer_scope.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. """PrinterScope semantics and WebSocket fan-out filtering (#1727)."""
  2. from __future__ import annotations
  3. from types import SimpleNamespace
  4. from unittest.mock import AsyncMock
  5. import pytest
  6. from fastapi import HTTPException
  7. from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
  8. from backend.app.core.websocket import ConnectionManager
  9. class TestPrinterScope:
  10. def test_unrestricted_allows_everything(self):
  11. assert ALL_PRINTERS.is_unrestricted
  12. assert ALL_PRINTERS.allows(1)
  13. assert ALL_PRINTERS.where(None) is None
  14. def test_restricted_allows_only_its_printers(self):
  15. scope = PrinterScope(frozenset({1, 2}))
  16. assert scope.allows(1)
  17. assert not scope.allows(3)
  18. def test_no_printer_is_always_in_scope(self):
  19. # Rows not bound to a printer (orphaned archives, model-based jobs)
  20. assert PrinterScope(frozenset()).allows(None)
  21. def test_ensure_reports_missing_not_forbidden(self):
  22. with pytest.raises(HTTPException) as exc:
  23. PrinterScope(frozenset({1})).ensure(2)
  24. assert exc.value.status_code == 404
  25. assert exc.value.detail == "Printer not found"
  26. def test_intersect(self):
  27. team = PrinterScope(frozenset({1, 2}))
  28. assert ALL_PRINTERS.intersect(team) == team
  29. assert team.intersect(ALL_PRINTERS) == team
  30. assert team.intersect(PrinterScope(frozenset({2, 3}))) == PrinterScope(frozenset({2}))
  31. def _socket(scope: PrinterScope | None):
  32. state = SimpleNamespace()
  33. if scope is not None:
  34. state.bambuddy_printer_scope = scope
  35. return SimpleNamespace(state=state, send_text=AsyncMock())
  36. class TestBroadcastFiltering:
  37. @pytest.mark.asyncio
  38. async def test_printer_events_reach_only_sockets_that_may_see_the_printer(self):
  39. mgr = ConnectionManager()
  40. team = _socket(PrinterScope(frozenset({1})))
  41. everyone = _socket(ALL_PRINTERS)
  42. mgr.active_connections = [team, everyone]
  43. await mgr.send_printer_status(2, {})
  44. team.send_text.assert_not_awaited()
  45. everyone.send_text.assert_awaited_once()
  46. @pytest.mark.asyncio
  47. async def test_printer_id_inside_data_is_honoured(self):
  48. mgr = ConnectionManager()
  49. team = _socket(PrinterScope(frozenset({1})))
  50. mgr.active_connections = [team]
  51. await mgr.send_archive_created({"id": 9, "printer_id": 2})
  52. team.send_text.assert_not_awaited()
  53. await mgr.send_archive_created({"id": 10, "printer_id": 1})
  54. team.send_text.assert_awaited_once()
  55. @pytest.mark.asyncio
  56. async def test_messages_about_no_printer_reach_everyone(self):
  57. mgr = ConnectionManager()
  58. team = _socket(PrinterScope(frozenset()))
  59. mgr.active_connections = [team]
  60. await mgr.broadcast({"type": "inventory_changed"})
  61. team.send_text.assert_awaited_once()
  62. @pytest.mark.asyncio
  63. async def test_socket_without_a_scope_gets_no_printer_events(self):
  64. """Fail closed: a socket that missed the connect-time stamp hears nothing printer-bound."""
  65. mgr = ConnectionManager()
  66. unstamped = _socket(None)
  67. mgr.active_connections = [unstamped]
  68. await mgr.send_printer_status(1, {})
  69. unstamped.send_text.assert_not_awaited()
  70. await mgr.broadcast({"type": "inventory_changed"})
  71. unstamped.send_text.assert_awaited_once()
  72. @pytest.mark.asyncio
  73. async def test_targeted_broadcast_is_filtered_too(self):
  74. mgr = ConnectionManager()
  75. own = _socket(PrinterScope(frozenset({1})))
  76. own.state.bambuddy_principal_user_id = 7
  77. mgr.active_connections = [own]
  78. await mgr.send_queue_item_acked(7, queue_item_id=1, printer_id=2)
  79. own.send_text.assert_not_awaited()
  80. class TestScopeRefresh:
  81. @pytest.mark.asyncio
  82. async def test_a_failed_refresh_fails_closed(self):
  83. """Old scopes may be wider than what was just granted, so they can't be kept."""
  84. from unittest.mock import patch
  85. mgr = ConnectionManager()
  86. socket = _socket(ALL_PRINTERS)
  87. socket.state.bambuddy_scope_principal = ("member", None)
  88. socket.close = AsyncMock()
  89. mgr.active_connections = [socket]
  90. scope_lookup = AsyncMock(side_effect=RuntimeError("db down"))
  91. with (
  92. patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=True)),
  93. # Still allowed to connect, so the refresh reaches the scope lookup.
  94. patch("backend.app.core.auth.principal_may_connect", AsyncMock(return_value=True)),
  95. patch("backend.app.core.auth.principal_printer_scope", scope_lookup),
  96. ):
  97. await mgr.refresh_printer_scopes()
  98. scope_lookup.assert_awaited_once()
  99. assert socket.state.bambuddy_printer_scope == PrinterScope(frozenset())
  100. socket.close.assert_awaited_once_with(code=4401)