| """Pin that the login handler keeps bcrypt off the event loop. |
| |
| `/api/auth/login` is an `async def` and is reachable unauthenticated. bcrypt |
| (`checkpw`/`hashpw`) is deliberately CPU-expensive (~100-300 ms). Running it |
| directly in the coroutine blocks the single event loop for that whole window, |
| freezing every other in-flight request (chat streams, polling, ...). Because |
| the endpoint is unauthenticated and rate-limited only per-IP, a burst of login |
| attempts serializes the whole server — a cheap DoS-amplification vector. |
| |
| The fix offloads the bcrypt-bearing AuthManager calls via asyncio.to_thread. |
| This test asserts those calls run on a worker thread, not the loop thread; it |
| fails if they are awaited inline again. |
| """ |
| import os |
| import sys |
| import types |
| import asyncio |
| import pytest |
| from types import SimpleNamespace |
| from unittest.mock import MagicMock |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| def _ensure_stub(name: str, **attrs): |
| """Create or augment a stub module, wiring it onto a stubbed parent package. |
| |
| Augments existing entries because an earlier-run test may have already |
| stubbed the same module with a different attribute set. The parent package |
| gets `__path__` pointed at the real on-disk dir so genuinely-unstubbed |
| submodules still load normally, while `core/__init__.py` itself is bypassed |
| (the package is already in `sys.modules`).""" |
| if "." in name: |
| parent_name, _, child_name = name.rpartition(".") |
| if parent_name not in sys.modules: |
| parent = types.ModuleType(parent_name) |
| real_path = os.path.join( |
| os.path.dirname(os.path.dirname(os.path.abspath(__file__))), |
| *parent_name.split("."), |
| ) |
| parent.__path__ = [real_path] if os.path.isdir(real_path) else [] |
| sys.modules[parent_name] = parent |
| else: |
| parent = sys.modules[parent_name] |
| else: |
| parent = None |
| child_name = None |
|
|
| mod = sys.modules.get(name) |
| if mod is None: |
| mod = types.ModuleType(name) |
| sys.modules[name] = mod |
| for k, v in attrs.items(): |
| if not hasattr(mod, k): |
| setattr(mod, k, v) |
| if parent is not None and not hasattr(parent, child_name): |
| setattr(parent, child_name, mod) |
| return mod |
|
|
|
|
| @pytest.fixture(autouse=True) |
| def _event_loop_stubs(monkeypatch): |
| db = _ensure_stub("core.database", SessionLocal=MagicMock()) |
| auth = _ensure_stub("core.auth", AuthManager=MagicMock()) |
| monkeypatch.setitem(sys.modules, "core.database", db) |
| monkeypatch.setitem(sys.modules, "core.auth", auth) |
|
|
|
|
| from routes.auth_routes import setup_auth_routes, LoginRequest |
|
|
|
|
| def _login_endpoint(auth_manager): |
| router = setup_auth_routes(auth_manager) |
| for r in router.routes: |
| if getattr(r, "path", None) == "/api/auth/login" and "POST" in getattr(r, "methods", set()): |
| return r.endpoint |
| raise AssertionError("login route not found on the auth router") |
|
|
|
|
| def test_login_offloads_bcrypt_bearing_calls(monkeypatch): |
| calls = [] |
| auth = MagicMock() |
|
|
| async def fake_to_thread(fn, *args, **kwargs): |
| calls.append(fn) |
| return fn(*args, **kwargs) |
|
|
| monkeypatch.setattr("routes.auth_routes.asyncio.to_thread", fake_to_thread) |
| auth.verify_password.return_value = True |
| auth.totp_enabled.return_value = False |
| auth.create_session.return_value = "tok-123" |
|
|
| login = _login_endpoint(auth) |
|
|
| request = SimpleNamespace(client=SimpleNamespace(host="203.0.113.7"), cookies={}) |
| response = MagicMock() |
| body = LoginRequest(username="alice", password="hunter2", remember=True) |
|
|
| result = asyncio.run(login(body=body, request=request, response=response)) |
|
|
| assert result["ok"] is True |
| auth.verify_password.assert_called_once() |
| auth.create_session.assert_called_once() |
| |
| |
| assert calls == [auth.verify_password, auth.create_session] |
|
|