test_ws_connect_permission.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. """Taking websocket:connect away ends the live connection it allowed.
  2. Only minting a WebSocket token checked the permission: the handshake and the
  3. scope refresh did not. A user whose group lost it kept their open dashboard
  4. (their printers' live status and every event of no printer), and their token
  5. reconnected for an hour.
  6. """
  7. from types import SimpleNamespace
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from fastapi import WebSocketDisconnect
  11. from httpx import AsyncClient
  12. from backend.app.core import database
  13. from backend.app.core.printer_scope import ALL_PRINTERS
  14. from backend.app.core.websocket import ws_manager
  15. from backend.app.models.api_key import APIKey
  16. pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
  17. @pytest.fixture
  18. async def admin(async_client: AsyncClient) -> dict:
  19. await async_client.post(
  20. "/api/v1/auth/setup",
  21. json={"auth_enabled": True, "admin_username": "wsadmin", "admin_password": "AdminPass1!"},
  22. )
  23. login = await async_client.post("/api/v1/auth/login", json={"username": "wsadmin", "password": "AdminPass1!"})
  24. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  25. async def _carol_in_group(client: AsyncClient, admin: dict) -> tuple[int, int]:
  26. group = await client.post(
  27. "/api/v1/groups/", headers=admin, json={"name": "Live", "permissions": ["websocket:connect", "printers:read"]}
  28. )
  29. assert group.status_code == 201, group.text
  30. user = await client.post(
  31. "/api/v1/users/",
  32. headers=admin,
  33. json={"username": "carol", "password": "Userpass1!", "role": "user", "group_ids": [group.json()["id"]]},
  34. )
  35. assert user.status_code == 201, user.text
  36. return group.json()["id"], user.json()["id"]
  37. def _socket(username: str | None, user_id: int | None, key_id: int | None = None, prefix: str | None = None):
  38. socket = MagicMock()
  39. socket.state = SimpleNamespace(
  40. bambuddy_scope_principal=(username if username else "", key_id),
  41. bambuddy_principal_user_id=user_id,
  42. bambuddy_api_key_prefix=prefix,
  43. bambuddy_printer_scope=ALL_PRINTERS,
  44. )
  45. socket.close = AsyncMock()
  46. return socket
  47. @pytest.mark.parametrize("keeps", [False, True])
  48. async def test_a_group_losing_the_permission_closes_its_users_sockets(async_client: AsyncClient, admin, keeps):
  49. group, carol = await _carol_in_group(async_client, admin)
  50. socket = _socket("carol", carol)
  51. ws_manager.active_connections.append(socket)
  52. permissions = ["websocket:connect", "printers:read"] if keeps else ["printers:read"]
  53. try:
  54. with patch("backend.app.core.database.async_session", database.async_session):
  55. response = await async_client.patch(
  56. f"/api/v1/groups/{group}", headers=admin, json={"permissions": permissions}
  57. )
  58. assert response.status_code == 200, response.text
  59. if keeps:
  60. socket.close.assert_not_awaited()
  61. else:
  62. socket.close.assert_awaited_once_with(code=4401)
  63. finally:
  64. if socket in ws_manager.active_connections:
  65. ws_manager.active_connections.remove(socket)
  66. async def test_a_user_removed_from_the_granting_group_loses_the_socket(async_client: AsyncClient, admin):
  67. group, carol = await _carol_in_group(async_client, admin)
  68. socket = _socket("carol", carol)
  69. ws_manager.active_connections.append(socket)
  70. try:
  71. response = await async_client.delete(f"/api/v1/groups/{group}/users/{carol}", headers=admin)
  72. assert response.status_code == 204, response.text
  73. socket.close.assert_awaited_once_with(code=4401)
  74. finally:
  75. if socket in ws_manager.active_connections:
  76. ws_manager.active_connections.remove(socket)
  77. async def _connect(principal: tuple):
  78. from backend.app.api.routes import websocket as ws_route
  79. socket = MagicMock()
  80. socket.state = SimpleNamespace()
  81. socket.accept = AsyncMock()
  82. socket.close = AsyncMock()
  83. socket.send_json = AsyncMock()
  84. socket.receive_json = AsyncMock(side_effect=WebSocketDisconnect())
  85. with (
  86. patch.object(ws_route, "is_auth_enabled", AsyncMock(return_value=True)),
  87. patch.object(ws_route, "verify_websocket_token_principal", AsyncMock(return_value=principal)),
  88. patch.object(ws_route, "principal_printer_scope", AsyncMock(return_value=ALL_PRINTERS)),
  89. patch.object(ws_route, "async_session", database.async_session),
  90. ):
  91. await ws_route.websocket_endpoint(socket, token="t")
  92. return socket
  93. async def test_a_token_whose_user_lost_the_permission_is_refused(async_client: AsyncClient, admin):
  94. """Minted while the user had it, still valid for an hour."""
  95. group, _ = await _carol_in_group(async_client, admin)
  96. await async_client.patch(f"/api/v1/groups/{group}", headers=admin, json={"permissions": ["printers:read"]})
  97. socket = await _connect(("carol", None))
  98. socket.close.assert_awaited_once_with(code=4401)
  99. socket.accept.assert_not_awaited()
  100. async def test_a_token_of_a_key_without_status_access_is_refused(async_client: AsyncClient, admin, db_session):
  101. key = APIKey(name="k", key_hash="h", key_prefix="bb_nostat", can_read_status=False)
  102. db_session.add(key)
  103. await db_session.commit()
  104. socket = await _connect(("", key.id))
  105. socket.close.assert_awaited_once_with(code=4401)
  106. socket.accept.assert_not_awaited()
  107. async def test_a_key_losing_status_access_closes_its_sockets(async_client: AsyncClient, admin, db_session):
  108. key = APIKey(name="k", key_hash="h", key_prefix="bb_stat")
  109. db_session.add(key)
  110. await db_session.commit()
  111. socket = _socket(None, None, key_id=key.id, prefix="bb_stat")
  112. ws_manager.active_connections.append(socket)
  113. try:
  114. with patch("backend.app.core.database.async_session", database.async_session):
  115. response = await async_client.patch(
  116. f"/api/v1/api-keys/{key.id}", headers=admin, json={"can_read_status": False}
  117. )
  118. assert response.status_code == 200, response.text
  119. socket.close.assert_awaited_once_with(code=4401)
  120. finally:
  121. if socket in ws_manager.active_connections:
  122. ws_manager.active_connections.remove(socket)
  123. async def test_the_permission_taken_while_a_socket_joins_closes_it(async_client: AsyncClient, admin):
  124. """The group change's refresh ran before the socket was listed."""
  125. from backend.app.api.routes import websocket as ws_route
  126. await _carol_in_group(async_client, admin)
  127. calls = []
  128. real = ws_route.principal_may_connect
  129. async def held_at_connect_then_gone(db, username, key_id):
  130. calls.append(1)
  131. return await real(db, username, key_id) if len(calls) == 1 else False
  132. with patch.object(ws_route, "principal_may_connect", held_at_connect_then_gone):
  133. socket = await _connect(("carol", None))
  134. socket.close.assert_awaited_once_with(code=4401)
  135. assert socket not in ws_manager.active_connections