Админка нужна на «голом» сервере, поэтому возвращаю её в основную ветку. Она выключена, пока не заданы ADMIN_LOGIN/ADMIN_PASSWORD: маршруты /api/admin/* не регистрируются, страницы нет — обычная установка ничего не замечает. Панель (открывается по секретному пути ADMIN_PATH, кнопки входа на сайте нет, вход по логину/паролю): - состояние: группы, когда обновлялось, дата файла на Диске, изменился ли файл при последнем чтении (sha256), ошибки, путь файла настроек; - новая ссылка на таблицу: проверяется скачиванием, при ошибке откат; - интервал обновления (TTL) в минутах; - «Обновить сейчас»: принудительно перечитывает таблицу (качает файл всегда, интервал — только если на Диске изменилась дата); - история входов: последняя попытка (откуда, когда, успешна ли) и последний успешный вход — и в панели, и в логе службы. Защита входа: ADMIN_MAX_ATTEMPTS неудач (по умолчанию 3), затем блокировка на ADMIN_LOCKOUT_MINUTES минут (по умолчанию 15) — всё из .env. Реализация: - backend/admin.py: cookie-сессия на HMAC (ключ — пароль, 12 ч, HttpOnly, SameSite, Secure за https), сравнение за постоянное время, реальный IP из X-Forwarded-For, логирование каждой попытки; - schedule/settings.py: ссылка, интервал и история входов в JSON; путь по умолчанию data/settings.json (в docker — том schedule_data), при недоступности файла настройки живут в памяти и панель предупреждает; - cache.py: apply_link (с проверкой и откатом), force_reload, state(); - frontend: страница админки по ADMIN_PATH (вне static, noindex) и прокси /api/admin/* с телом и Set-Cookie; - install.sh пишет переменные админки в .env, Dockerfile копирует admin.py. Тесты: 156. Проверено локально (страница, 3 попытки -> блокировка, история) и в собранном docker-образе (том настроек, запись истории).
52 lines
2.0 KiB
Python
52 lines
2.0 KiB
Python
"""Проверки сборки образов: все нужные файлы должны копироваться.
|
||
|
||
Ошибка «в Dockerfile забыли COPY какого-то модуля» роняет контейнер на импорте,
|
||
а тесты из исходников её не видят (они запускаются из рабочей копии). Ловим
|
||
статически, без docker.
|
||
"""
|
||
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
DOCKERFILE = ROOT / "Dockerfile"
|
||
COMPOSE = ROOT / "docker-compose.yml"
|
||
|
||
|
||
def _stage(name: str) -> str:
|
||
dockerfile = DOCKERFILE.read_text(encoding="utf-8")
|
||
part = dockerfile.split(f"FROM base AS {name}", 1)[1]
|
||
for other in ("api", "frontend"):
|
||
if other != name:
|
||
part = part.split(f"FROM base AS {other}", 1)[0]
|
||
return part
|
||
|
||
|
||
def test_api_image_copies_every_backend_module():
|
||
stage = _stage("api")
|
||
for module in sorted((ROOT / "src" / "backend").glob("*.py")):
|
||
assert module.name in stage, f"{module.name} не копируется в образ api"
|
||
assert "src/backend/schedule" in stage, "пакет schedule не копируется в образ api"
|
||
|
||
|
||
def test_frontend_image_copies_page_and_static():
|
||
stage = _stage("frontend")
|
||
for path in ("src/frontend/main.py", "src/frontend/admin.html", "src/frontend/static"):
|
||
assert path in stage, f"{path} не копируется в образ frontend"
|
||
|
||
|
||
def test_compose_passes_required_env():
|
||
compose = COMPOSE.read_text(encoding="utf-8")
|
||
for var in (
|
||
"SCHEDULE_LINK",
|
||
"SCHEDULE_INTERVAL_MINUTES",
|
||
"SCHEDULE_API",
|
||
"ADMIN_PATH",
|
||
"ADMIN_LOGIN",
|
||
"ADMIN_PASSWORD",
|
||
"ADMIN_MAX_ATTEMPTS",
|
||
"ADMIN_LOCKOUT_MINUTES",
|
||
"SETTINGS_FILE",
|
||
):
|
||
assert var in compose, f"{var} не передаётся контейнерам"
|
||
assert "schedule_data:/data" in compose, "том для настроек админки не подключён"
|