Some checks failed
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Failing after 21s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 55s
CI Trade-In / frontend-checks (pull_request) Successful in 1m26s
CI / openapi-codegen-check (pull_request) Successful in 2m7s
CI / backend-tests (pull_request) Successful in 16m53s
_manifest_applied.txt по построению не мог покраснеть. Тест считал «новым» любой файл, которого нет в списке, а новые файлы от списка освобождены (докстринг test_manifest_covers_all_but_new_files: «НЕ требует, чтобы новый файл уже был в manifest»). Забытое имя и новая миграция PR для гейта — одно и то же, поэтому дрейф был не пропуском проверки, а её штатным исключением. Замер на main 2026-08-07: 15 имён не дописано, все четыре теста зелёные — через сутки после того, как #2692 догнал список руками. Список при этом был лишь копией того, что git и так знает: deploy-tradein.yml применяет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP, то есть «файл доехал до main» и есть «имя закреплено на проде». Ведём эталон в git — и дрейфовать становится нечему. Кросс-ветковая дыра закрыта тем же ходом: номер нового файла сверяется с ПОЛНЫМ origin/main, а не с рабочим деревом, поэтому коллизия с миграцией, смерженной после ветвления, находится. Проверено на живом PR #2754 (234_trade_in_estimates_retain_until против 234_scrape_runs_ban_kind_unknown из main): старый гейт зелёный, новый красный. Удаление/переименование применённой миграции сверяется с ТОЧКОЙ ВЕТВЛЕНИЯ, а не с origin/main: иначе ветка недельной давности краснела бы за чужие миграции. Проверено — ветка от 2026-07-30 при +43 миграциях в main зелёная. CI: checkout переведён на fetch-depth 0 + отдельный fetch main. Этот Forgejo не публикует refs/pull/N/merge (1620 */head, ноль */merge), а на depth=1 нет ни origin/main, ни общего предка — без этого гейту не с чем сверять, и он намеренно красный, а не тихо пропущенный. Контракт сведён к одной формулировке — докстринг test_migration_numbering.py; шапка манифеста, правило 3, хвост манифеста и рецепт из .claude/rules удалены или заменены ссылкой. Заодно исправлен сам рецепт: `git ls-tree` без `-r` печатает каталог, а не файлы. Refs #2683
182 lines
11 KiB
Python
182 lines
11 KiB
Python
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
|
||
|
||
Снимка «уже применённого» здесь нет и не нужно: у соседнего стека такой файл-список был
|
||
(tradein data/sql/_manifest_applied.txt) и его удалили в #2683 — он отставал от каталога
|
||
и по построению не мог покраснеть. Аналог гейта для tradein теперь берёт эталон из git:
|
||
tradein-mvp/backend/tests/test_migration_numbering.py. Здесь же проверяются инварианты,
|
||
выполнимые БЕЗ всякого эталона: они верны на новом каталоге с первого дня и ловят
|
||
регрессии, которые иначе всплывают только на проде во время деплоя.
|
||
|
||
Тест не требует БД — только чтение файлов.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||
_AUTH_SQL_DIR = _REPO_ROOT / "data" / "sql" / "auth"
|
||
_BOOTSTRAP_SQL_DIR = _REPO_ROOT / "ops" / "db-bootstrap"
|
||
_DEPLOY_WORKFLOW = _REPO_ROOT / ".forgejo" / "workflows" / "deploy.yml"
|
||
|
||
_FILENAME_RE = re.compile(r"^(\d{3})_[a-z0-9_]+\.sql$")
|
||
|
||
# Признаки утёкшего пароля в git. bcrypt-хеши ($2a$/$2b$/$2y$) запрещены наравне с
|
||
# plaintext: хеш из репозитория брутфорсится офлайн и переживает ротацию пароля,
|
||
# оставаясь в истории коммитов. Конвенция репо — сид вставляет password_hash = NULL,
|
||
# значения проставляются на проде (прецедент: tradein м.193).
|
||
_SECRET_PATTERNS = (
|
||
re.compile(r"\$2[aby]\$\d{2}\$"), # bcrypt hash
|
||
re.compile(r"PASSWORD\s+'", re.IGNORECASE), # CREATE/ALTER ROLE ... PASSWORD 'literal'
|
||
)
|
||
|
||
|
||
def _auth_sql_files() -> list[Path]:
|
||
"""Файлы, к которым применимы конвенции миграций (имя NNN_*, обёртка BEGIN/COMMIT)."""
|
||
return sorted(_AUTH_SQL_DIR.glob("*.sql"))
|
||
|
||
|
||
def _secret_scanned_files() -> list[Path]:
|
||
"""Файлы, по которым гоняется поиск паролей/хешей — ШИРЕ, чем список миграций.
|
||
|
||
⚠️ НЕ «унифицируй» этот список с _auth_sql_files(): разделение намеренное.
|
||
|
||
* data/sql/auth/*.sql — миграции: обязаны иметь имя NNN_snake_case.sql и обёртку
|
||
BEGIN;/COMMIT; (см. test_filenames_and_unique_prefix, test_migrations_are_transactional).
|
||
* ops/db-bootstrap/*.sql — bootstrap: НЕ миграции, поэтому намеренно без NNN-префикса
|
||
(порядок задан явными шагами deploy.yml, не сортировкой) и намеренно без транзакции
|
||
(CREATE DATABASE запрещён внутри транзакционного блока). Прогонять по ним проверки
|
||
имён/BEGIN-COMMIT — значит сломать тест на корректных файлах.
|
||
|
||
А вот запрет на пароли применим к обоим каталогам, и именно bootstrap здесь важнее:
|
||
единственное место в репозитории с конструкцией `ALTER ROLE ... PASSWORD` — это
|
||
ops/db-bootstrap/set_*_password.sql, то есть ровно тот файл, куда проще всего однажды
|
||
«временно» вписать литерал вместо чтения из env. Другого контроля на это нет:
|
||
в .pre-commit-config.yaml из секрет-сканеров только detect-private-key (bcrypt не ловит),
|
||
а репо-wide grep невозможен — caddy/users.caddy.snippet легально содержит bcrypt-хеши
|
||
действующих логинов.
|
||
"""
|
||
return _auth_sql_files() + sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql"))
|
||
|
||
|
||
def test_scanned_dirs_are_not_empty() -> None:
|
||
"""Sanity: пути до каталогов не разъехались (иначе все проверки ниже — пустые).
|
||
|
||
Red => каталог переименован/перенесён, а тест этого не заметил бы: `glob` по
|
||
несуществующему пути возвращает [], и все циклы ниже стали бы no-op'ами, оставаясь
|
||
зелёными. Особенно опасно для проверки паролей — «зелено, потому что ничего не проверено».
|
||
"""
|
||
assert _auth_sql_files(), f"Не найдено *.sql в {_AUTH_SQL_DIR}"
|
||
assert sorted(_BOOTSTRAP_SQL_DIR.glob("*.sql")), f"Не найдено *.sql в {_BOOTSTRAP_SQL_DIR}"
|
||
|
||
|
||
def test_filenames_and_unique_prefix() -> None:
|
||
"""Имя вида NNN_snake_case.sql, префикс NNN уникален.
|
||
|
||
Red => прод применяет файлы в порядке `ls | sort`; два файла с одним NNN дают
|
||
неоднозначный порядок (например, роль/гранты раньше таблиц). Присвой следующий
|
||
свободный номер.
|
||
"""
|
||
seen: dict[str, str] = {}
|
||
bad_names: list[str] = []
|
||
collisions: list[str] = []
|
||
for path in _auth_sql_files():
|
||
m = _FILENAME_RE.match(path.name)
|
||
if m is None:
|
||
bad_names.append(path.name)
|
||
continue
|
||
prefix = m.group(1)
|
||
if prefix in seen:
|
||
collisions.append(f"{path.name} (префикс {prefix} уже у {seen[prefix]})")
|
||
else:
|
||
seen[prefix] = path.name
|
||
|
||
assert not bad_names, (
|
||
f"Имена не соответствуют NNN_snake_case.sql: {bad_names}. "
|
||
"Порядок применения на проде определяется сортировкой имён."
|
||
)
|
||
assert not collisions, "Дублирующийся NNN-префикс: " + "; ".join(collisions)
|
||
|
||
|
||
def test_migrations_are_transactional() -> None:
|
||
"""Каждая миграция обёрнута в BEGIN; ... COMMIT; (.claude/rules/sql.md).
|
||
|
||
Red => частично применённая миграция оставит БД auth в промежуточном состоянии:
|
||
деплой падает на ON_ERROR_STOP, а уже выполненный DDL не откатывается.
|
||
"""
|
||
broken: list[str] = []
|
||
for path in _auth_sql_files():
|
||
text = path.read_text(encoding="utf-8")
|
||
statements = [
|
||
line.strip()
|
||
for line in text.splitlines()
|
||
if line.strip() and not line.strip().startswith("--")
|
||
]
|
||
if not statements or statements[0] != "BEGIN;" or statements[-1] != "COMMIT;":
|
||
broken.append(path.name)
|
||
assert (
|
||
not broken
|
||
), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)."
|
||
|
||
|
||
def test_no_concurrent_index_in_migrations() -> None:
|
||
"""Ни одной CREATE/DROP INDEX CONCURRENTLY в data/sql/auth/*.sql.
|
||
|
||
Red => миграция гарантированно падает на проде: CONCURRENTLY нельзя выполнять внутри
|
||
транзакционного блока (Postgres: 25001 «CREATE INDEX CONCURRENTLY cannot run inside a
|
||
transaction block»), а обёртка BEGIN;/COMMIT; здесь обязательна для всех файлов
|
||
(test_migrations_are_transactional). Две проверки по отдельности зелёные, а вместе
|
||
невыполнимые — поэтому запрет нужен явный: комбинация ловится только здесь.
|
||
Нужен CONCURRENTLY на большой таблице — это отдельный ручной прогон вне auto-apply,
|
||
а не файл в этом каталоге.
|
||
"""
|
||
hits: list[str] = []
|
||
for path in _auth_sql_files():
|
||
text = path.read_text(encoding="utf-8")
|
||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||
if line.lstrip().startswith("--"):
|
||
continue # комментарий может объяснять запрет, не нарушая его
|
||
if re.search(r"\bCONCURRENTLY\b", line, re.IGNORECASE):
|
||
hits.append(f"{path.name}:{line_no}: {line.strip()}")
|
||
assert not hits, "CONCURRENTLY внутри BEGIN/COMMIT — упадёт на деплое: " + "; ".join(hits)
|
||
|
||
|
||
def test_no_password_material_in_auth_sql() -> None:
|
||
"""Ни в data/sql/auth, ни в ops/db-bootstrap нет plaintext-паролей и bcrypt-хешей.
|
||
|
||
Покрытие шире каталога миграций сознательно — обоснование в _secret_scanned_files().
|
||
|
||
Red => пароль/хеш попал в git. Убери значение: сид вставляет password_hash = NULL,
|
||
пароль роли ставится из env через ops/db-bootstrap/set_auth_app_password.sql
|
||
(значение приезжает из .env.runtime на VPS и в репозитории не существует).
|
||
"""
|
||
hits: list[str] = []
|
||
for path in _secret_scanned_files():
|
||
rel = path.relative_to(_REPO_ROOT).as_posix()
|
||
text = path.read_text(encoding="utf-8")
|
||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||
if line.lstrip().startswith("--"):
|
||
continue # комментарии описывают запрет, а не нарушают его
|
||
for pattern in _SECRET_PATTERNS:
|
||
if pattern.search(line):
|
||
hits.append(f"{rel}:{line_no}: {line.strip()}")
|
||
assert not hits, "Похоже на пароль/хеш в SQL: " + "; ".join(hits)
|
||
|
||
|
||
def test_deploy_workflow_applies_auth_migrations() -> None:
|
||
"""deploy.yml реально прогоняет data/sql/auth/*.sql.
|
||
|
||
Каталог обособлен намеренно: основной цикл миграций использует `ls -1 data/sql/*.sql`
|
||
и в подкаталоги НЕ рекурсирует (чтобы файлы auth физически не могли примениться в БД
|
||
gendesign). Обратная сторона — без отдельного цикла в deploy.yml эти файлы не
|
||
применяются вообще и никто этого не заметит. Red => wiring удалён или переименован.
|
||
"""
|
||
workflow = _DEPLOY_WORKFLOW.read_text(encoding="utf-8")
|
||
assert "data/sql/auth/*.sql" in workflow, (
|
||
f"В {_DEPLOY_WORKFLOW.name} нет цикла по data/sql/auth/*.sql — миграции БД auth "
|
||
"не применяются на деплое."
|
||
)
|
||
assert (
|
||
"ops/db-bootstrap/create_auth_db.sql" in workflow
|
||
), f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth."
|