"""Проверка пароля не должна блокировать event loop (регресс после теста 31.07.2026). Синхронный `verify_password` останавливал весь процесс backend на 95–155 мс. При массовом входе (около 70 человек разом) это давало p95 логина 7.28 секунды, 33 соединения к БД в состоянии `idle in transaction` при одном активном запросе и отказы на посторонних ручках — включая вход в конференцию, где пароль вообще не проверялся. Разбор — `.forcc/LOGIN-BOTTLENECK.md`. Тесты ниже проверяют не скорость (она зависит от железа), а **свойства**: event loop остаётся живым, проверки идут параллельно, старые хэши мигрируют. """ import asyncio import time from argon2 import PasswordHasher from core.security import hash_password, needs_rehash, verify_password PASSWORD = "correct-horse-battery-staple" async def test_verify_password_does_not_block_event_loop() -> None: """Пока считается argon2, event loop продолжает обслуживать другие задачи. Это главное свойство правки. Фоновая корутина тикает каждую миллисекунду; если проверка пароля выполняется синхронно в loop, тиков за её время будет ноль или единицы — именно так и вело себя приложение до исправления. """ password_hash = await hash_password(PASSWORD) ticks = 0 stop = False async def ticker() -> None: nonlocal ticks while not stop: ticks += 1 await asyncio.sleep(0.001) ticker_task = asyncio.create_task(ticker()) await asyncio.sleep(0.005) # даём тикеру стартовать ticks_before = ticks assert await verify_password(PASSWORD, password_hash) is True ticks_during = ticks - ticks_before stop = True await ticker_task # Даже на быстром железе argon2 занимает десятки миллисекунд — за это время # loop обязан прокрутить заметное число тиков. Порог намеренно щадящий: # при блокировке тиков будет 0–1, а не «мало». assert ticks_during >= 5, ( f"event loop простоял во время проверки пароля: {ticks_during} тиков — " "похоже, argon2 снова считается синхронно" ) async def test_parallel_verifications_are_concurrent() -> None: """Параллельные проверки идут одновременно, а не выстраиваются в очередь. argon2-cffi — C-расширение и освобождает GIL, поэтому пул потоков даёт настоящий параллелизм. Проверяем, что 8 проверок занимают заметно меньше, чем 8 последовательных: иначе массовый вход снова упрётся в сериализацию. """ password_hash = await hash_password(PASSWORD) start = time.perf_counter() await verify_password(PASSWORD, password_hash) single = time.perf_counter() - start start = time.perf_counter() results = await asyncio.gather(*(verify_password(PASSWORD, password_hash) for _ in range(8))) parallel = time.perf_counter() - start assert all(results) # На 4-ядерном сервере 8 проверок идеально легли бы в 2×single; берём 5× # с большим запасом на шум CI и разное железо — важно лишь то, что это # НЕ 8× (последовательное выполнение). assert parallel < single * 5, ( f"8 параллельных проверок заняли {parallel:.3f} с при {single:.3f} с на одну — " "похоже, они выполняются последовательно" ) async def test_wrong_password_is_rejected() -> None: """Асинхронная обёртка не сломала саму проверку.""" password_hash = await hash_password(PASSWORD) assert await verify_password(PASSWORD, password_hash) is True assert await verify_password("wrong-password", password_hash) is False async def test_hasher_uses_owasp_parameters() -> None: """Параметры argon2id — по рекомендации OWASP, а не дефолт библиотеки. Дефолт argon2-cffi (t=3, m=64 МБ, p=4) стоил 95 мс на проверку, причём `parallelism=4` занимал все четыре ядра сервера — те же, на которых работает LiveKit. """ password_hash = await hash_password(PASSWORD) # Параметры зашиты в саму строку хэша: $argon2id$v=19$m=19456,t=2,p=1$... assert "m=19456" in password_hash assert "t=2" in password_hash assert "p=1" in password_hash async def test_legacy_hash_is_verified_and_marked_for_rehash() -> None: """Хэш со старыми параметрами проверяется, но помечается на перевыпуск. Гарантия обратной совместимости: пароли, выданные до смены параметров, продолжают работать. `AuthService.login` перевыпускает такой хэш при первом же успешном входе — другого момента, когда открытый пароль есть на руках, не будет. """ legacy_hasher = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4) legacy_hash = legacy_hasher.hash(PASSWORD) assert await verify_password(PASSWORD, legacy_hash) is True assert needs_rehash(legacy_hash) is True fresh_hash = await hash_password(PASSWORD) assert needs_rehash(fresh_hash) is False async def test_broken_hash_is_marked_for_rehash() -> None: """Мусор вместо хэша не роняет вход, а помечается на замену.""" assert needs_rehash("not-a-valid-argon2-hash") is True