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 10:12:12 -04:00
|
|
|
"""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
|
Scope core.* module stubs to the test, not the module (#1513)
Three test files (test_auth_regressions, test_auth_event_loop,
test_null_owner_gates) install stubs for core.database / core.auth /
src.endpoint_resolver at module-import time, so they outlive the
file and are still present in sys.modules when later-collected test
files try to import the real modules. The stubs are minimal (a
handful of MagicMock attrs) so the import chain that follows fails
with ImportError on the very next real import.
test_companion_pairing also leaks, with a twist: its _DBStub
subclass returns a MagicMock for *any* attribute including dunders,
so the next test that does `from core.database import *` reads
`__all__` as a MagicMock and dies with 'Item in __all__ must be
str, not MagicMock'.
Move the stub installation into an autouse fixture per file and
register each stub with monkeypatch.setitem so sys.modules is
restored to its pre-test state on teardown. Tighten _DBStub to
refuse dunder names so __all__ stays undefined. _CAPTURED is
cleared per test so the mint-token assertions see a fresh dict.
Before: 3 test files fail at collection time (test_chat_image_routing,
test_context_compactor, test_webhook_ssrf_resilience). After: 0
collection errors. 1365/1370 pass, 1 skip, 4 unrelated pre-existing
failures (verified against origin/main baseline).
Out of scope: test_task_scheduler_session_delivery::
test_session_delivery_survives_empty_database also fails in the
full suite due to order-dependent state from a different test
file. That's a separate leak with a different root cause.
2026-06-03 06:23:40 +01:00
|
|
|
import pytest
|
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 10:12:12 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
Scope core.* module stubs to the test, not the module (#1513)
Three test files (test_auth_regressions, test_auth_event_loop,
test_null_owner_gates) install stubs for core.database / core.auth /
src.endpoint_resolver at module-import time, so they outlive the
file and are still present in sys.modules when later-collected test
files try to import the real modules. The stubs are minimal (a
handful of MagicMock attrs) so the import chain that follows fails
with ImportError on the very next real import.
test_companion_pairing also leaks, with a twist: its _DBStub
subclass returns a MagicMock for *any* attribute including dunders,
so the next test that does `from core.database import *` reads
`__all__` as a MagicMock and dies with 'Item in __all__ must be
str, not MagicMock'.
Move the stub installation into an autouse fixture per file and
register each stub with monkeypatch.setitem so sys.modules is
restored to its pre-test state on teardown. Tighten _DBStub to
refuse dunder names so __all__ stays undefined. _CAPTURED is
cleared per test so the mint-token assertions see a fresh dict.
Before: 3 test files fail at collection time (test_chat_image_routing,
test_context_compactor, test_webhook_ssrf_resilience). After: 0
collection errors. 1365/1370 pass, 1 skip, 4 unrelated pre-existing
failures (verified against origin/main baseline).
Out of scope: test_task_scheduler_session_delivery::
test_session_delivery_survives_empty_database also fails in the
full suite due to order-dependent state from a different test
file. That's a separate leak with a different root cause.
2026-06-03 06:23:40 +01:00
|
|
|
@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)
|
|
|
|
|
|
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 10:12:12 -04:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 05:48:59 +09:00
|
|
|
def test_login_offloads_bcrypt_bearing_calls(monkeypatch):
|
|
|
|
|
calls = []
|
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 10:12:12 -04:00
|
|
|
auth = MagicMock()
|
|
|
|
|
|
2026-06-02 05:48:59 +09:00
|
|
|
async def fake_to_thread(fn, *args, **kwargs):
|
|
|
|
|
calls.append(fn)
|
|
|
|
|
return fn(*args, **kwargs)
|
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 10:12:12 -04:00
|
|
|
|
2026-06-02 05:48:59 +09:00
|
|
|
monkeypatch.setattr("routes.auth_routes.asyncio.to_thread", fake_to_thread)
|
|
|
|
|
auth.verify_password.return_value = True
|
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 10:12:12 -04:00
|
|
|
auth.totp_enabled.return_value = False
|
2026-06-02 05:48:59 +09:00
|
|
|
auth.create_session.return_value = "tok-123"
|
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 10:12:12 -04:00
|
|
|
|
|
|
|
|
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()
|
2026-06-02 05:48:59 +09:00
|
|
|
# The whole point: the expensive bcrypt-bearing calls go through
|
|
|
|
|
# asyncio.to_thread rather than running inline in the request coroutine.
|
|
|
|
|
assert calls == [auth.verify_password, auth.create_session]
|