Интерфейс: - после входа на главной появляются «Админка» и «Выйти» — в шапке, перед переключателем темы и после него соответственно (порядок: Админка, тема, Выйти); - «Выйти» теперь красная и заметно реагирует на наведение (темнеет, приподнимается, тень) — и на сайте, и в админке (одна кнопка в шапке, из карточки убрана); - в админке убраны «осталось попыток», путь к файлу настроек и карточка «Данные расписания» (дублировала шапку); блокировка показывается отдельной плашкой только когда действительно активна. Безопасность пути: - путь к панели не отдаётся ни в HTML, ни в JSON (из /api/admin/state убран), ни в JS: на главной для вошедшего только флаг, а кнопка «Админка» ведёт на служебный /api/admin/goto, который отвечает 302 на секретный путь; - без сессии /api/admin/goto и /api/admin/state отвечают 401, неизвестные пути дают 404 — перебором путь не находится; - страница панели: noindex/nofollow/noarchive, Referrer-Policy: no-referrer, X-Frame-Options: DENY, Cache-Control: no-store; - robots.txt без пути (только Disallow: /api/), внешние ссылки в панели — rel=noopener noreferrer. Тесты: 161 (состояние без пути, /goto: 401 без входа и 302 с ним).
240 lines
9.2 KiB
Python
240 lines
9.2 KiB
Python
"""Тесты админки: вход, блокировка после неудач, история входов, ссылка/интервал/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
|
||
# путь к панели не должен утекать в JSON: переход идёт через /goto
|
||
assert "admin_path" not in state
|
||
assert ADMIN.path not in str(state)
|
||
|
||
|
||
def test_goto_redirects_to_panel_only_for_authorized(client):
|
||
"""Переход в панель: без сессии 401, с сессией — редирект на секретный путь."""
|
||
assert client.get("/api/admin/goto", follow_redirects=False).status_code == 401
|
||
do_login(client)
|
||
resp = client.get("/api/admin/goto", follow_redirects=False)
|
||
assert resp.status_code == 302
|
||
assert resp.headers["location"] == ADMIN.path
|
||
|
||
|
||
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
|