Files
odysseus/tests/test_auth_event_loop.py
Collin 11c2931efb Run auth password work off the event loop
* fix: run bcrypt off the event loop in auth routes

The auth routes are async, but each bcrypt call ran synchronously on the event
loop. bcrypt (checkpw/hashpw) is intentionally CPU-expensive (~100-300 ms), so
every login / signup / setup / change-password froze the single event loop for
that window, stalling all other in-flight requests (chat streams, polling, ...).

/api/auth/login is the worst case: it is reachable unauthenticated, runs bcrypt
twice (verify_password, then create_session re-verifies), and is rate-limited
only per-IP. A burst of login attempts serializes the whole server — cheap
DoS amplification.

Offload the bcrypt-bearing AuthManager calls (setup, signup/create_user,
login's verify_password + create_session, change_password) via
asyncio.to_thread, matching how the codebase already offloads blocking work
(e.g. src/builtin_actions._run_subprocess, email summarize). The event loop
stays responsive while bcrypt runs on a worker thread.

Add tests/test_auth_event_loop.py: asserts login runs verify_password and
create_session on a worker thread, not the loop thread. Fails if those calls
are awaited inline again.

* test: isolate auth event-loop test from heavy core/* import chain

The regression test imported routes.auth_routes, which pulls in
core.auth and so triggers core/__init__.py — transitively importing
src.llm_core (hangs at import under the project venv) and the SQLAlchemy
declarative models (metaclass error on a bare core.database import / under
the conftest sqlalchemy stubs). Reported by the maintainer: collection
failed on system Python and hung under the venv.

Stub core.auth/core.database before the import, mirroring the existing
_ensure_stub pattern in test_auth_regressions.py and test_null_owner_gates.py.
AuthManager is only a type hint here and the handler is exercised with a
MagicMock, so no real core machinery is needed. Test now imports cleanly
and passes in <0.3s without bcrypt/sqlalchemy installed.
2026-06-01 23:12:12 +09:00

114 lines
4.5 KiB
Python

"""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 threading
from types import SimpleNamespace
from unittest.mock import MagicMock
# Stub `core.auth` / `core.database` before importing the route module.
# `routes.auth_routes` does `from core.auth import AuthManager`, and importing
# any `core.*` submodule first runs `core/__init__.py`, which transitively
# imports `src.llm_core` (hangs at import under the project venv) and the
# SQLAlchemy declarative models (metaclass blows up on a bare `core.database`
# import / under the conftest's `sqlalchemy.*` MagicMock stubs). We only need
# `AuthManager` as a type hint here — the handler is exercised with a MagicMock
# — so stub the heavy modules out. Same trick as test_auth_regressions.py /
# test_null_owner_gates.py.
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
_ensure_stub("core.database", SessionLocal=MagicMock())
_ensure_stub("core.auth", AuthManager=MagicMock())
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_runs_bcrypt_off_the_event_loop():
loop_thread = threading.get_ident()
seen = {}
auth = MagicMock()
def _verify(username, password):
seen["verify_thread"] = threading.get_ident()
return True
def _create(username, password):
seen["create_thread"] = threading.get_ident()
return "tok-123"
auth.verify_password.side_effect = _verify
auth.totp_enabled.return_value = False
auth.create_session.side_effect = _create
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()
# The whole point: the expensive bcrypt calls must NOT run on the loop thread.
assert seen["verify_thread"] != loop_thread, "verify_password ran on the event-loop thread"
assert seen["create_thread"] != loop_thread, "create_session ran on the event-loop thread"