From 491655de5c1189c24aea00fdeb9272e64a4279e6 Mon Sep 17 00:00:00 2001 From: gglamer Date: Sat, 26 Sep 2026 19:27:52 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=81=D0=BE=D0=BA=D0=B5=D1=82=D1=8B=20d?= =?UTF-8?q?ev=20=D0=BD=D0=B5=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BD=D0=BE=D0=B9=20=D0=91?= =?UTF-8?q?=D0=94=20=D0=BF=D0=BE=D0=BA=D0=BE=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/auth.py | 8 +++++++- backend/tests/test_auth_hardening.py | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 95ba71c..0725e20 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -72,7 +72,13 @@ async def _close_auth_state_unavailable(ws: WebSocket) -> None: def _close_unverified_sockets() -> None: - for sockets in list(_active_sockets.values()): + # Синтетический `dev` middleware пропускает без сверки поколения — и его + # сокеты не зависят от синхронизации с БД, иначе стенд без базы рвёт + # занятие через две секунды после старта. + synthetic = {"dev"} if get_settings().dev_auth_bypass else set() + for login, sockets in list(_active_sockets.items()): + if login in synthetic: + continue for ws, loop in list(sockets.items()): try: if not loop.is_closed(): diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py index 8e7057e..b35b247 100644 --- a/backend/tests/test_auth_hardening.py +++ b/backend/tests/test_auth_hardening.py @@ -674,3 +674,28 @@ def test_backup_error_redacts_database_credentials(monkeypatch): detail = admin._safe_backup_error(admin.backup_service.BackupError(f"bad DSN: {dsn}")) assert "supersecret" not in detail assert dsn not in detail + + +def test_stale_generation_sync_keeps_synthetic_dev_socket(): + """`dev` не хранится в users, и middleware не сверяет его поколение. + Закрывать его сокеты при недоступной БД — значит рвать занятие на + стенде без базы, хотя HTTP того же входа продолжает работать.""" + class FakeWebSocket: + closed_with = None + + async def close(self, code, reason): + self.closed_with = (code, reason) + + async def run(): + websocket = FakeWebSocket() + sockets = auth._active_sockets.setdefault("dev", weakref.WeakKeyDictionary()) + sockets[websocket] = asyncio.get_running_loop() + try: + auth._close_unverified_sockets() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert websocket.closed_with is None + finally: + sockets.pop(websocket, None) + + asyncio.run(run())