Админка: секретный путь, смена ссылки, интервал и принудительное обновление

Панель без публичного входа: страница живёт по 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:
2026-09-28 12:06:25 +03:00
parent 0e62d5ca8d
commit cfa6a99639
16 changed files with 1285 additions and 83 deletions

View File

@@ -85,7 +85,7 @@ def make_sheets(sheets: dict[str, SheetSpec]):
return parse_workbook(build_workbook(sheets))
def make_cache(monkeypatch, xlsx: bytes | None, modified: str = "fixed-modified"):
def make_cache(monkeypatch, xlsx: bytes | None, modified: str = "fixed-modified", settings=None):
"""ScheduleCache с заменёнными сетевыми функциями."""
import schedule.cache as cache_mod
from schedule import ScheduleCache
@@ -107,5 +107,5 @@ def make_cache(monkeypatch, xlsx: bytes | None, modified: str = "fixed-modified"
monkeypatch.setattr(cache_mod, "download_schedule", fake_download)
monkeypatch.setattr(cache_mod, "fetch_table_modified", fake_modified)
cache = ScheduleCache("test-link", ttl_seconds=3600, retry_after=60)
cache = ScheduleCache("test-link", ttl_seconds=3600, retry_after=60, settings=settings)
return cache, calls, fail

189
tests/test_admin.py Normal file
View File

@@ -0,0 +1,189 @@
"""Тесты админки: вход по cookie, смена ссылки/интервала, принудительное обновление."""
import json
import time
import pytest
from fastapi.testclient import TestClient
import main
from admin import AdminConfig, make_token, verify_token
from fixtures import fixture_xlsx, make_cache
from schedule import ScheduleError
from schedule.settings import SettingsStore
ADMIN = AdminConfig(path="/secret-panel", login="admin", password="s3cret")
@pytest.fixture()
def store(tmp_path):
return SettingsStore(link="test-link", interval_minutes=15, path=tmp_path / "settings.json")
@pytest.fixture()
def cache(monkeypatch, store):
c, calls, fail = make_cache(monkeypatch, fixture_xlsx(), settings=store)
c.calls = calls
c.fail = fail
return c
@pytest.fixture()
def client(cache):
app = main.create_app(cache, admin=ADMIN)
with TestClient(app) as c:
yield c
def do_login(client, login="admin", password="s3cret"):
return client.post("/api/admin/login", json={"login": login, "password": password})
# --- авторизация ---------------------------------------------------------------
@pytest.mark.parametrize(
"method, path, payload",
[
("get", "/api/admin/state", None),
("post", "/api/admin/refresh", {}),
("post", "/api/admin/link", {"link": "https://disk.yandex.ru/i/x"}),
("post", "/api/admin/interval", {"interval_minutes": 30}),
],
)
def test_requires_login(client, method, path, payload):
resp = getattr(client, method)(path, json=payload) if method == "post" else client.get(path)
assert resp.status_code == 401
@pytest.mark.parametrize(
"login, password",
[("admin", "неверный"), ("неверный", "s3cret"), ("", "")],
)
def test_login_rejects_wrong_credentials(client, login, password):
resp = client.post("/api/admin/login", json={"login": login, "password": password})
assert resp.status_code == 401
assert "schedule_admin" not in client.cookies
def test_login_and_state(client, cache):
assert do_login(client).status_code == 200
resp = client.get("/api/admin/state")
assert resp.status_code == 200
state = resp.json()
assert state["link"] == "test-link"
assert state["interval_minutes"] == 15
assert state["groups"] == 2
assert state["error"] is None
def test_logout(client):
do_login(client)
assert client.get("/api/admin/state").status_code == 200
assert client.post("/api/admin/logout").status_code == 200
assert client.get("/api/admin/state").status_code == 401
def test_token_helpers():
token = make_token("admin", "s3cret", ttl=60)
assert verify_token(token, "s3cret")
assert not verify_token(token, "другой-пароль")
assert not verify_token(token + "x", "s3cret")
assert not verify_token("мусор", "s3cret")
assert not verify_token(make_token("admin", "s3cret", ttl=-5), "s3cret")
# --- интервал ------------------------------------------------------------------
def test_set_interval(client, cache, store):
do_login(client)
resp = client.post("/api/admin/interval", json={"interval_minutes": 30})
assert resp.status_code == 200
assert resp.json()["interval_minutes"] == 30
assert cache.interval_minutes == 30
assert cache._ttl == 1800
assert client.get("/api/admin/state").json()["interval_minutes"] == 30
# сохранилось на диск
saved = json.loads((store._path).read_text(encoding="utf-8"))
assert saved["interval_minutes"] == 30
# новый процесс видит сохранённое значение
assert SettingsStore(path=store._path).interval_minutes == 30
@pytest.mark.parametrize("value, status", [(0, 400), (1441, 400), (-5, 400), ("abc", 422)])
def test_set_interval_validation(client, value, status):
do_login(client)
resp = client.post("/api/admin/interval", json={"interval_minutes": value})
assert resp.status_code == status
# --- смена ссылки ---------------------------------------------------------------
def test_set_link(client, cache, store):
do_login(client)
before = cache.calls["download"]
resp = client.post("/api/admin/link", json={"link": " https://disk.yandex.ru/i/new "})
assert resp.status_code == 200
state = resp.json()
assert state["link"] == "https://disk.yandex.ru/i/new"
assert cache.calls["download"] == before + 1 # перечитали таблицу
assert json.loads(store._path.read_text(encoding="utf-8"))["link"] == "https://disk.yandex.ru/i/new"
def test_set_link_rejects_broken(client, cache, store):
do_login(client)
old_link = cache.state()["link"]
cache.fail["download"] = True
resp = client.post("/api/admin/link", json={"link": "https://disk.yandex.ru/i/broken"})
assert resp.status_code == 400
assert "не подошла" in resp.json()["detail"]
# ссылка и данные не изменились
assert cache.state()["link"] == old_link
assert cache.state()["has_data"] is True
# в файл настроек нерабочая ссылка не попала
if store._path.exists():
assert json.loads(store._path.read_text(encoding="utf-8"))["link"] != "https://disk.yandex.ru/i/broken"
def test_set_empty_link(client, cache):
do_login(client)
resp = client.post("/api/admin/link", json={"link": " "})
assert resp.status_code == 400
assert cache.state()["has_data"] is True
# --- принудительное обновление ---------------------------------------------------
def test_force_refresh_ignores_ttl_and_modified(client, cache):
do_login(client)
client.get("/api/admin/state") # кэш свежий, дальше без принуждения не качали бы
downloads = cache.calls["download"]
cache._ttl = 3600
resp = client.post("/api/admin/refresh")
assert resp.status_code == 200
assert cache.calls["download"] == downloads + 1
assert resp.json()["has_data"] is True
def test_force_refresh_reports_error(client, cache):
do_login(client)
cache.fail["download"] = True
resp = client.post("/api/admin/refresh")
assert resp.status_code == 502
assert cache.state()["error"] is not None
# старые данные не потеряли
assert cache.state()["has_data"] is True
# --- выключенная админка -----------------------------------------------------------
def test_admin_disabled_without_credentials(cache):
app = main.create_app(cache, admin=AdminConfig())
with TestClient(app) as c:
assert c.get("/api/admin/state").status_code == 404
assert c.post("/api/admin/login", json={"login": "a", "password": "b"}).status_code == 404
assert c.get("/healthz").status_code == 200

View File

@@ -196,12 +196,17 @@ def test_cache_failure_without_data_raises(cache):
assert cache.status()["last_updated"] is None
def test_broken_cache_http(client=None):
def test_cache_without_link():
"""Без ссылки приложение живёт: /healthz есть, данные отвечают понятной ошибкой."""
from fastapi.testclient import TestClient as TC
app = main.create_app(main._BrokenCache())
from schedule import ScheduleCache
from schedule.settings import SettingsStore
cache = ScheduleCache(settings=SettingsStore(link=""), retry_after=60)
app = main.create_app(cache)
with TC(app) as c:
assert c.get("/healthz").status_code == 200
assert c.get("/groups").status_code == 502
body = c.get("/status").json()
assert "SCHEDULE_LINK" in body["error"]
assert "Ссылка" in body["error"]

97
tests/test_settings.py Normal file
View 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