All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m47s
CI / backend-tests (pull_request) Successful in 15m48s
Схема под решения владельца от 2026-07-31 по эпику «единый вход». Python-кода нет, поведение прода не меняется — в БД auth пока никто не ходит. Развилка А закрыта в пользу ПОЛНОГО переезда: tradein_users (БД tradein) в итоге удаляется, auth.users становится единственным реестром людей. Значит role и manager_id переезжают сюда — это отменяет решение 001:15-19 («ролей здесь нет — сознательно»), что зафиксировано в шапке файла и переписанным COMMENT ON TABLE, а не оставлено расходиться молча. Развилка Б закрыта в пользу трёх состояний: is_active заменён на access_state (active / trial_expired / disabled). Булев флаг схлопывал «пускаем, но объясняем» и «не пускаем вовсе» в одно значение — trial-экран исчезал бы без падения тестов. Семантика зафиксирована в COMMENT: trial_expired при ВЕРНОМ пароле даёт 403 с отдельным кодом и НЕ выдаёт сессию, disabled — generic 401; неверный пароль в любом состоянии остаётся generic 401, то есть защита от перечисления логинов сохраняется. user2 («Брусника») → trial_expired. Колонки role/manager_id зеркалят м.192 побуквенно (CHECK ролей, иерархический CHECK, partial index, self-FK ON DELETE SET NULL), чтобы код «Меры» переехал на auth.users без правок. Добавлен users_manager_not_self_ck — на уровне БД самоназначение менеджером иначе проходит, а второй потребитель (Птица) валидации «Меры» не имеет. Гранты. INSERT выдан — без него переезд не состоится (создание сотрудника из «Команды»). DELETE НЕ выдан: потребителя нет (в team.py только POST и PATCH), а 002:22-33 отклоняла ровно такие гранты-на-будущее; появится хендлер — появится строка GRANT в той же миграции. Табличный UPDATE из 002:80 сужен до column-level: иначе auth_app молча получил бы право писать role и access_state, и ошибка в PATCH-эндпоинте превращалась бы в тихое повышение до админа или тихое снятие блокировки. role и manager_id в список не включены — их сегодня не пишет никто. Гранта на users_id_seq нет намеренно: для GENERATED ALWAYS AS IDENTITY PostgreSQL использует NextValueExpr → nextval_internal(check_permissions := false), ACL последовательности не проверяется. Утверждение 002:26-27 («идентичность требует nextval») фактически неверно; проверено обратным экспериментом — REVOKE, затем INSERT. Проверено исполнением на postgres:16, не по комментариям: - чистая сборка 001→002→003→004 — 13 строк, роли admin/manager×2/employee×10, user2 = trial_expired, is_active отсутствует, все 6 констрейнтов на месте; - повторный прогон 004 ×2 идемпотентен; - ручные прод-правки (user2 → active, user3 → manager) переживают повтор — backfill не затирает решения владельца; - периметр auth_app: INSERT users ✓, UPDATE access_state ✓, INSERT sessions ✓; UPDATE role ✗, UPDATE manager_id ✗, DELETE ✗, CREATE TABLE ✗; - CHECK'и ловят: admin с manager_id, self-manager, access_state вне списка, role вне списка, INSERT без role. Тест: 6 passed. Добавлена проверка запрета CREATE INDEX CONCURRENTLY — в связке с обязательной обёрткой BEGIN/COMMIT это комбинация, невыполнимая на проде (25001), а отдельной проверки на неё не было.
182 lines
11 KiB
Python
182 lines
11 KiB
Python
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
|
||
|
||
Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только
|
||
в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по
|
||
tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла
|
||
корневого каталога — не задача этого PR, поэтому здесь проверяются инварианты, которые
|
||
можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого
|
||
дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя.
|
||
|
||
Тест не требует БД — только чтение файлов.
|
||
"""
|
||
|
||
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."
|