test_session_end_closes_sockets.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. """Whatever ends a user's sessions also closes their open WebSockets.
  2. A socket is authenticated once, at connect. A password change or reset, an
  3. admin removing 2FA, or a backup restore makes every earlier JWT invalid, but a
  4. dashboard someone else had open on the account kept receiving its printers'
  5. status and events until the socket dropped.
  6. """
  7. import sqlite3
  8. from datetime import datetime, timedelta, timezone
  9. from unittest.mock import AsyncMock, patch
  10. import pytest
  11. from httpx import AsyncClient
  12. from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
  13. pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
  14. CLOSE_USER = "backend.app.core.websocket.ws_manager.close_user"
  15. @pytest.fixture
  16. async def admin(async_client: AsyncClient) -> dict:
  17. await async_client.post(
  18. "/api/v1/auth/setup",
  19. json={"auth_enabled": True, "admin_username": "sockadmin", "admin_password": "AdminPass1!"},
  20. )
  21. login = await async_client.post("/api/v1/auth/login", json={"username": "sockadmin", "password": "AdminPass1!"})
  22. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  23. async def _user(client: AsyncClient, admin: dict) -> int:
  24. response = await client.post(
  25. "/api/v1/users/", headers=admin, json={"username": "carol", "password": "Userpass1!", "role": "user"}
  26. )
  27. assert response.status_code == 201, response.text
  28. return response.json()["id"]
  29. async def test_changing_ones_password_closes_ones_sockets(async_client: AsyncClient, admin):
  30. carol = await _user(async_client, admin)
  31. login = await async_client.post("/api/v1/auth/login", json={"username": "carol", "password": "Userpass1!"})
  32. headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
  33. with patch(CLOSE_USER, AsyncMock()) as close:
  34. response = await async_client.post(
  35. "/api/v1/users/me/change-password",
  36. headers=headers,
  37. json={"current_password": "Userpass1!", "new_password": "Newpass12!"},
  38. )
  39. assert response.status_code == 200, response.text
  40. close.assert_awaited_once_with(carol)
  41. async def test_a_reset_link_closes_the_sockets(async_client: AsyncClient, admin, db_session):
  42. carol = await _user(async_client, admin)
  43. db_session.add(
  44. AuthEphemeralToken(
  45. token="reset-carol",
  46. token_type=TokenType.PASSWORD_RESET,
  47. username="carol",
  48. expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
  49. )
  50. )
  51. await db_session.commit()
  52. with patch(CLOSE_USER, AsyncMock()) as close:
  53. response = await async_client.post(
  54. "/api/v1/auth/forgot-password/confirm", json={"token": "reset-carol", "new_password": "Newpass12!"}
  55. )
  56. assert response.status_code == 200, response.text
  57. close.assert_awaited_once_with(carol)
  58. async def test_an_admin_removing_2fa_closes_the_sockets(async_client: AsyncClient, admin):
  59. carol = await _user(async_client, admin)
  60. with patch(CLOSE_USER, AsyncMock()) as close:
  61. response = await async_client.request(
  62. "DELETE", f"/api/v1/auth/2fa/admin/{carol}", headers=admin, json={"admin_password": "AdminPass1!"}
  63. )
  64. assert response.status_code == 200, response.text
  65. close.assert_awaited_once_with(carol)
  66. def _socket(user_id=None, key_id=None):
  67. from types import SimpleNamespace
  68. from unittest.mock import MagicMock
  69. socket = MagicMock()
  70. socket.state = SimpleNamespace(
  71. bambuddy_principal_user_id=user_id,
  72. bambuddy_scope_principal=("someone" if user_id else ("" if key_id else None), key_id),
  73. )
  74. socket.close = AsyncMock()
  75. return socket
  76. @pytest.mark.sqlite_only # switches the app to its own SQLite database file
  77. @pytest.mark.parametrize("auth_on", [True, False])
  78. async def test_a_backup_restore_closes_the_signed_in_sockets(async_client: AsyncClient, monkeypatch, tmp_path, auth_on):
  79. """With auth on in the restored data, sessions end, and what the restored
  80. data no longer allows goes. With auth off nothing is closed: no sign-in is
  81. needed any more (the restore still invalidates every session), and everyone
  82. may see every printer."""
  83. from backend.app.api.routes.settings import create_backup_zip
  84. from backend.app.core.config import settings as app_settings
  85. from backend.app.core.websocket import ws_manager
  86. db_path = tmp_path / "bambuddy.db"
  87. with sqlite3.connect(db_path) as db:
  88. db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
  89. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  90. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  91. monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
  92. zip_path, _ = await create_backup_zip(output_path=tmp_path)
  93. user, key, anonymous = _socket(user_id=5), _socket(key_id=3), _socket()
  94. ws_manager.active_connections.extend([user, key, anonymous])
  95. try:
  96. with (
  97. patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
  98. patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
  99. patch("backend.app.core.database.init_db", new_callable=AsyncMock),
  100. patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
  101. patch("backend.app.services.print_scheduler.scheduler.stop"),
  102. patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
  103. patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
  104. patch("backend.app.api.routes.settings.is_auth_enabled", AsyncMock(return_value=auth_on)),
  105. ):
  106. response = await async_client.post(
  107. "/api/v1/settings/restore", files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")}
  108. )
  109. assert response.status_code == 200, response.text
  110. if auth_on:
  111. # The user's session ended, and the anonymous one has every printer.
  112. # (A key missing from the restored data goes at the scope refresh,
  113. # see test_api_key_id_reuse.)
  114. for socket in (user, anonymous):
  115. socket.close.assert_awaited_once_with(code=4401)
  116. else: # auth off: everyone may see every printer, and no sign-in would reopen them
  117. for socket in (user, key, anonymous):
  118. socket.close.assert_not_awaited()
  119. finally:
  120. for socket in (user, key, anonymous):
  121. if socket in ws_manager.active_connections:
  122. ws_manager.active_connections.remove(socket)
  123. @pytest.mark.sqlite_only # switches the app to its own SQLite database file
  124. async def test_a_restore_that_turns_authentication_on_closes_the_anonymous_sockets(
  125. async_client: AsyncClient, monkeypatch, tmp_path
  126. ):
  127. """The restore replaces the auth setting too: dashboards opened while it was
  128. off would keep every printer on an install that now needs a sign-in."""
  129. from backend.app.api.routes.settings import create_backup_zip
  130. from backend.app.core.config import settings as app_settings
  131. from backend.app.core.websocket import ws_manager
  132. db_path = tmp_path / "bambuddy.db"
  133. with sqlite3.connect(db_path) as db:
  134. db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
  135. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  136. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  137. monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
  138. zip_path, _ = await create_backup_zip(output_path=tmp_path)
  139. anonymous = _socket()
  140. ws_manager.active_connections.append(anonymous)
  141. try:
  142. with (
  143. patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
  144. patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
  145. patch("backend.app.core.database.init_db", new_callable=AsyncMock),
  146. patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
  147. patch("backend.app.services.print_scheduler.scheduler.stop"),
  148. patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
  149. patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
  150. patch("backend.app.api.routes.settings.is_auth_enabled", AsyncMock(return_value=True), create=True),
  151. ):
  152. response = await async_client.post(
  153. "/api/v1/settings/restore", files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")}
  154. )
  155. assert response.status_code == 200, response.text
  156. anonymous.close.assert_awaited_once_with(code=4401)
  157. finally:
  158. if anonymous in ws_manager.active_connections:
  159. ws_manager.active_connections.remove(anonymous)
  160. async def _ws_tokens(db, username: str) -> list[str]:
  161. from sqlalchemy import select
  162. rows = await db.execute(
  163. select(AuthEphemeralToken.token_type)
  164. .where(AuthEphemeralToken.username == username, AuthEphemeralToken.token_type != "revoked_jti")
  165. .execution_options(populate_existing=True)
  166. )
  167. return list(rows.scalars().all())
  168. async def _give_tokens(db, username: str) -> None:
  169. expires = datetime.now(timezone.utc) + timedelta(hours=1)
  170. db.add_all(
  171. [
  172. AuthEphemeralToken(token=f"{kind}-{username}", token_type=kind, username=username, expires_at=expires)
  173. for kind in ("websocket", "camera_stream")
  174. ]
  175. )
  176. await db.commit()
  177. async def test_change_password_drops_the_tokens_a_closed_dashboard_would_reconnect_with(
  178. async_client: AsyncClient, admin, db_session
  179. ):
  180. """A WebSocket token is reusable for an hour: closing the socket alone let
  181. whoever held it connect again straight away."""
  182. await _user(async_client, admin)
  183. login = await async_client.post("/api/v1/auth/login", json={"username": "carol", "password": "Userpass1!"})
  184. await _give_tokens(db_session, "carol")
  185. response = await async_client.post(
  186. "/api/v1/users/me/change-password",
  187. headers={"Authorization": f"Bearer {login.json()['access_token']}"},
  188. json={"current_password": "Userpass1!", "new_password": "Newpass12!"},
  189. )
  190. assert response.status_code == 200, response.text
  191. assert await _ws_tokens(db_session, "carol") == []
  192. async def test_a_reset_link_drops_them_too(async_client: AsyncClient, admin, db_session):
  193. await _user(async_client, admin)
  194. await _give_tokens(db_session, "carol")
  195. db_session.add(
  196. AuthEphemeralToken(
  197. token="reset-carol2",
  198. token_type=TokenType.PASSWORD_RESET,
  199. username="carol",
  200. expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
  201. )
  202. )
  203. await db_session.commit()
  204. response = await async_client.post(
  205. "/api/v1/auth/forgot-password/confirm", json={"token": "reset-carol2", "new_password": "Newpass12!"}
  206. )
  207. assert response.status_code == 200, response.text
  208. assert await _ws_tokens(db_session, "carol") == []
  209. async def test_an_admin_removing_2fa_drops_them_too(async_client: AsyncClient, admin, db_session):
  210. carol = await _user(async_client, admin)
  211. await _give_tokens(db_session, "carol")
  212. response = await async_client.request(
  213. "DELETE", f"/api/v1/auth/2fa/admin/{carol}", headers=admin, json={"admin_password": "AdminPass1!"}
  214. )
  215. assert response.status_code == 200, response.text
  216. assert await _ws_tokens(db_session, "carol") == []
  217. async def test_deactivating_a_user_closes_their_sockets_and_drops_their_tokens(
  218. async_client: AsyncClient, admin, db_session
  219. ):
  220. """Their printers go at the next scope refresh, but events of no printer
  221. (inventory, SpoolBuddy tags, announcements) still reached them."""
  222. carol = await _user(async_client, admin)
  223. await _give_tokens(db_session, "carol")
  224. with patch(CLOSE_USER, AsyncMock()) as close:
  225. response = await async_client.patch(f"/api/v1/users/{carol}", headers=admin, json={"is_active": False})
  226. assert response.status_code == 200, response.text
  227. close.assert_awaited_once_with(carol)
  228. assert await _ws_tokens(db_session, "carol") == []
  229. async def test_turning_authentication_on_closes_the_anonymous_sockets(async_client: AsyncClient):
  230. """Opened while auth was off, they had every printer and kept it."""
  231. from backend.app.core.websocket import ws_manager
  232. anonymous = _socket()
  233. ws_manager.active_connections.append(anonymous)
  234. try:
  235. response = await async_client.post(
  236. "/api/v1/auth/setup",
  237. json={"auth_enabled": True, "admin_username": "sockadmin3", "admin_password": "AdminPass1!"},
  238. )
  239. assert response.status_code == 200, response.text
  240. anonymous.close.assert_awaited_once_with(code=4401)
  241. finally:
  242. if anonymous in ws_manager.active_connections:
  243. ws_manager.active_connections.remove(anonymous)
  244. async def test_deactivating_a_user_also_ends_their_keys_connections(async_client: AsyncClient, admin, db_session):
  245. """REST already refuses a deactivated owner's key; its open dashboards kept
  246. receiving the events of no printer, and its tokens opened new ones."""
  247. from backend.app.core.websocket import ws_manager
  248. from backend.app.models.api_key import APIKey
  249. carol = await _user(async_client, admin)
  250. key = APIKey(name="kiosk", key_hash="h", key_prefix="bb_kiosk", user_id=carol)
  251. db_session.add(key)
  252. await db_session.commit()
  253. db_session.add(
  254. AuthEphemeralToken(
  255. token="ws-kiosk",
  256. token_type="websocket",
  257. username="",
  258. api_key_id=key.id,
  259. expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
  260. )
  261. )
  262. await db_session.commit()
  263. socket = _socket(key_id=key.id)
  264. ws_manager.active_connections.append(socket)
  265. try:
  266. response = await async_client.patch(f"/api/v1/users/{carol}", headers=admin, json={"is_active": False})
  267. assert response.status_code == 200, response.text
  268. socket.close.assert_awaited_once_with(code=4401)
  269. from sqlalchemy import select
  270. left = await db_session.execute(
  271. select(AuthEphemeralToken.token)
  272. .where(AuthEphemeralToken.api_key_id == key.id)
  273. .execution_options(populate_existing=True)
  274. )
  275. assert left.scalars().all() == []
  276. finally:
  277. if socket in ws_manager.active_connections:
  278. ws_manager.active_connections.remove(socket)
  279. async def _key_with_socket(db, **fields):
  280. from backend.app.core.websocket import ws_manager
  281. from backend.app.models.api_key import APIKey
  282. key = APIKey(name="kiosk", key_hash="h", key_prefix="bb_kiosk2", **fields)
  283. db.add(key)
  284. await db.commit()
  285. db.add(
  286. AuthEphemeralToken(
  287. token=f"ws-key-{key.id}",
  288. token_type="websocket",
  289. username="",
  290. api_key_id=key.id,
  291. expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
  292. )
  293. )
  294. await db.commit()
  295. socket = _socket(key_id=key.id)
  296. socket.state.bambuddy_api_key_prefix = "bb_kiosk2"
  297. ws_manager.active_connections.append(socket)
  298. return key.id, socket
  299. async def test_disabling_a_key_ends_its_connections(async_client: AsyncClient, admin, db_session):
  300. """REST refuses it from then on; its dashboards kept the events of no printer
  301. and its token reconnected for an hour."""
  302. from sqlalchemy import select
  303. from backend.app.core.websocket import ws_manager
  304. key, socket = await _key_with_socket(db_session)
  305. try:
  306. response = await async_client.patch(f"/api/v1/api-keys/{key}", headers=admin, json={"enabled": False})
  307. assert response.status_code == 200, response.text
  308. socket.close.assert_awaited_once_with(code=4401)
  309. left = await db_session.execute(
  310. select(AuthEphemeralToken.token)
  311. .where(AuthEphemeralToken.api_key_id == key)
  312. .execution_options(populate_existing=True)
  313. )
  314. assert left.scalars().all() == []
  315. finally:
  316. if socket in ws_manager.active_connections:
  317. ws_manager.active_connections.remove(socket)
  318. async def test_a_scope_refresh_closes_the_connections_of_an_expired_key(async_client: AsyncClient, admin, db_session):
  319. from backend.app.core import database
  320. from backend.app.core.websocket import ws_manager
  321. key, socket = await _key_with_socket(db_session, expires_at=datetime.now(timezone.utc) - timedelta(minutes=1))
  322. try:
  323. with patch("backend.app.core.database.async_session", database.async_session):
  324. await ws_manager.refresh_printer_scopes()
  325. socket.close.assert_awaited_once_with(code=4401)
  326. finally:
  327. if socket in ws_manager.active_connections:
  328. ws_manager.active_connections.remove(socket)
  329. async def _connect(principal: tuple, verify_results: list):
  330. from types import SimpleNamespace
  331. from unittest.mock import MagicMock
  332. from fastapi import WebSocketDisconnect
  333. from backend.app.api.routes import websocket as ws_route
  334. from backend.app.core import database
  335. from backend.app.core.printer_scope import ALL_PRINTERS
  336. socket = MagicMock()
  337. socket.state = SimpleNamespace()
  338. socket.accept = AsyncMock()
  339. socket.close = AsyncMock()
  340. socket.send_json = AsyncMock()
  341. socket.receive_json = AsyncMock(side_effect=WebSocketDisconnect())
  342. with (
  343. patch.object(ws_route, "is_auth_enabled", AsyncMock(return_value=True)),
  344. patch.object(ws_route, "verify_websocket_token_principal", AsyncMock(side_effect=verify_results)),
  345. patch.object(ws_route, "principal_printer_scope", AsyncMock(return_value=ALL_PRINTERS)),
  346. patch.object(ws_route, "async_session", database.async_session),
  347. ):
  348. await ws_route.websocket_endpoint(socket, token="t")
  349. return socket
  350. async def test_a_websocket_of_a_disabled_key_is_refused(async_client: AsyncClient, db_session):
  351. from backend.app.core.websocket import ws_manager
  352. from backend.app.models.api_key import APIKey
  353. key = APIKey(name="off", key_hash="h", key_prefix="bb_off", enabled=False)
  354. db_session.add(key)
  355. await db_session.commit()
  356. socket = await _connect(("", key.id), [("", key.id)] * 2)
  357. socket.close.assert_awaited_once_with(code=4401)
  358. socket.accept.assert_not_awaited()
  359. assert socket not in ws_manager.active_connections
  360. async def test_a_session_ending_while_a_websocket_joins_closes_it(async_client: AsyncClient, admin):
  361. """The password change drops the tokens, then closes the user's sockets: one
  362. that checked its token before and joins after is missed by the close."""
  363. from backend.app.core.websocket import ws_manager
  364. await _user(async_client, admin)
  365. socket = await _connect(("carol", None), [("carol", None), None]) # gone by the second check
  366. socket.close.assert_awaited_once_with(code=4401)
  367. assert socket not in ws_manager.active_connections
  368. async def test_turning_authentication_on_drops_the_tokens_minted_while_it_was_off(
  369. async_client: AsyncClient, db_session
  370. ):
  371. """Every tab mints one with auth off, naming nobody: it would reconnect the
  372. closed dashboard straight away (to the events of no printer)."""
  373. from sqlalchemy import select
  374. expires = datetime.now(timezone.utc) + timedelta(hours=1)
  375. db_session.add_all(
  376. [
  377. AuthEphemeralToken(token="anon-ws", token_type="websocket", username="", expires_at=expires),
  378. AuthEphemeralToken(token="anon-cam", token_type="camera_stream", username="", expires_at=expires),
  379. AuthEphemeralToken(token="logged-out", token_type="revoked_jti", username="", expires_at=expires),
  380. ]
  381. )
  382. await db_session.commit()
  383. response = await async_client.post(
  384. "/api/v1/auth/setup",
  385. json={"auth_enabled": True, "admin_username": "sockadmin4", "admin_password": "AdminPass1!"},
  386. )
  387. assert response.status_code == 200, response.text
  388. left = await db_session.execute(select(AuthEphemeralToken.token).execution_options(populate_existing=True))
  389. assert sorted(left.scalars().all()) == ["logged-out"]
  390. @pytest.mark.sqlite_only # switches the app to its own SQLite database file
  391. async def test_a_restore_reads_the_restored_auth_setting_not_the_cached_one(
  392. async_client: AsyncClient, monkeypatch, tmp_path
  393. ):
  394. """The restore request itself has just cached 'auth on' for 30 s."""
  395. from backend.app.api.routes.settings import create_backup_zip
  396. from backend.app.core import auth
  397. from backend.app.core.config import settings as app_settings
  398. db_path = tmp_path / "bambuddy.db"
  399. with sqlite3.connect(db_path) as db:
  400. db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
  401. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  402. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  403. monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
  404. zip_path, _ = await create_backup_zip(output_path=tmp_path)
  405. calls = []
  406. real = auth.invalidate_auth_enabled_cache
  407. monkeypatch.setattr(auth, "invalidate_auth_enabled_cache", lambda: (calls.append(1), real())[1])
  408. with (
  409. patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
  410. patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
  411. patch("backend.app.core.database.init_db", new_callable=AsyncMock),
  412. patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
  413. patch("backend.app.services.print_scheduler.scheduler.stop"),
  414. patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
  415. patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
  416. ):
  417. response = await async_client.post(
  418. "/api/v1/settings/restore", files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")}
  419. )
  420. assert response.status_code == 200, response.text
  421. assert calls, "the auth setting cache was not cleared after the restore"
  422. async def test_authentication_turned_on_while_a_websocket_joins_closes_it(async_client: AsyncClient):
  423. """Admitted with auth off, it joined after the anonymous sockets were closed:
  424. nothing checked it again, and it kept every printer."""
  425. from types import SimpleNamespace
  426. from unittest.mock import MagicMock
  427. from fastapi import WebSocketDisconnect
  428. from backend.app.api.routes import websocket as ws_route
  429. from backend.app.core import database
  430. from backend.app.core.websocket import ws_manager
  431. socket = MagicMock()
  432. socket.state = SimpleNamespace()
  433. socket.accept = AsyncMock()
  434. socket.close = AsyncMock()
  435. socket.send_json = AsyncMock()
  436. socket.receive_json = AsyncMock(side_effect=WebSocketDisconnect())
  437. with (
  438. patch.object(ws_route, "is_auth_enabled", AsyncMock(side_effect=[False, True])), # on by the second read
  439. patch.object(ws_route, "async_session", database.async_session),
  440. ):
  441. await ws_route.websocket_endpoint(socket, token=None)
  442. socket.close.assert_awaited_once_with(code=4401)
  443. assert socket not in ws_manager.active_connections