удаление test-контура (дополнение)

This commit is contained in:
2026-09-14 20:50:40 +03:00
parent dafbf5bad4
commit 101c457f7e
27 changed files with 195 additions and 375 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ def _ensure_admin(session: Session) -> None:
# Администратор уже существует.
if not settings.is_development:
# В test/prod пароль НЕ перезаписываем (мог быть изменён через панель).
# В prod пароль НЕ перезаписываем (мог быть изменён через панель).
return
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
+19 -10
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import model_validator
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
@@ -18,6 +18,9 @@ _DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
_MIN_SECRET_KEY_LENGTH = 32
# Допустимые окружения. Отдельного test-контура нет: всё проверяется в development.
_APP_ENVS = ("development", "production")
class Settings(BaseSettings):
model_config = SettingsConfigDict(
@@ -27,14 +30,13 @@ class Settings(BaseSettings):
case_sensitive=False,
)
# ── Главный переключатель окружения: development | test | production ───────
# ── Главный переключатель окружения: development | production ─────────────
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
# test — прод-клон в Docker локально (порт 8080), ведёт себя как прод.
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
app_env: str = "development"
log_level: str = "INFO"
# Публикация локального окружения (dev/test) наружу через VPS-туннель.
# Публикация локального dev-окружения наружу через VPS-туннель.
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
local_public: str = "local"
@@ -91,17 +93,13 @@ class Settings(BaseSettings):
стартовый bootstrap в lifespan и синхронизацию админа из .env."""
return self.app_env.lower() == "development"
@property
def is_test(self) -> bool:
return self.app_env.lower() == "test"
@property
def is_production(self) -> bool:
return self.app_env.lower() == "production"
@property
def database_url(self) -> str:
"""БД: dev — отдельный файл дева; test и prod — том контейнера (/data)."""
"""БД: dev — отдельный файл дева; prod — том контейнера (/data)."""
return self.dev_database_url if self.is_development else self.prod_database_url
@property
@@ -122,13 +120,24 @@ class Settings(BaseSettings):
def cookie_domain_value(self) -> str | None:
return self.cookie_domain or None
@field_validator("app_env")
@classmethod
def _known_app_env(cls, value: str) -> str:
"""Неизвестное окружение — ошибка старта, а не молчаливое «почти прод»: любое
значение, кроме development, выбирает прод-пути к данным и выключает dev-вход."""
if value.lower() not in _APP_ENVS:
raise ValueError(
f"APP_ENV={value!r} не поддерживается — допустимо: {', '.join(_APP_ENVS)}"
)
return value
@model_validator(mode="after")
def _forbid_default_secrets_in_prod(self) -> "Settings":
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
известным паролем администратора. В dev/test проверка не мешает — там дефолты норма."""
известным паролем администратора. В dev проверка не мешает — там дефолты норма."""
if self.app_env.lower() != "production":
return self
problems: list[str] = []
+4 -4
View File
@@ -132,7 +132,7 @@ async def _lifespan(_app: FastAPI):
hub.bind_loop(asyncio.get_running_loop())
# В DEV приложение само подтягивает справочники и админа из .env при старте
# (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
try:
from app.bootstrap import bootstrap
@@ -151,7 +151,7 @@ async def _lifespan(_app: FastAPI):
def create_app() -> FastAPI:
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev: она нужна для
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
# незачем облегчать разведку поверхности API анонимам (#61).
docs_enabled = not settings.is_production
@@ -165,7 +165,7 @@ def create_app() -> FastAPI:
)
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin).
# В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем.
# В prod (и в dev через VPS-туннель) всё single-origin → CORS не подключаем.
if settings.is_development and settings.cors_origins_list:
app.add_middleware(
CORSMiddleware,
@@ -204,7 +204,7 @@ def create_app() -> FastAPI:
app.include_router(r, prefix="/api")
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
# и только если код физически есть (в test/prod-образе dev_*-файлы исключены
# и только если код физически есть (в прод-образе dev_*-файлы исключены
# .dockerignore, импорт просто не выполнится).
if settings.is_development:
for mod_name in ("dev_auth", "dev_admin"):
+1 -1
View File
@@ -153,7 +153,7 @@ def set_user_password(
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только
# routers/dev_admin.py (исключён из прод-образа). В проде аккаунт только
# отключается (PATCH is_active), удалять нельзя.
+2 -2
View File
@@ -1,9 +1,9 @@
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер
Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только
возможность удаления не попадает в прод — там аккаунт можно только
отключить (PATCH is_active).
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
+1 -1
View File
@@ -74,7 +74,7 @@ def set_player_password(session: Session, user_id: int, new_password: str) -> Us
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
# логика вынесена в routers/dev_admin.py (файл исключён из прод-образа).
# ─── Группы ──────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,4 +1,4 @@
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6)."""
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev (#61, F6)."""
from __future__ import annotations
from fastapi.testclient import TestClient
+1 -5
View File
@@ -23,14 +23,12 @@ def test_enabled_methods_by_env(monkeypatch):
monkeypatch.setattr(settings, "app_env", "development")
assert set(enabled_methods()) == {"password", "telegram", "stub"}
monkeypatch.setattr(settings, "app_env", "test")
assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub
monkeypatch.setattr(settings, "app_env", "production")
assert enabled_methods() == ["password", "telegram"] # prod → без stub
def test_env_flags_and_db_path(monkeypatch):
"""dev → файл дева; test и prod → том /data (общая ветвь is_development)."""
"""dev → файл дева; prod → том /data."""
from app.core.config import settings
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db")
@@ -38,8 +36,6 @@ def test_env_flags_and_db_path(monkeypatch):
monkeypatch.setattr(settings, "app_env", "development")
assert settings.is_development and settings.database_url == "sqlite:///dev.db"
monkeypatch.setattr(settings, "app_env", "test")
assert settings.is_test and settings.database_url == "sqlite:////data/prod.db"
monkeypatch.setattr(settings, "app_env", "production")
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
+12
View File
@@ -65,3 +65,15 @@ def test_development_allows_defaults():
admin_password=config._DEFAULT_ADMIN_PASSWORD,
)
assert s.is_development
@pytest.mark.parametrize("app_env", ["test", "staging", ""])
def test_unknown_app_env_rejected(app_env):
# Отдельного test-контура больше нет: такое значение не должно молча включать прод-пути.
with pytest.raises(ValidationError):
config.Settings(app_env=app_env)
def test_app_env_case_insensitive():
s = config.Settings(app_env="Development")
assert s.is_development