gendesign/tradein-mvp/backend/tests/test_password.py
bot-backend cdf493f345
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

387 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты для 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