test_session_end_closes_sockets.py 23 KB

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