test_session_end_closes_sockets.py 23 KB

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