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())