test(auth): проверять вынос bcrypt механизмом, а не секундомером
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m53s

`probe_latencies[-1] < 0.5` мерил на общем раннере не свойство кода, а
свободен ли CPU у соседей: 03.09 деплой Trade-In встал на «худший
сторонний запрос 544мс» на коммите, который часом раньше прошёл на
незанятой машине (#3343). Тест из #2712 проверял правильную вещь
неправильной метрикой.

Вердикт теперь дискретный: сверка пароля обязана исполниться НЕ в потоке
событийного цикла (двойник bcrypt пишет `threading.get_ident()`).
Загрузка раннера этого не подделает. Второй половиной остаётся счётчик
тиков пробы: при bcrypt в `async def` цикл стоит всю секунду флуда и
проба не успевает почти никогда, при выносе в пул успевает ~100 —
порог 10 лежит посередине с запасом ×10 в обе стороны. Абсолютные
латентности сохранены в тексте падения как диагностика, но не гейтят.

Фальсификация: возврат `verify_password` из пула прямо в `async def`
роняет тест на новом утверждении («сверка пароля исполнилась в потоке
событийного цикла», `assert 8387963968 not in {8387963968}`), 5 прогонов
подряд под load average 24 — зелёные.

Closes #3343
This commit is contained in:
bot-backend 2026-09-05 22:48:37 +05:00
parent 99f112db0b
commit ecd6d5c91d

View file

@ -35,6 +35,7 @@ import asyncio
import logging import logging
import os import os
import re import re
import threading
import time import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
@ -763,14 +764,19 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive(
app = _build_test_app(store) app = _build_test_app(store)
attempts: list[float] = [] attempts: list[float] = []
verify_threads: set[int] = set()
def _slow_verify(plain: str, hashed: str) -> bool: def _slow_verify(plain: str, hashed: str) -> bool:
"""Стенд-двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше. """Стенд-двойник bcrypt: столько же БЛОКИРУЮЩЕГО времени, только меньше.
Блокирующий `time.sleep`, а не `await` суть проблемы в том, что bcrypt Блокирующий `time.sleep`, а не `await` суть проблемы в том, что bcrypt
не отпускает поток; двойник с `await` проверял бы не то. не отпускает поток; двойник с `await` проверял бы не то.
Заодно записывает ПОТОК исполнения: это и есть механизм выноса (#3343) —
величина дискретная, от загрузки раннера не зависящая.
""" """
attempts.append(time.monotonic()) attempts.append(time.monotonic())
verify_threads.add(threading.get_ident())
time.sleep(verify_s) time.sleep(verify_s)
return False return False
@ -824,19 +830,38 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive(
# Проверяется ПЕРВЫМ: если цикл занят, встаёт и сам флуд, и тогда # Проверяется ПЕРВЫМ: если цикл занят, встаёт и сам флуд, и тогда
# остальные числа мерят не потолок, а паралич — их надо читать после # остальные числа мерят не потолок, а паралич — их надо читать после
# этого вердикта, а не вместо него. # этого вердикта, а не вместо него.
assert probe_latencies, "проба не сделала ни одного запроса" #
# Вердикт — про МЕХАНИЗМ, а не про секундомер (#3343). Раньше здесь стоял
# абсолютный порог латентности пробы (`худший < 500мс`, `медиана < 50мс`).
# На общем раннере он мерил не свойство кода, а свободен ли CPU у соседей:
# 03.09 деплой встал на «худший сторонний запрос 544мс» ровно на том
# коммите, который часом раньше прошёл на незанятой машине. Порог, который
# краснеет от чужой параллельной сборки, не отличает поломку от нагрузки,
# а красный обязан значить «значение неверно», иначе его начинают
# переспрашивать. Обе замены переживают любую загрузку:
# - ПОТОК, в котором исполнилась сверка, — величина дискретная. Вернись
# bcrypt в `async def` — здесь окажется поток цикла, и никакой простой
# раннера этого не замаскирует и не подделает;
# - ТИКИ пробы: цикл с bcrypt внутри стоит практически всю секунду
# флуда (сверки идут подряд по 50мс), и проба не успевает почти
# никогда; при выносе в пул она просыпается раз в 10мс, то есть ~100
# раз. Порог 10 лежит в середине этого разрыва с запасом ×10 в обе
# стороны — на общем раннере съедается пропускная способность, но не
# порядок величины.
# Сами латентности остаются в сообщении как ДИАГНОСТИКА: числа полезны,
# когда тест красный, и не годятся в вердикт, пока машина общая.
assert verify_threads, "ни одной сверки пароля не состоялось — мерить нечего"
assert threading.get_ident() not in verify_threads, (
"сверка пароля исполнилась в потоке событийного цикла — bcrypt держит весь "
"API на всё время проверки (вынос в пул из #2665 отменён)"
)
assert probe_latencies, "проба не сделала ни одного запроса — цикл был занят"
probe_latencies.sort() probe_latencies.sort()
assert probe_latencies[-1] < 0.5, (
f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа"
)
median_probe = probe_latencies[len(probe_latencies) // 2] median_probe = probe_latencies[len(probe_latencies) // 2]
assert median_probe < verify_s, (
f"медиана стороннего запроса {median_probe * 1000:.0f}мс ≥ времени одной "
f"сверки — цикл занят проверкой пароля, API стоит"
)
# Мало проб за секунду — тоже занятый цикл: проба просыпается раз в 10мс.
assert len(probe_latencies) >= 10, ( assert len(probe_latencies) >= 10, (
f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят" f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с "
f"(медиана {median_probe * 1000:.0f}мс, худший {probe_latencies[-1] * 1000:.0f}мс) "
f"— цикл был занят"
) )
# 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает # 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает