Админка в master: секретный путь, ссылка, интервал, force, блокировка входа, история
Админка нужна на «голом» сервере, поэтому возвращаю её в основную ветку. Она выключена, пока не заданы 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-образе (том настроек, запись истории).
This commit is contained in:
@@ -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
|
||||
|
||||
227
tests/test_admin.py
Normal file
227
tests/test_admin.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Тесты админки: вход, блокировка после неудач, история входов, ссылка/интервал/force."""
|
||||
|
||||
import json
|
||||
|
||||
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, store):
|
||||
app = main.create_app(cache, admin=ADMIN, settings=store)
|
||||
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
|
||||
assert state["throttle"]["max_attempts"] == ADMIN.max_attempts
|
||||
|
||||
|
||||
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_lockout_after_configured_attempts(client):
|
||||
"""Разрешённые попытки тратятся, следующая попытка включает паузу."""
|
||||
for _ in range(ADMIN.max_attempts - 1):
|
||||
resp = do_login(client, password="неверный")
|
||||
assert resp.status_code == 401
|
||||
assert "Осталось попыток" in resp.json()["detail"]
|
||||
|
||||
exhausted = do_login(client, password="неверный")
|
||||
assert exhausted.status_code == 429
|
||||
assert "заблокирован" in exhausted.json()["detail"].lower()
|
||||
# пауза действует и для верного пароля
|
||||
assert do_login(client, password="s3cret").status_code == 429
|
||||
|
||||
|
||||
def test_successful_login_resets_failures(client):
|
||||
assert do_login(client, password="неверный").status_code == 401
|
||||
assert do_login(client).status_code == 200
|
||||
# счётчик сброшен: ещё две неудачи не блокируют
|
||||
assert do_login(client, password="неверный").status_code == 401
|
||||
assert do_login(client, password="неверный").status_code == 401
|
||||
assert do_login(client).status_code == 200
|
||||
|
||||
|
||||
# --- история входов ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_login_history_visible_and_saved(client, store):
|
||||
assert do_login(client, password="неверный").status_code == 401
|
||||
assert do_login(client).status_code == 200
|
||||
|
||||
state = client.get("/api/admin/state").json()
|
||||
assert state["last_attempt"]["ok"] is True
|
||||
assert state["last_attempt"]["ip"] # откуда
|
||||
assert state["last_attempt"]["at"] # когда
|
||||
assert state["last_success"]["ip"] == state["last_attempt"]["ip"]
|
||||
|
||||
saved = json.loads(store.path.read_text(encoding="utf-8"))
|
||||
assert saved["last_attempt"]["ok"] is True
|
||||
assert saved["last_success"]["at"]
|
||||
|
||||
|
||||
# --- интервал --------------------------------------------------------------------
|
||||
|
||||
|
||||
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
|
||||
assert resp.json()["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, store):
|
||||
app = main.create_app(cache, admin=AdminConfig(), settings=store)
|
||||
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
|
||||
@@ -254,11 +254,12 @@ def test_cache_without_link():
|
||||
from fastapi.testclient import TestClient as TC
|
||||
|
||||
from schedule import ScheduleCache
|
||||
from schedule.settings import SettingsStore
|
||||
|
||||
cache = ScheduleCache(link="", retry_after=60)
|
||||
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"]
|
||||
|
||||
@@ -28,13 +28,24 @@ def test_api_image_copies_every_backend_module():
|
||||
assert "src/backend/schedule" in stage, "пакет schedule не копируется в образ api"
|
||||
|
||||
|
||||
def test_frontend_image_copies_static():
|
||||
def test_frontend_image_copies_page_and_static():
|
||||
stage = _stage("frontend")
|
||||
for path in ("src/frontend/main.py", "src/frontend/static"):
|
||||
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"):
|
||||
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, "том для настроек админки не подключён"
|
||||
|
||||
125
tests/test_settings.py
Normal file
125
tests/test_settings.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Тесты хранилища настроек админки: ссылка, интервал, история входов."""
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user