From 5e11f0d0bf0f39063e254c4b28e93db17c9c3100 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:13:30 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20seed=20--dev=20=D1=82=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B8=20DEBUG=3Dtrue=20(OQ-60,=20#9?= =?UTF-8?q?=20=D0=BF.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI `python -m scripts.seed --dev` сверяется с `DEBUG` (`check_dev_allowed`) до чтения seed и подключения к БД; при `DEBUG=false` — код возврата 2 и сообщение «--dev доступен только при DEBUG=true (dev-пользователи не для прода)». `run_seed(session, dev=True)` защиты не имеет (её вызывают тесты). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 2 +- README.md | 10 ++++--- api/scripts/seed.py | 56 +++++++++++++++++++++++++++++++------- api/tests/test_seed_dev.py | 34 ++++++++++++++++++++++- 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7a03618..9e62126 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ (`LAST_ADMIN` с блокировкой строки права). Порядок: маршрут (`require(code)`) → policy → сервис → аудит. - **Аутентификация** — `core/auth.py` (проверка JWT), `src/api/dependencies/auth.py` (`current_user`, `require(code)`); dev-эмитент (`POST /api/v1/dev/token`) существует только при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`. -- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен; `--dev` — только для разработки. +- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен; `--dev` — только для разработки (CLI отказывает с кодом 2 при `DEBUG=false`). **Сверка ORM↔DBML** — `scripts/check_dbml_vs_orm.py` (`make dbml-check`). ## Зависимости от соседей diff --git a/README.md b/README.md index baac7f3..d943306 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,9 @@ make seed-dev # справочники продукта + dev-пользов ``` `make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без -dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует. +dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует. `make seed-dev` +(`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2 +(«`--dev` доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД. ### 5. Запуск @@ -482,7 +484,7 @@ email без учёта регистра проверяет только сер | `make up` | поднимает compose из `../docs/dev` (PostgreSQL, NATS, MinIO, бакет) | | `make migrate` | `alembic upgrade head` на БД из `api/.env` | | `make seed` | загрузка справочников продукта (идемпотентна) | -| `make seed-dev` | то же и dev-пользователи/филиалы (`--dev`) — только для разработки | +| `make seed-dev` | то же и dev-пользователи/филиалы (`--dev`) — только для разработки, требует `DEBUG=true` | | `make lint` | `ruff check`, `ruff format --check`, `mypy src scripts` | | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | | `make test-integration` | интеграционные тесты: `pytest -m integration`, нужны PostgreSQL (и NATS) | @@ -579,8 +581,8 @@ Dockerfile только для локального compose - Маршрут существует лишь при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`; при любом другом сочетании его нет вовсе (404), а dev-проверяющий токенов не создаётся даже при заданном `AUTH_DEV_SECRET`. В проде `DEBUG=false`, поэтому dev-эмитент там невозможен. -- `AUTH_DEV_SECRET` из `api/.env.example` — публичный пример, в проде не использовать. `make seed-dev` в прод не - запускать. +- `AUTH_DEV_SECRET` из `api/.env.example` — публичный пример, в проде не использовать. `make seed-dev` + (`python -m scripts.seed --dev`) при `DEBUG=false` отказывает (код 2), dev-пользователи в прод не попадут. - Порты compose по умолчанию публикуются только на `127.0.0.1` (`BIND_ADDR`); пароли `armka`/`minioadmin` — только dev. ## Соседние документы diff --git a/api/scripts/seed.py b/api/scripts/seed.py index 5d3097f..d8bdb62 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -26,9 +26,10 @@ ASSUMPTION(OQ-46)): `seed_lib/loaders_rules.py` проверяет, что ка Dev-данные (`--dev`, `seed/dev/`, задача 15): 2 региональных филиала (`regional_offices.yaml`, upsert по `name`) и по одному пользователю на каждую системную роль (`users.yaml`, `<роль>@armka.local`, upsert `app_user` по `email`, `user_role` по (user_id, role_id)). Ссылки пользователей на роли и филиалы проверяются до записи. Без `--dev` ни -пользователи, ни dev-филиалы не загружаются. ASSUMPTION: dev-данные — только для локальной разработки и тестовых -стендов; в прод `--dev` не запускать (защиты флага нет, спека её не требует). Удалений нет: роль, убранная из -`users.yaml`, остаётся назначенной. +пользователи, ни dev-филиалы не загружаются. Dev-данные — только для локальной разработки и тестовых стендов: CLI +принимает `--dev` лишь при `DEBUG=true` (`check_dev_allowed`, OQ-60, issue #9), иначе — код возврата 2 до чтения +seed и подключения к БД. `run_seed(session, dev=True)` защиты не имеет (её вызывают тесты). Удалений нет: роль, +убранная из `users.yaml`, остаётся назначенной. Расширение (задачи 13–15): поле в `SeedData` (`seed_lib/model.py`), читатель в `load_seed`, шаг в `apply_seed`. """ @@ -37,14 +38,41 @@ import argparse import asyncio import sys from pathlib import Path +from typing import TYPE_CHECKING from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from scripts.seed_lib.apply import apply_seed from scripts.seed_lib.loaders import DEFAULT_SEED_DIR, load_seed -from scripts.seed_lib.model import SeedData, SeedError, SeedReport +from scripts.seed_lib.model import SeedError, SeedReport -__all__ = ["DEFAULT_SEED_DIR", "SeedError", "SeedReport", "apply_seed", "load_seed", "main", "run_seed"] +if TYPE_CHECKING: + from src.core.config import Settings + +__all__ = [ + "DEFAULT_SEED_DIR", + "DEV_FORBIDDEN_MESSAGE", + "SeedError", + "SeedReport", + "apply_seed", + "check_dev_allowed", + "load_seed", + "main", + "run_seed", +] + +DEV_FORBIDDEN_MESSAGE = "--dev доступен только при DEBUG=true (dev-пользователи не для прода)" +#: код возврата CLI при `--dev` без DEBUG (1 — ошибка файлов seed) +EXIT_DEV_FORBIDDEN = 2 + + +class DevSeedForbiddenError(Exception): + """`--dev` запрошен при `DEBUG=false`.""" + + +def check_dev_allowed(settings: "Settings") -> str | None: + """Можно ли грузить dev-данные: `None` — да, иначе текст отказа (dev-пользователи только при `DEBUG=true`).""" + return None if settings.DEBUG else DEV_FORBIDDEN_MESSAGE async def run_seed(session: AsyncSession, *, dev: bool = False, seed_dir: Path | None = None) -> SeedReport: @@ -55,9 +83,15 @@ async def run_seed(session: AsyncSession, *, dev: bool = False, seed_dir: Path | return await apply_seed(session, load_seed(seed_dir, dev=dev)) -async def _run_cli(data: SeedData) -> SeedReport: - # импорт здесь: настройки читаются из ENV_FILE только при реальном запуске, - # поэтому `--help` и ошибки валидации работают без env +async def _run_cli(seed_dir: Path, *, dev: bool) -> SeedReport: + # импорт настроек — только после проверки файлов seed: модуль конфигурации читает ENV_FILE при импорте, + # поэтому `--help` и ошибки валидации без `--dev` работают без env; `--dev` сначала сверяется с DEBUG + if dev: + from src.core.config import get_settings # noqa: PLC0415 + + if (refusal := check_dev_allowed(get_settings())) is not None: + raise DevSeedForbiddenError(refusal) + data = load_seed(seed_dir, dev=dev) from src.core.config import settings # noqa: PLC0415 engine = create_async_engine( @@ -80,8 +114,10 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--seed-dir", type=Path, default=DEFAULT_SEED_DIR, help="каталог seed (по умолчанию api/seed)") args = parser.parse_args(argv) try: - data = load_seed(args.seed_dir, dev=args.dev) - report = asyncio.run(_run_cli(data)) + report = asyncio.run(_run_cli(args.seed_dir, dev=args.dev)) + except DevSeedForbiddenError as error: + print(f"Ошибка seed: {error}", file=sys.stderr) + return EXIT_DEV_FORBIDDEN except SeedError as error: print(f"Ошибка seed: {error}", file=sys.stderr) return 1 diff --git a/api/tests/test_seed_dev.py b/api/tests/test_seed_dev.py index facc086..1ccaae2 100644 --- a/api/tests/test_seed_dev.py +++ b/api/tests/test_seed_dev.py @@ -1,13 +1,17 @@ """Dev-seed (spec reference-data-seed, требование «Dev-данные только по явному флагу», задача 15).""" +import os import shutil +import subprocess +import sys from pathlib import Path import pytest from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from scripts.seed import SeedError, load_seed, run_seed +from scripts.seed import DEV_FORBIDDEN_MESSAGE, SeedError, check_dev_allowed, load_seed, run_seed +from src.core.config import get_settings from src.models.access import AppUser, Permission, Role, RolePermission, UserRole from src.models.directories import RegionalOffice from src.models.enums import BusinessSegment @@ -240,3 +244,31 @@ def test_missing_dev_file_is_seed_error_only_with_dev_flag(tmp_path: Path) -> No load_seed(seed_dir) with pytest.raises(SeedError, match="users.yaml"): load_seed(seed_dir, dev=True) + + +# ---- `--dev` только при DEBUG=true (OQ-60) ---- + + +def test_dev_allowed_only_with_debug() -> None: + settings = get_settings() + + assert check_dev_allowed(settings.model_copy(update={"DEBUG": True})) is None + assert check_dev_allowed(settings.model_copy(update={"DEBUG": False})) == DEV_FORBIDDEN_MESSAGE + assert "DEBUG=true" in DEV_FORBIDDEN_MESSAGE + + +def test_cli_dev_without_debug_exits_with_code_2_before_db() -> None: + """`--dev` при DEBUG=false: код 2 и текст в stderr; до БД дело не доходит (переменная окружения важнее ENV_FILE).""" + env = {**os.environ, "ENV_FILE": ".env.test", "DEBUG": "false"} + result = subprocess.run( # noqa: S603 + [sys.executable, "-m", "scripts.seed", "--dev"], + cwd=API_DIR, + capture_output=True, + text=True, + check=False, + env=env, + ) + + assert result.returncode == 2 # noqa: PLR2004 + assert DEV_FORBIDDEN_MESSAGE in result.stderr + assert "Seed применён" not in result.stdout