All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m0s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m9s
387 lines
20 KiB
Python
387 lines
20 KiB
Python
"""Тесты для app/core/password.py — bcrypt hash/verify (#2550)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import os
|
||
import threading
|
||
import time
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
|
||
# С #2665 password.py читает настройки (размер пула проверок) — значит тянет
|
||
# `Settings()`, которому нужен DATABASE_URL. В CI он в env (ci-tradein.yml),
|
||
# локально подставляем заглушку, как это делает tests/test_auth_api.py.
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import pytest
|
||
|
||
from app.core import password as password_mod
|
||
from app.core.config import settings
|
||
from app.core.password import (
|
||
PasswordVerifyOverloadedError,
|
||
hash_password,
|
||
verify_password,
|
||
verify_password_bounded,
|
||
)
|
||
|
||
|
||
def test_roundtrip() -> None:
|
||
"""hash_password → verify_password с тем же паролем возвращает True."""
|
||
hashed = hash_password("correct horse battery staple")
|
||
assert verify_password("correct horse battery staple", hashed) is True
|
||
|
||
|
||
def test_wrong_password_returns_false() -> None:
|
||
"""Неверный пароль против валидного хеша → False."""
|
||
hashed = hash_password("correct horse battery staple")
|
||
assert verify_password("wrong password", hashed) is False
|
||
|
||
|
||
def test_hash_too_long_raises_value_error() -> None:
|
||
"""Пароль >72 байт в UTF-8 → ValueError в hash_password (нет silent truncation)."""
|
||
long_password = "a" * 73
|
||
with pytest.raises(ValueError):
|
||
hash_password(long_password)
|
||
|
||
|
||
def test_hash_exactly_72_bytes_ok() -> None:
|
||
"""Ровно 72 байта — граничное значение, ещё допустимо."""
|
||
password = "a" * 72
|
||
hashed = hash_password(password)
|
||
assert verify_password(password, hashed) is True
|
||
|
||
|
||
def test_hash_too_long_multibyte_raises_value_error() -> None:
|
||
"""40 кириллических символов = 80 байт UTF-8 (2 байта/символ) → ValueError.
|
||
|
||
Проверяет, что лимит считается в байтах, а не в символах — иначе 40-символьный
|
||
кириллический пароль (< 72 символов, но 80 байт) прошёл бы мимо guard'а.
|
||
"""
|
||
long_cyrillic_password = "а" * 40
|
||
assert len(long_cyrillic_password.encode("utf-8")) == 80
|
||
with pytest.raises(ValueError):
|
||
hash_password(long_cyrillic_password)
|
||
|
||
|
||
def test_verify_too_long_returns_false_not_raise() -> None:
|
||
"""verify_password на >72-байтовом пароле возвращает False, НЕ raise."""
|
||
hashed = hash_password("some valid password")
|
||
long_password = "a" * 73
|
||
assert verify_password(long_password, hashed) is False
|
||
|
||
|
||
def test_hash_empty_raises_value_error() -> None:
|
||
"""Пустой пароль → ValueError в hash_password."""
|
||
with pytest.raises(ValueError):
|
||
hash_password("")
|
||
|
||
|
||
def test_verify_empty_returns_false() -> None:
|
||
"""Пустой пароль в verify_password → False (не raise)."""
|
||
hashed = hash_password("some valid password")
|
||
assert verify_password("", hashed) is False
|
||
|
||
|
||
def test_hash_is_unique_due_to_salt() -> None:
|
||
"""Два хеша одного пароля различаются (уникальная соль на каждый вызов)."""
|
||
password = "correct horse battery staple"
|
||
hash1 = hash_password(password)
|
||
hash2 = hash_password(password)
|
||
assert hash1 != hash2
|
||
assert verify_password(password, hash1) is True
|
||
assert verify_password(password, hash2) is True
|
||
|
||
|
||
def test_verify_malformed_hash_returns_false() -> None:
|
||
"""Некорректный (не-bcrypt) хеш в verify_password → False, не raise."""
|
||
assert verify_password("some password", "not-a-bcrypt-hash") is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# #2665 — verify_password_bounded: вне событийного цикла + потолок темпа
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_verify_ceiling_defaults_stay_within_the_db_pool() -> None:
|
||
"""Дефолты — часть защиты, а не тюнинг. Стережём их здесь.
|
||
|
||
Тест про темп (test_auth_api.py) вычисляет ожидаемый потолок из той же
|
||
настройки, которую охраняет, поэтому подъём дефолта он не заметит. А
|
||
наступит ослабление именно через настройку: не правкой кода и не ревью, а
|
||
строчкой `LOGIN_PASSWORD_VERIFY_WORKERS=32` в `.env.runtime` под предлогом
|
||
«входы тормозят». Пусть тогда краснеет хотя бы этот тест.
|
||
"""
|
||
from app.core.db import engine
|
||
|
||
# max_inflight ждущих ДЕРЖАТ по соединению к БД (сессия реестра открыта
|
||
# после SELECT в get_user_by_username) — очередь обязана быть уже пула.
|
||
assert (
|
||
settings.login_password_verify_max_inflight < engine.pool.size() + engine.pool._max_overflow
|
||
)
|
||
assert settings.login_password_verify_workers == 1, (
|
||
"потолок перебора = workers/282мс. Подъём — осознанное решение "
|
||
"«во сколько раз ускоряем перебор», а не рефакторинг: правь вместе с тестом"
|
||
)
|
||
# ЛИТЕРАЛЫ, а не арифметика от настройки. Доля на ключ (#2714) считается как
|
||
# max_inflight // 2, и сторож вида `cap == max_inflight // 2` был бы
|
||
# тавтологией: подъём max_inflight до 64 он бы проспал, а вместе с ним —
|
||
# возврат к «один адрес занимает всё» (доля 32 при очереди в 4 живых слота
|
||
# ничего не делит). Поэтому здесь зафиксированы ОБА числа.
|
||
assert settings.login_password_verify_max_inflight == 4, (
|
||
"очередь 4 выбрана под QueuePool 5+10 и худшее ожидание 4/1×282мс ≈ 1.1с; "
|
||
"меняешь — пересчитывай и долю на ключ ниже"
|
||
)
|
||
assert password_mod._per_key_slot_cap() == 2, (
|
||
"один адрес держит не больше 2 слотов из 4: половина ёмкости обязана "
|
||
"оставаться тем, кто приходит впервые (#2714)"
|
||
)
|
||
|
||
|
||
async def test_bounded_gives_same_answer_as_sync() -> None:
|
||
"""Обёртка не меняет вердикт — она меняет только ГДЕ он считается."""
|
||
hashed = hash_password("correct horse battery staple")
|
||
key = "203.0.113.1"
|
||
assert await verify_password_bounded("correct horse battery staple", hashed, key=key) is True
|
||
assert await verify_password_bounded("wrong password", hashed, key=key) is False
|
||
|
||
|
||
async def test_bounded_runs_off_the_event_loop_thread(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""bcrypt считается В ДРУГОМ ПОТОКЕ, а не в потоке событийного цикла.
|
||
|
||
Замер на проде: сверка = 282 мс, и ровно столько цикл не обслуживал никого.
|
||
Проверка идентичности потока — самая прямая формулировка «цикл свободен»;
|
||
таймингом её подменять нельзя, тайминг в CI флейкует.
|
||
"""
|
||
loop_thread = threading.get_ident()
|
||
seen: list[int] = []
|
||
|
||
def _spy(plain: str, hashed: str) -> bool:
|
||
seen.append(threading.get_ident())
|
||
return True
|
||
|
||
monkeypatch.setattr(password_mod, "verify_password", _spy)
|
||
assert await verify_password_bounded("x", "y", key="203.0.113.1") is True
|
||
assert seen and seen[0] != loop_thread
|
||
|
||
|
||
async def test_bounded_rejects_surplus_instead_of_queueing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Сверх лимита — немедленный отказ, а не ожидание в очереди.
|
||
|
||
Ожидание выглядело бы безобиднее, но каждый ждущий запрос держит соединение
|
||
к БД (сессия реестра открыта после SELECT), а в QueuePool их 5+10:
|
||
неограниченная очередь выбрала бы пул и положила API — тем же концом, каким
|
||
его клала блокировка цикла.
|
||
"""
|
||
monkeypatch.setattr(settings, "login_password_verify_max_inflight", 2)
|
||
|
||
def _slow(plain: str, hashed: str) -> bool:
|
||
time.sleep(0.2)
|
||
return False
|
||
|
||
monkeypatch.setattr(password_mod, "verify_password", _slow)
|
||
|
||
# У каждого запроса СВОЙ ключ: тест про ОБЩИЙ потолок, и отказывать здесь
|
||
# обязан именно он. С одним ключом на всех первым сработал бы лимит доли
|
||
# (#2714) — числа сошлись бы по другой причине, а поломка общего потолка
|
||
# осталась бы незамеченной.
|
||
results = await asyncio.gather(
|
||
*(verify_password_bounded("x", "y", key=f"203.0.113.{i}") for i in range(6)),
|
||
return_exceptions=True,
|
||
)
|
||
rejected = [r for r in results if isinstance(r, PasswordVerifyOverloadedError)]
|
||
admitted = [r for r in results if r is False]
|
||
assert len(admitted) == 2, results
|
||
assert len(rejected) == 4, results
|
||
|
||
# Слоты возвращаются: после отработки очереди вход снова доступен.
|
||
assert await verify_password_bounded("x", "y", key="203.0.113.9") is False
|
||
|
||
|
||
async def test_bounded_slot_freed_by_the_work_not_by_cancellation(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Отмена запроса не возвращает слот раньше времени.
|
||
|
||
Отмена снимает работу, которая ещё НЕ началась, — с ней проблем нет. Но уже
|
||
начатую сверку она не забирает: поток занят ею все 282 мс. Освобождай мы
|
||
слот по выходу из корутины, на это время он числился бы свободным, и
|
||
одновременно работающих сверок стало бы больше, чем разрешено.
|
||
"""
|
||
monkeypatch.setattr(settings, "login_password_verify_max_inflight", 1)
|
||
started = threading.Event()
|
||
finish = threading.Event()
|
||
|
||
def _blocked(plain: str, hashed: str) -> bool:
|
||
started.set()
|
||
finish.wait(5)
|
||
return False
|
||
|
||
monkeypatch.setattr(password_mod, "verify_password", _blocked)
|
||
|
||
task = asyncio.create_task(verify_password_bounded("x", "y", key="203.0.113.1"))
|
||
await asyncio.to_thread(started.wait, 5)
|
||
|
||
task.cancel()
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await task
|
||
|
||
# Работа всё ещё занимает поток — слот занят, следующий получает отказ.
|
||
# Ключ ДРУГОЙ: отказ обязан прийти от общего потолка (max_inflight=1), а не
|
||
# от доли на ключ — иначе тест проверял бы не тот механизм.
|
||
with pytest.raises(PasswordVerifyOverloadedError):
|
||
await verify_password_bounded("x", "y", key="203.0.113.2")
|
||
|
||
finish.set()
|
||
for _ in range(100): # дать колбэку доехать до цикла
|
||
await asyncio.sleep(0.01)
|
||
if settings.login_password_verify_max_inflight > password_mod._verify_inflight:
|
||
break
|
||
# Отменённая работа вернула И общий слот, И слот своего ключа: тот же адрес
|
||
# снова обслуживается (утечка по ключу при пуле в 1 поток была бы вечным
|
||
# отказом именно этому адресу и больше ничем себя не проявила).
|
||
assert await verify_password_bounded("x", "y", key="203.0.113.1") is False
|
||
|
||
|
||
async def test_bounded_frees_slot_when_verify_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Исключение внутри сверки тоже возвращает слот — оба счётчика.
|
||
|
||
`verify_password` глотает ValueError/TypeError сама, так что сюда доезжает
|
||
только неожиданное (падение библиотеки, MemoryError). Пул из ОДНОГО потока
|
||
не прощает: один невозвращённый слот — вечный 429 всем на входе, и внешне
|
||
это выглядит не как ошибка bcrypt, а как «вход сломался неизвестно почему».
|
||
"""
|
||
|
||
def _boom(plain: str, hashed: str) -> bool:
|
||
raise MemoryError("bcrypt died")
|
||
|
||
monkeypatch.setattr(password_mod, "verify_password", _boom)
|
||
with pytest.raises(MemoryError):
|
||
await verify_password_bounded("x", "y", key="10.0.0.3")
|
||
|
||
monkeypatch.setattr(password_mod, "verify_password", lambda plain, hashed: False)
|
||
assert await verify_password_bounded("x", "y", key="10.0.0.3") is False
|
||
assert password_mod._verify_inflight == 0
|
||
assert not password_mod._verify_inflight_by_key
|
||
|
||
|
||
async def test_one_key_cannot_take_more_than_its_share(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Один ключ занимает не больше своей доли — остальным ёмкость остаётся (#2714).
|
||
|
||
Меряем именно ЭТО, а не латентность: с общим котлом слотов один источник
|
||
выбирал его целиком, и вход с другого адреса получал 429 бессрочно —
|
||
потолок темпа исправно работал против легитимных пользователей.
|
||
"""
|
||
monkeypatch.setattr(settings, "login_password_verify_max_inflight", 4)
|
||
assert password_mod._per_key_slot_cap() == 2 # 4 // 2 — исходные условия теста
|
||
|
||
finish = threading.Event()
|
||
|
||
def _blocked(plain: str, hashed: str) -> bool:
|
||
finish.wait(5)
|
||
return False
|
||
|
||
monkeypatch.setattr(password_mod, "verify_password", _blocked)
|
||
|
||
async def _wait_inflight(n: int) -> None:
|
||
deadline = time.monotonic() + 5
|
||
while password_mod._verify_inflight < n:
|
||
assert (
|
||
time.monotonic() < deadline
|
||
), f"слотов занято {password_mod._verify_inflight} < {n}"
|
||
await asyncio.sleep(0.005)
|
||
|
||
flood = [
|
||
asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.1")) for _ in range(2)
|
||
]
|
||
await _wait_inflight(2)
|
||
|
||
# Третий с ТОГО ЖЕ адреса — отказ, хотя два слота из четырёх свободны.
|
||
# Это ЦЕНА правки, а не побочный эффект: три одновременных входа из одного
|
||
# офиса за NAT укладываются в окно одной сверки (282 мс), и третьему
|
||
# сотруднику теперь отказывают при наполовину пустом пуле — до правки для
|
||
# этого требовалось пятеро. Закрепляем явно, чтобы размен нельзя было
|
||
# потерять молча: свои с ЧУЖИХ адресов за это получают 100% вместо 37%.
|
||
assert password_mod._verify_inflight == 2
|
||
assert settings.login_password_verify_max_inflight == 4
|
||
with pytest.raises(PasswordVerifyOverloadedError):
|
||
await verify_password_bounded("x", "y", key="10.0.0.1")
|
||
|
||
# А с другого — пускают. Задачу ставим ДО finish.set() и ждём, пока она
|
||
# займёт слот: иначе «пустили» означало бы только «флуд успел закончиться».
|
||
legit = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.2"))
|
||
await _wait_inflight(3)
|
||
|
||
finish.set()
|
||
assert await legit is False, "вход с другого адреса обязан пройти во время флуда"
|
||
assert [await f for f in flood] == [False, False]
|
||
|
||
|
||
def test_per_key_cap_never_rounds_down_to_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""При очереди в 1 слот доля не округляется в ноль.
|
||
|
||
`1 // 2 == 0` означало бы «ни одному ключу нельзя ни одного слота» —
|
||
молчаливый отказ ВСЕМ на входе, причём тем более незаметный, что настройка
|
||
выглядит как безобидное ужесточение. `max(1, …)` — тот же страховочный пол,
|
||
что `ge=1` у самой настройки, только от деления.
|
||
"""
|
||
monkeypatch.setattr(settings, "login_password_verify_max_inflight", 1)
|
||
assert password_mod._per_key_slot_cap() == 1
|
||
|
||
|
||
async def test_bounded_frees_slot_when_pool_refuses_work(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Пул не принял работу → слот отдан прямо здесь, колбэка ведь не будет.
|
||
|
||
Единственный путь, где освобождение НЕ висит на future: `submit` бросает
|
||
(пул закрыт на остановке процесса). Утечка тут стоила бы дорого — при пуле
|
||
в один поток невозвращённый слот это вечный 429 всем на входе.
|
||
"""
|
||
dead_pool = ThreadPoolExecutor(max_workers=1)
|
||
dead_pool.shutdown()
|
||
monkeypatch.setattr(password_mod, "_VERIFY_POOL", dead_pool)
|
||
|
||
with pytest.raises(RuntimeError):
|
||
await verify_password_bounded("x", "y", key="10.0.0.4")
|
||
|
||
assert password_mod._verify_inflight == 0
|
||
assert not password_mod._verify_inflight_by_key
|
||
|
||
|
||
async def test_cancelling_queued_work_returns_the_key_slot(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Отмена ЕЩЁ НЕ НАЧАТОЙ работы возвращает слот — и общий, и ключа.
|
||
|
||
Ветка future другая, чем у отмены начатой работы (`cancel()` на очереди
|
||
успевает, и работа не исполняется вовсе), поэтому проверяется отдельно:
|
||
соседний тест про начатую работу эту не покрывает. Пул из одного потока —
|
||
настоящий, так что второй запрос гарантированно ЖДЁТ в очереди.
|
||
"""
|
||
monkeypatch.setattr(settings, "login_password_verify_max_inflight", 4)
|
||
started = threading.Event()
|
||
finish = threading.Event()
|
||
|
||
def _blocked(plain: str, hashed: str) -> bool:
|
||
started.set()
|
||
finish.wait(5)
|
||
return False
|
||
|
||
monkeypatch.setattr(password_mod, "verify_password", _blocked)
|
||
|
||
running = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.5"))
|
||
await asyncio.to_thread(started.wait, 5)
|
||
|
||
queued = asyncio.create_task(verify_password_bounded("x", "y", key="10.0.0.6"))
|
||
deadline = time.monotonic() + 5
|
||
while password_mod._verify_inflight_by_key.get("10.0.0.6") != 1:
|
||
assert time.monotonic() < deadline, "второй запрос не занял слот"
|
||
await asyncio.sleep(0.005)
|
||
|
||
queued.cancel()
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await queued
|
||
|
||
deadline = time.monotonic() + 5
|
||
while "10.0.0.6" in password_mod._verify_inflight_by_key:
|
||
assert time.monotonic() < deadline, "слот отменённой очереди не вернулся"
|
||
await asyncio.sleep(0.005)
|
||
|
||
finish.set()
|
||
assert await running is False
|