"""Тесты API системных уведомлений (ТЗ 3.21): config, подписки, изоляция.
По умолчанию ключей VAPID нет — push выключен, и это первое, что проверяем: без
ключей подписываться некуда, API обязан сказать об этом честно, а не падать на
отправке позже. Ключи в тестах включаются точечно (`vapid_keys`), причём фикстура
ниже глушит и те, что лежат в `.env` разработчика: набор не должен зависеть от
того, завёл ли он себе push.
"""
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.config import get_settings
from app.dependencies import require_user
from app.main import app
from app.models import PushSubscription
from tests.conftest import _test_session_factory # type: ignore[attr-defined]
from tests.test_multiuser import switch_user
ENDPOINT = "https://push.example.com/sub/abc123"
PAYLOAD = {"endpoint": ENDPOINT, "keys": {"p256dh": "p256dh-key", "auth": "auth-key"}}
@pytest.fixture(autouse=True)
def _no_vapid_keys() -> Iterator[None]:
settings = get_settings()
old = (settings.vapid_public_key, settings.vapid_private_key, settings.vapid_subject)
settings.vapid_public_key = settings.vapid_private_key = settings.vapid_subject = ""
yield
settings.vapid_public_key, settings.vapid_private_key, settings.vapid_subject = old
@contextmanager
def vapid_keys(public: str = "pub-key", private: str = "priv-key") -> Iterator[None]:
"""Включить push на время теста: ключи живут в настройках, а не в БД."""
settings = get_settings()
old = (settings.vapid_public_key, settings.vapid_private_key, settings.vapid_subject)
settings.vapid_public_key, settings.vapid_private_key = public, private
settings.vapid_subject = "mailto:test@example.com"
try:
yield
finally:
settings.vapid_public_key, settings.vapid_private_key, settings.vapid_subject = old
def _subscriptions() -> list[PushSubscription]:
session = _test_session_factory()
try:
return list(session.scalars(select(PushSubscription)).all())
finally:
session.close()
def test_config_reports_push_off_without_keys(client: TestClient) -> None:
body = client.get("/api/push/config").json()
assert body == {"enabled": False, "public_key": None}
def test_config_returns_public_key_when_configured(client: TestClient) -> None:
with vapid_keys():
assert client.get("/api/push/config").json() == {"enabled": True, "public_key": "pub-key"}
def test_subscribe_is_rejected_when_push_is_off(client: TestClient) -> None:
# 503, а не 500: сервер настроен не полностью, и фронт покажет это словами
assert client.post("/api/push/subscribe", json=PAYLOAD).status_code == 503
assert _subscriptions() == []
def test_subscribe_stores_device_with_user_agent(client: TestClient) -> None:
with vapid_keys():
resp = client.post(
"/api/push/subscribe", json=PAYLOAD, headers={"User-Agent": "Phone/1.0 (Android)"}
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["user_agent"] == "Phone/1.0 (Android)"
assert body["last_success_at"] is None
(sub,) = _subscriptions()
assert (sub.user_id, sub.endpoint, sub.p256dh, sub.auth) == (
"1",
ENDPOINT,
"p256dh-key",
"auth-key",
)
def test_subscriptions_are_listed_and_removed(client: TestClient) -> None:
with vapid_keys():
sub_id = client.post("/api/push/subscribe", json=PAYLOAD).json()["id"]
listed = client.get("/api/push/subscriptions").json()
assert [item["id"] for item in listed] == [sub_id]
assert client.delete(f"/api/push/subscriptions/{sub_id}").json() == {"ok": True}
assert client.get("/api/push/subscriptions").json() == []
def test_unsubscribe_by_endpoint_is_idempotent(client: TestClient) -> None:
with vapid_keys():
client.post("/api/push/subscribe", json=PAYLOAD)
assert client.request(
"DELETE", "/api/push/subscribe", json={"endpoint": ENDPOINT}
).json() == {"ok": True}
# повторная отписка — тоже успех: браузер мог потерять подписку сам
assert client.request(
"DELETE", "/api/push/subscribe", json={"endpoint": ENDPOINT}
).json() == {"ok": True}
assert _subscriptions() == []
def test_same_endpoint_is_rebound_to_the_new_user(client: TestClient) -> None:
"""Один браузерный профиль, два аккаунта: подписка переезжает, а не двоится."""
other = {"endpoint": ENDPOINT, "keys": {"p256dh": "чужой-ключ", "auth": "чужой-auth"}}
with vapid_keys():
client.post("/api/push/subscribe", json=PAYLOAD)
with switch_user("other-user"):
client.post("/api/push/subscribe", json=other)
assert [s.user_id for s in _subscriptions()] == ["other-user"]
assert client.get("/api/push/subscriptions").json() != []
# прежний владелец устройство больше не видит и удалить по id не может
assert client.get("/api/push/subscriptions").json() == []
def test_device_of_another_user_is_not_visible(client: TestClient) -> None:
with vapid_keys():
sub_id = client.post("/api/push/subscribe", json=PAYLOAD).json()["id"]
with switch_user("other-user"):
assert client.delete(f"/api/push/subscriptions/{sub_id}").status_code == 404
assert len(_subscriptions()) == 1
@pytest.mark.parametrize(
"schema",
[
{"endpoint": "short", "keys": {"p256dh": "a", "auth": "b"}},
{"endpoint": ENDPOINT, "keys": {"p256dh": "", "auth": "b"}},
{"endpoint": ENDPOINT},
],
)
def test_broken_payloads_are_rejected(client: TestClient, schema: dict[str, Any]) -> None:
with vapid_keys():
assert client.post("/api/push/subscribe", json=schema).status_code == 422
def test_requires_authentication(client: TestClient, monkeypatch: Any) -> None:
"""Устройства принадлежат пользователю: без сессии API их не показывает."""
monkeypatch.delitem(app.dependency_overrides, require_user)
assert client.get("/api/push/subscriptions").status_code == 401