Админка нужна на «голом» сервере, поэтому возвращаю её в основную ветку. Она выключена, пока не заданы 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-образе (том настроек, запись истории).
126 lines
4.4 KiB
Python
126 lines
4.4 KiB
Python
"""Тесты хранилища настроек админки: ссылка, интервал, история входов."""
|
||
|
||
import json
|
||
|
||
import pytest
|
||
|
||
from schedule import ScheduleError
|
||
from schedule.settings import (
|
||
DEFAULT_INTERVAL_MINUTES,
|
||
SettingsStore,
|
||
clamp_interval,
|
||
interval_from_env,
|
||
)
|
||
|
||
|
||
def test_defaults():
|
||
store = SettingsStore()
|
||
assert store.link == ""
|
||
assert store.interval_minutes == DEFAULT_INTERVAL_MINUTES
|
||
assert store.last_attempt is None
|
||
assert store.last_success is None
|
||
|
||
|
||
def test_persist_and_reload(tmp_path):
|
||
path = tmp_path / "settings.json"
|
||
store = SettingsStore(link="l1", interval_minutes=20, path=path)
|
||
store.set_link("l2")
|
||
store.set_interval_minutes(45)
|
||
|
||
again = SettingsStore(link="из-окружения", interval_minutes=1, path=path)
|
||
assert again.link == "l2"
|
||
assert again.interval_minutes == 45
|
||
|
||
|
||
def test_file_overrides_env(tmp_path):
|
||
path = tmp_path / "settings.json"
|
||
path.write_text(json.dumps({"link": "из-файла", "interval_minutes": 5}), encoding="utf-8")
|
||
store = SettingsStore(link="из-окружения", interval_minutes=99, path=path)
|
||
assert store.link == "из-файла"
|
||
assert store.interval_minutes == 5
|
||
|
||
|
||
def test_interval_validation():
|
||
store = SettingsStore()
|
||
for bad in (0, 1441, -5, "abc"):
|
||
with pytest.raises(ScheduleError):
|
||
store.set_interval_minutes(bad)
|
||
assert store.set_interval_minutes("30") == 30
|
||
|
||
|
||
def test_empty_link_rejected():
|
||
store = SettingsStore()
|
||
with pytest.raises(ScheduleError):
|
||
store.set_link(" ")
|
||
with pytest.raises(ScheduleError):
|
||
store.set_link("")
|
||
|
||
|
||
def test_corrupt_file_is_tolerated(tmp_path):
|
||
path = tmp_path / "settings.json"
|
||
path.write_text("{это не json", encoding="utf-8")
|
||
store = SettingsStore(link="env-link", interval_minutes=7, path=path)
|
||
assert store.link == "env-link"
|
||
assert store.interval_minutes == 7
|
||
|
||
|
||
def test_login_history_saved(tmp_path):
|
||
path = tmp_path / "settings.json"
|
||
store = SettingsStore(link="l", path=path)
|
||
|
||
store.record_login("10.0.0.1", ok=False)
|
||
assert store.last_attempt["ip"] == "10.0.0.1"
|
||
assert store.last_attempt["ok"] is False
|
||
assert store.last_attempt["at"]
|
||
assert store.last_success is None # успешных ещё не было
|
||
|
||
store.record_login("10.0.0.2", ok=True)
|
||
assert store.last_success["ip"] == "10.0.0.2"
|
||
|
||
# история переживает перезапуск: читаем тем же путём
|
||
again = SettingsStore(path=path)
|
||
assert again.last_attempt["ip"] == "10.0.0.2"
|
||
assert again.last_attempt["ok"] is True
|
||
assert again.last_success["ip"] == "10.0.0.2"
|
||
|
||
|
||
def test_unwritable_path_marks_save_error(tmp_path):
|
||
blocker = tmp_path / "blocker"
|
||
blocker.write_text("x", encoding="utf-8")
|
||
store = SettingsStore(link="l", interval_minutes=3, path=blocker / "settings.json")
|
||
assert store.set_interval_minutes(10) == 10 # в памяти меняется
|
||
assert store.interval_minutes == 10
|
||
assert store.save_error # и видно, что на диск не сохранилось
|
||
|
||
|
||
def test_default_settings_path_is_inside_project():
|
||
"""Путь по умолчанию вычисляется от корня проекта, а не от глубины файла.
|
||
|
||
В docker-образе файлы лежат плоско (/app/...), поэтому глубину не считаем.
|
||
"""
|
||
from schedule.settings import DEFAULT_SETTINGS_PATH
|
||
|
||
assert DEFAULT_SETTINGS_PATH.name == "settings.json"
|
||
assert DEFAULT_SETTINGS_PATH.parent.name == "data"
|
||
root = DEFAULT_SETTINGS_PATH.parent.parent
|
||
assert (root / "requirements.txt").is_file() # корень найден по маркеру
|
||
|
||
|
||
def test_clamp_interval():
|
||
assert clamp_interval(0) == 1
|
||
assert clamp_interval(99999) == 1440
|
||
assert clamp_interval(30) == 30
|
||
|
||
|
||
def test_interval_from_env(monkeypatch):
|
||
monkeypatch.delenv("SCHEDULE_INTERVAL_MINUTES", raising=False)
|
||
monkeypatch.delenv("SCHEDULE_CACHE_TTL", raising=False)
|
||
assert interval_from_env() == DEFAULT_INTERVAL_MINUTES
|
||
|
||
# старый SCHEDULE_CACHE_TTL (в секундах) продолжает работать
|
||
monkeypatch.setenv("SCHEDULE_CACHE_TTL", "900")
|
||
assert interval_from_env() == 15
|
||
|
||
monkeypatch.setenv("SCHEDULE_INTERVAL_MINUTES", "7")
|
||
assert interval_from_env() == 7
|