Merge branch 'feat/cluster-auth-handshake'
fix: неизвестный узлу логин проверяется разово, не отзывается сразу
This commit is contained in:
commit
1c91341273
3 changed files with 810 additions and 28 deletions
|
|
@ -135,6 +135,229 @@ def test_generation_sync_preserves_synthetic_dev_account(monkeypatch):
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_generation_snapshot_does_not_revoke_newly_resolved_login(monkeypatch):
|
||||
snapshot_read = asyncio.Event()
|
||||
release_snapshot = asyncio.Event()
|
||||
|
||||
class FakeResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, _query):
|
||||
snapshot_read.set()
|
||||
await release_snapshot.wait()
|
||||
return FakeResult()
|
||||
|
||||
async def scalar(self, _query):
|
||||
return 0
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
auth.prime_generations({})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
sync = asyncio.create_task(auth.sync_generations())
|
||||
await snapshot_read.wait()
|
||||
assert await auth._resolve_unknown_login("just-created") == 0
|
||||
release_snapshot.set()
|
||||
await sync
|
||||
assert auth._generations["just-created"] == 0
|
||||
assert "just-created" not in auth._vanished
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_generation_snapshot_does_not_restore_revoked_cookie(monkeypatch):
|
||||
snapshot_read = asyncio.Event()
|
||||
release_snapshot = asyncio.Event()
|
||||
|
||||
raised = []
|
||||
|
||||
class FakeResult:
|
||||
def all(self):
|
||||
return [("revoked", 0)]
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, query):
|
||||
if query.is_update:
|
||||
raised.append(query.compile().params)
|
||||
return None
|
||||
snapshot_read.set()
|
||||
await release_snapshot.wait()
|
||||
return FakeResult()
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
auth.prime_generations({"revoked": 0})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
sync = asyncio.create_task(auth.sync_generations())
|
||||
await snapshot_read.wait()
|
||||
auth.invalidate_login("revoked", 1)
|
||||
release_snapshot.set()
|
||||
await sync
|
||||
assert auth._generations["revoked"] == 1
|
||||
# В БД версия уже 1 (отзыв записан до invalidate); UPDATE «только вверх»
|
||||
# в реальной БД ничего не изменит, в снимке же он выглядит как отставание.
|
||||
assert [params["auth_version"] for params in raised] == [1]
|
||||
|
||||
|
||||
def _versioned_users_db(versions: dict[str, int]):
|
||||
"""Поддельная users: сверка читает версии, UPDATE поднимает их только вверх."""
|
||||
updates = []
|
||||
|
||||
class FakeResult:
|
||||
def all(self):
|
||||
return list(versions.items())
|
||||
|
||||
class FakeDb:
|
||||
async def execute(self, query):
|
||||
if query.is_update:
|
||||
params = query.compile().params
|
||||
login = next(v for k, v in params.items() if k.startswith("login"))
|
||||
target = params["auth_version"]
|
||||
updates.append((login, target))
|
||||
if versions.get(login, target) < target:
|
||||
versions[login] = target
|
||||
return None
|
||||
return FakeResult()
|
||||
|
||||
async def scalar(self, _query):
|
||||
raise AssertionError("login is cached, no one-shot lookup expected")
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
async def rollback(self):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
return updates, lambda: lambda: FakeSession()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_logout_revocation_is_written_to_db_so_nodes_converge(monkeypatch):
|
||||
"""Неудачный logout поднял версию только на этом узле (B). Сверка не
|
||||
откатывает отзыв, а записывает его в БД: соседний узел A догоняет, и вход
|
||||
на A после этого выдаёт cookie, которую B принимает."""
|
||||
login = "failed-logout-user"
|
||||
versions = {login: 4}
|
||||
updates, sessionmaker = _versioned_users_db(versions)
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
|
||||
auth.prime_generations({login: 4})
|
||||
try:
|
||||
auth.invalidate_login(login) # ветка except в logout: БД недоступна
|
||||
assert auth._generations[login] == 5
|
||||
|
||||
await auth.sync_generations()
|
||||
assert updates == [(login, 5)]
|
||||
assert versions[login] == 5, "revocation must reach the shared DB"
|
||||
assert auth._generations[login] == 5, "sync must not roll back a local revocation"
|
||||
|
||||
# Вход на A берёт версию из БД, и теперь она совпадает с версией B.
|
||||
assert versions[login] == auth._generations[login]
|
||||
|
||||
await auth.sync_generations()
|
||||
assert updates == [(login, 5)], "converged nodes must not write again"
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recreated_account_is_raised_to_the_node_revocation_version(monkeypatch):
|
||||
"""Учётку удалили (узел отозвал её, поколение +1) и создали заново с 0.
|
||||
Без записи в БД узел остался бы впереди навсегда."""
|
||||
login = "recreated-user"
|
||||
versions = {}
|
||||
updates, sessionmaker = _versioned_users_db(versions)
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
|
||||
auth.prime_generations({login: 2})
|
||||
try:
|
||||
await auth.sync_generations()
|
||||
assert auth._generations[login] == 3
|
||||
assert login in auth._vanished
|
||||
|
||||
versions[login] = 0 # оператор создал учётку заново
|
||||
await auth.sync_generations()
|
||||
assert updates == [(login, 3)]
|
||||
assert versions[login] == 3
|
||||
assert login not in auth._vanished
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
auth._vanished.discard(login)
|
||||
|
||||
|
||||
def test_login_raises_db_version_when_node_revocation_was_not_persisted(client, monkeypatch):
|
||||
"""Вход на узле, где отзыв не дошёл до БД: cookie получает версию узла,
|
||||
а БД поднимается до неё. Сброс к версии БД вернул бы силу старой cookie."""
|
||||
from app.config import get_settings
|
||||
|
||||
user = SimpleNamespace(
|
||||
login="ahead-login", auth_provider="local", password_hash="hash",
|
||||
blocked=False, role="instructor", full_name="Преподаватель",
|
||||
service=None, trainee_id=None, auth_version=1,
|
||||
)
|
||||
updates = []
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _statement):
|
||||
return user
|
||||
|
||||
async def execute(self, query):
|
||||
assert query.is_update
|
||||
updates.append(query.compile().params["auth_version"])
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def audit_ok(*_args, **_kwargs):
|
||||
return True
|
||||
|
||||
settings = get_settings().model_copy(update={"demo_no_db": False, "ldap_enabled": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
monkeypatch.setattr(auth, "verify_password", lambda *_args: True)
|
||||
monkeypatch.setattr(auth, "audit", audit_ok)
|
||||
auth._generations[user.login] = 2 # неудачный logout на этом узле
|
||||
try:
|
||||
response = client.post("/api/auth/login", json={"login": user.login, "password": "x"})
|
||||
assert response.status_code == 200
|
||||
assert updates == [2]
|
||||
assert auth._generations[user.login] == 2, "local revocation must not be rolled back"
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
finally:
|
||||
auth._generations.pop(user.login, None)
|
||||
|
||||
|
||||
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
|
||||
assert client.post("/api/auth/dev-token").status_code == 200
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
|
|
@ -410,6 +633,401 @@ def test_auth_middleware_fails_closed_when_generation_cache_is_stale_but_allows_
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_middleware_resolves_and_caches_login_unknown_to_this_node(monkeypatch):
|
||||
"""Cluster handshake (lct-42): a login another node just authenticated is
|
||||
fetched via a single SELECT rather than being treated as revoked, and the
|
||||
result is cached so a second request for it does not query again."""
|
||||
login = "peer-node-fresh-login"
|
||||
calls = []
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
calls.append(1)
|
||||
return 5
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class InnerApp:
|
||||
def __init__(self):
|
||||
self.called = 0
|
||||
|
||||
async def __call__(self, _scope, _receive, _send):
|
||||
self.called += 1
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
|
||||
def make_scope():
|
||||
return {
|
||||
"type": "http", "path": "/api/admin/users",
|
||||
"session": {
|
||||
"principal": {"login": login},
|
||||
"auth_instance": auth._INSTANCE,
|
||||
"auth_generation": 5,
|
||||
},
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(_message):
|
||||
return None
|
||||
|
||||
first = InnerApp()
|
||||
await auth.AuthVersionMiddleware(first)(make_scope(), receive, send)
|
||||
assert first.called == 1, "a fresh, valid epoch must reach the route"
|
||||
assert auth._generations[login] == 5
|
||||
assert calls == [1]
|
||||
|
||||
second = InnerApp()
|
||||
await auth.AuthVersionMiddleware(second)(make_scope(), receive, send)
|
||||
assert second.called == 1
|
||||
assert calls == [1], "cached epoch must not trigger a second SELECT"
|
||||
|
||||
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
auth._lookup_locks.pop(login, None)
|
||||
|
||||
|
||||
def test_middleware_rejects_login_missing_from_users_via_one_shot_query(monkeypatch):
|
||||
login = "peer-node-deleted-login"
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class InnerApp:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
async def __call__(self, _scope, _receive, _send):
|
||||
self.called = True
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
cookie_session = {
|
||||
"principal": {"login": login},
|
||||
"auth_instance": auth._INSTANCE,
|
||||
"auth_generation": 3,
|
||||
}
|
||||
scope = {"type": "http", "path": "/api/admin/users", "session": cookie_session}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(_message):
|
||||
return None
|
||||
|
||||
protected = InnerApp()
|
||||
await auth.AuthVersionMiddleware(protected)(scope, receive, send)
|
||||
assert protected.called, "route still runs but the session was cleared below"
|
||||
assert cookie_session == {}
|
||||
assert login not in auth._generations
|
||||
|
||||
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._lookup_locks.pop(login, None)
|
||||
|
||||
|
||||
def test_unknown_login_lookup_does_not_clobber_a_newer_local_revocation(monkeypatch):
|
||||
"""A stale SELECT reply landing after a concurrent local revoke must not
|
||||
resurrect the revoked cookie's epoch (regression: lock only deduplicates
|
||||
concurrent lookups, it does not order a lookup against a write)."""
|
||||
login = "peer-node-race-login"
|
||||
started = asyncio.Event()
|
||||
resume = asyncio.Event()
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
started.set()
|
||||
await resume.wait()
|
||||
return 0 # the epoch as it stood before the concurrent revoke below
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
task = asyncio.ensure_future(auth._resolve_unknown_login(login))
|
||||
await started.wait() # the SELECT is in flight, holding the login's lock
|
||||
auth.invalidate_login(login, 1) # a local logout/edit races the reply
|
||||
resume.set()
|
||||
result = await task
|
||||
assert result == 1, "the newer local revocation must win over the stale SELECT reply"
|
||||
assert auth._generations[login] == 1
|
||||
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
auth._lookup_locks.pop(login, None)
|
||||
|
||||
|
||||
def test_unknown_login_lookup_deduplicates_concurrent_requests_into_one_select(monkeypatch):
|
||||
login = "peer-node-concurrent-login"
|
||||
calls = []
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
calls.append(1)
|
||||
await asyncio.sleep(0.01) # widen the window for a racing second caller
|
||||
return 9
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
results = await asyncio.gather(
|
||||
auth._resolve_unknown_login(login),
|
||||
auth._resolve_unknown_login(login),
|
||||
auth._resolve_unknown_login(login),
|
||||
)
|
||||
assert results == [9, 9, 9]
|
||||
assert calls == [1], "concurrent lookups for one login must issue a single SELECT"
|
||||
assert login not in auth._lookup_locks, "lock entry must not outlive its waiters"
|
||||
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
auth._lookup_locks.pop(login, None)
|
||||
|
||||
|
||||
def test_unknown_login_lookup_fails_closed_when_database_is_unreachable(monkeypatch):
|
||||
login = "peer-node-db-outage-login"
|
||||
|
||||
class BrokenSession:
|
||||
async def __aenter__(self):
|
||||
raise OSError("database unavailable")
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class InnerApp:
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
async def __call__(self, _scope, _receive, _send):
|
||||
self.called = True
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
cookie_session = {
|
||||
"principal": {"login": login},
|
||||
"auth_instance": auth._INSTANCE,
|
||||
"auth_generation": 0,
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
http_messages = []
|
||||
|
||||
async def http_send(message):
|
||||
http_messages.append(message)
|
||||
|
||||
http_app = InnerApp()
|
||||
await auth.AuthVersionMiddleware(http_app)(
|
||||
{"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
|
||||
receive, http_send,
|
||||
)
|
||||
assert not http_app.called
|
||||
assert http_messages[0]["status"] == 503
|
||||
|
||||
ws_messages = []
|
||||
|
||||
async def ws_send(message):
|
||||
ws_messages.append(message)
|
||||
|
||||
ws_app = InnerApp()
|
||||
await auth.AuthVersionMiddleware(ws_app)(
|
||||
{"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
|
||||
receive, ws_send,
|
||||
)
|
||||
assert not ws_app.called
|
||||
assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
|
||||
|
||||
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: BrokenSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._lookup_locks.pop(login, None)
|
||||
|
||||
|
||||
def test_unknown_login_lookup_times_out_instead_of_hanging_on_partition(monkeypatch):
|
||||
"""При partition SELECT может висеть до таймаута TCP. Разовая проверка
|
||||
ограничена тем же порогом, что сверка, и закрывает вход 503 / 1013 —
|
||||
и для запроса, который ждёт lock за зависшим."""
|
||||
login = "peer-node-hanging-db-login"
|
||||
monkeypatch.setattr(auth, "AUTH_GENERATION_MAX_AGE_SECONDS", 0.3)
|
||||
|
||||
class HangingDb:
|
||||
async def scalar(self, _query):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return HangingDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class InnerApp:
|
||||
called = False
|
||||
|
||||
async def __call__(self, _scope, _receive, _send):
|
||||
self.called = True
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
cookie_session = {
|
||||
"principal": {"login": login},
|
||||
"auth_instance": auth._INSTANCE,
|
||||
"auth_generation": 0,
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
http_messages, ws_messages = [], []
|
||||
|
||||
async def http_send(message):
|
||||
http_messages.append(message)
|
||||
|
||||
async def ws_send(message):
|
||||
ws_messages.append(message)
|
||||
|
||||
http_app, ws_app = InnerApp(), InnerApp()
|
||||
started = asyncio.get_running_loop().time()
|
||||
await asyncio.wait_for(asyncio.gather(
|
||||
auth.AuthVersionMiddleware(http_app)(
|
||||
{"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
|
||||
receive, http_send,
|
||||
),
|
||||
auth.AuthVersionMiddleware(ws_app)(
|
||||
{"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
|
||||
receive, ws_send,
|
||||
),
|
||||
), timeout=2)
|
||||
elapsed = asyncio.get_running_loop().time() - started
|
||||
assert elapsed < 1, "a queued handshake must not wait for a second timeout"
|
||||
assert not http_app.called and not ws_app.called
|
||||
assert http_messages[0]["status"] == 503
|
||||
assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
|
||||
assert login not in auth._lookup_locks, "timed-out lookup must release its lock entry"
|
||||
|
||||
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_one_shot_lookup_logs_login_marker_not_login(caplog, monkeypatch):
|
||||
"""Кластерный смоук ищет эту метку в журнале узла B: она доказывает, что
|
||||
вход прошёл через разовую проверку, а не через обычную сверку."""
|
||||
login = "peer-node-logged-login"
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
return 0
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
assert await auth._resolve_unknown_login(login) == 0
|
||||
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
with caplog.at_level("WARNING", logger="app.api.auth"):
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._generations.pop(login, None)
|
||||
assert auth.login_log_marker(login) in caplog.text
|
||||
assert login not in caplog.text
|
||||
|
||||
|
||||
def test_missing_login_is_cached_until_next_sync_and_lock_entries_are_released(monkeypatch):
|
||||
login = "peer-node-replayed-missing-login"
|
||||
lookups = []
|
||||
|
||||
class FakeResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
class FakeDb:
|
||||
async def scalar(self, _query):
|
||||
lookups.append(1)
|
||||
return None
|
||||
|
||||
async def execute(self, _query):
|
||||
return FakeResult()
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return FakeDb()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def run():
|
||||
auth.prime_generations({})
|
||||
assert await auth._resolve_unknown_login(login) is None
|
||||
assert await auth._resolve_unknown_login(login) is None
|
||||
assert lookups == [1], "a replayed cookie must not query users on every request"
|
||||
assert auth._lookup_locks == {}
|
||||
|
||||
await auth.sync_generations()
|
||||
assert await auth._resolve_unknown_login(login) is None
|
||||
assert lookups == [1, 1], "the negative result lives only until the next sync"
|
||||
|
||||
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||
try:
|
||||
asyncio.run(run())
|
||||
finally:
|
||||
auth._missing_logins.discard(login)
|
||||
|
||||
|
||||
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
|
||||
from app.config import get_settings
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue