Админка: секретный путь, смена ссылки, интервал и принудительное обновление
Панель без публичного входа: страница живёт по ADMIN_PATH, логин/пароль — ADMIN_LOGIN/ADMIN_PASSWORD из .env (пусто = админка выключена и маршруты /api/admin/* не регистрируются, прод можно не трогать). Умеет: посмотреть текущую ссылку и состояние кэша, вставить новую ссылку (проверяется скачиванием и разбором до сохранения, при ошибке откат), принудительно обновить таблицу вне интервала и без проверки modified, задать интервал в минутах (1..1440). Реализация: - backend/admin.py — cookie-сессия на HMAC (ключ = пароль, 12 часов, HttpOnly/SameSite/Secure-за-https), сравнение за постоянное время; compare_digest по байтам — кириллица в логине не роняет сервер; - schedule/settings.py — ссылка и интервал в JSON (SETTINGS_FILE), атомарная запись, терпимость к битому/недоступному файлу (тогда живёт в памяти); - schedule/cache.py — интервал вместо TTL, apply_link с откатом, force_reload, state() для панели; кэш без ссылки больше не роняет старт; - frontend: страница admin.html (вне static, прямой /admin.html закрыт) и прокси /api/admin/* с телом и Set-Cookie; - docker-compose: том schedule_data для настроек, ADMIN_* в окружении; - deploy/: nginx-конфиг и инструкция для стенда dev.schedule.our-place.ru. Тесты: 138 (новые — админ-API, сессии, ссылка/интервал, force, хранилище).
This commit is contained in:
97
tests/test_settings.py
Normal file
97
tests/test_settings.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Тесты хранилища настроек администратора (ссылка, интервал)."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
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_no_path_keeps_settings_in_memory():
|
||||
store = SettingsStore(link="l", interval_minutes=3)
|
||||
assert store.set_interval_minutes(10) == 10
|
||||
assert store.interval_minutes == 10
|
||||
assert store.set_link("l2") == "l2"
|
||||
assert store.link == "l2"
|
||||
|
||||
|
||||
def test_unwritable_path_does_not_crash(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
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user