From 1dc3a08563dcbe02b5144868eaea28c7c00eb285 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 17:39:49 +0500 Subject: [PATCH] =?UTF-8?q?test(tradein/auth):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=D0=BE=20=D0=BF=D1=80=D0=BE=20=D1=81=D0=B8=D0=BD?= =?UTF-8?q?=D1=85=D1=80=D0=BE=D0=BD=D0=BD=D1=83=D1=8E=20=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D1=83=20=E2=80=94=20=D1=81=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D0=BC,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B5=D0=BC=20?= =?UTF-8?q?(#2715)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пункт 3 хвоста #2715. Правило «из async def зови ТОЛЬКО verify_password_bounded» жило в docstring app/core/password.py, а синхронная verify_password оставалась публичной и импортируемой. Одной строчки asyncio.to_thread(verify_password, …) в будущем коде хватит, чтобы получить вынос в поток вообще без учёта слотов: внешне быстро, а потолок перебора (#2665) и доля на ключ (#2714) тихо исчезают. Сторож по AST, а не по grep: verify_password поминается в комментариях и docstring'ах (auth.py, config.py) — текстовый поиск краснел бы на них, а форму без скобок (to_thread(verify_password, …)) — ровно ту, ради которой всё и пишется — не поймал бы вовсе. Сам детектор проверен на себе (test_detector_actually_detects): пустой детектор прошёл бы все файлы и выглядел бы рабочим сторожем. Проверено мутацией: импорт verify_password в app/api/v1/team.py красит тест. Прецедент такого сторожа: backend/tests/sql/test_auth_sql_migrations.py. Refs #2715 --- .../backend/tests/test_password_call_sites.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_password_call_sites.py diff --git a/tradein-mvp/backend/tests/test_password_call_sites.py b/tradein-mvp/backend/tests/test_password_call_sites.py new file mode 100644 index 00000000..1cda0b34 --- /dev/null +++ b/tradein-mvp/backend/tests/test_password_call_sites.py @@ -0,0 +1,83 @@ +"""Правило «из `async def` зови ТОЛЬКО ограниченную сверку» — проверяемое (#2715). + +Правило живёт в docstring `app/core/password.py`: синхронный `verify_password` +блокирует поток на ~282 мс (bcrypt cost 12), поэтому из кода приложения его +зовёт РОВНО ОДНА функция — `verify_password_bounded`, и она же единственная, +кто считает слоты (потолок темпа #2665 + доля на ключ #2714). + +Комментарий это правило не удерживает. Синхронная функция остаётся публичной и +импортируемой, и достаточно одной строчки `asyncio.to_thread(verify_password, +…)` в будущем коде, чтобы получить вынос в поток ВООБЩЕ БЕЗ учёта слотов: +внешне всё работает, вход отвечает быстро, а потолок перебора тихо исчезает. +Ревью такое ловит ровно до тех пор, пока помнит, что правило есть. + +Прецедент такого сторожа в репозитории: backend/tests/sql/test_auth_sql_migrations.py. + +ПОЧЕМУ AST, А НЕ GREP. `verify_password` упоминается в комментариях и docstring'ах +(app/api/v1/auth.py, app/core/config.py) — текстовый поиск краснел бы на них, и +сторож пришлось бы ослаблять исключениями до бессмысленности. AST видит только +ССЫЛКИ НА СИМВОЛ и ловит форму без скобок (`to_thread(verify_password, …)`), +которую `grep 'verify_password('` не поймал бы вовсе — то есть ровно ту, ради +которой сторож и написан. + +Без БД и без сети — только чтение файлов. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +_APP_DIR = _BACKEND_ROOT / "app" +# Единственное место, которому синхронная сверка разрешена: там она и определена, +# и оттуда её забирает пул внутри `verify_password_bounded`. +_OWNER = _APP_DIR / "core" / "password.py" + + +def _references_verify_password(source: str) -> bool: + """Ссылается ли модуль на символ `verify_password` (в любой форме).""" + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Name) and node.id == "verify_password": + return True + if isinstance(node, ast.Attribute) and node.attr == "verify_password": + return True + if isinstance(node, ast.ImportFrom) and any( + alias.name == "verify_password" for alias in node.names + ): + return True + return False + + +def test_detector_actually_detects() -> None: + """Сторож обязан уметь краснеть — иначе он зелен вхолостую. + + Проверка на самого себя: пустой детектор (`return False`) прошёл бы все + файлы приложения и выглядел бы работающим сторожем ровно до первого + настоящего нарушения. + """ + # Формы, которые обязан ловить. + assert _references_verify_password("from app.core.password import verify_password") + assert _references_verify_password("asyncio.to_thread(verify_password, plain, hashed)") + assert _references_verify_password("password.verify_password(plain, hashed)") + assert _references_verify_password("ok = verify_password(plain, hashed)") + + # Формы, на которые краснеть НЕЛЬЗЯ (иначе сторож потребуют выключить). + assert not _references_verify_password("await verify_password_bounded(p, h, key=ip)") + assert not _references_verify_password('"""Зови verify_password только из пула."""') + assert not _references_verify_password("# verify_password тут только в комментарии") + + +def test_sync_verify_password_is_called_from_one_place_only() -> None: + """В `app/` синхронную сверку не поминает никто, кроме её собственного модуля.""" + offenders = [ + str(path.relative_to(_BACKEND_ROOT)) + for path in sorted(_APP_DIR.rglob("*.py")) + if path != _OWNER and _references_verify_password(path.read_text(encoding="utf-8")) + ] + + assert offenders == [], ( + f"{offenders}: синхронный verify_password блокирует поток на ~282 мс и НЕ считает " + "слоты. Из кода приложения зови verify_password_bounded (app/core/password.py) — " + "она единственная точка выноса в пул и единственная точка учёта потолка" + )