gendesign/backend/tests/sql/test_auth_sql_migrations.py
bot-backend b5976c0cc9
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
feat(auth): роли и трёхзначное состояние доступа в БД auth [PR-2a/6]
Схема под решения владельца от 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), а отдельной проверки на неё не было.
2026-08-01 00:28:37 +03:00

182 lines
11 KiB
Python
Raw Permalink 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.

"""Инварианты миграций БД `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."