mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
258 lines
8.5 KiB
Python
258 lines
8.5 KiB
Python
"""
|
||
验证Web Push 订阅保存、清理与权限校验。
|
||
"""
|
||
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.adapters.web.security import access
|
||
from app.api.context import get_host_runtime, get_sync_session
|
||
from app.api.endpoints import message as message_endpoint
|
||
from app.api.endpoints.message import is_webpush_subscription_gone
|
||
from app.application.security.token import create_access_token
|
||
from app.runtime.config import global_vars, settings
|
||
from app.runtime.webpush import webpush_registry
|
||
from app.schemas.message import SubscriptionMessage
|
||
from app.schemas.token import TokenPayload
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def clear_push_subscriptions():
|
||
"""
|
||
在每个用例前后清理跨用例共享的 Web Push 订阅。
|
||
"""
|
||
with global_vars.SUBSCRIPTIONS_LOCK:
|
||
global_vars.SUBSCRIPTIONS.clear()
|
||
yield
|
||
with global_vars.SUBSCRIPTIONS_LOCK:
|
||
global_vars.SUBSCRIPTIONS.clear()
|
||
|
||
|
||
def test_push_subscription_upserts_by_endpoint():
|
||
"""
|
||
相同 endpoint 的 Web Push 订阅应更新而不是重复追加。
|
||
"""
|
||
global_vars.push_subscription(
|
||
{"endpoint": "https://push.example/a", "keys": {"p256dh": "old"}}
|
||
)
|
||
global_vars.push_subscription(
|
||
{"endpoint": "https://push.example/a", "keys": {"p256dh": "new"}}
|
||
)
|
||
|
||
subscriptions = global_vars.get_subscriptions()
|
||
|
||
assert len(subscriptions) == 1
|
||
assert subscriptions[0]["keys"]["p256dh"] == "new"
|
||
|
||
|
||
def test_remove_subscription_deletes_by_endpoint():
|
||
"""
|
||
失效订阅应能按 endpoint 从全局订阅表删除。
|
||
"""
|
||
subscription = {"endpoint": "https://push.example/a", "keys": {}}
|
||
global_vars.push_subscription(subscription)
|
||
|
||
assert global_vars.remove_subscription(subscription)
|
||
assert global_vars.get_subscriptions() == []
|
||
|
||
|
||
def test_global_vars_webpush_api_delegates_to_canonical_registry():
|
||
"""
|
||
旧订阅 API 与 canonical WebPushRegistry 必须共享同一事实源。
|
||
"""
|
||
subscription = {"endpoint": "https://push.example/registry", "keys": {}}
|
||
|
||
webpush_registry.upsert(subscription)
|
||
|
||
assert global_vars.get_subscriptions() == [subscription]
|
||
assert global_vars.remove_subscription(subscription)
|
||
assert webpush_registry.list() == []
|
||
|
||
|
||
def test_is_webpush_subscription_gone_matches_404_and_410():
|
||
"""
|
||
推送服务返回 404/410 时应识别为订阅已失效。
|
||
"""
|
||
assert is_webpush_subscription_gone(
|
||
SimpleNamespace(response=SimpleNamespace(status_code=410))
|
||
)
|
||
assert is_webpush_subscription_gone(
|
||
SimpleNamespace(response=SimpleNamespace(status=404))
|
||
)
|
||
assert not is_webpush_subscription_gone(
|
||
SimpleNamespace(response=SimpleNamespace(status_code=500))
|
||
)
|
||
|
||
|
||
def test_send_notification_reports_all_delivery_failures(monkeypatch):
|
||
"""
|
||
所有浏览器通知发送失败时,接口应返回可执行的失败提示。
|
||
"""
|
||
webpush_registry.upsert(
|
||
{"endpoint": "https://push.example/a", "keys": {"p256dh": "key"}}
|
||
)
|
||
monkeypatch.setattr(
|
||
message_endpoint,
|
||
"get_api_runtime_config_snapshot",
|
||
lambda: SimpleNamespace(vapid_private_key="private", vapid_subject="mailto:test@example.com"),
|
||
)
|
||
|
||
from app.adapters.network.webpush import WebPushDeliveryError
|
||
|
||
def fail_delivery(**_):
|
||
"""
|
||
模拟推送服务拒收。
|
||
"""
|
||
raise WebPushDeliveryError("failed")
|
||
|
||
monkeypatch.setattr("app.adapters.network.webpush.send_webpush", fail_delivery)
|
||
|
||
response = message_endpoint.send_notification(SubscriptionMessage(title="测试"), object())
|
||
|
||
assert response.success is False
|
||
assert response.message == "消息发送失败,请检查浏览器通知权限后重试"
|
||
|
||
|
||
def test_send_notification_reports_partial_delivery(monkeypatch):
|
||
"""
|
||
部分设备发送成功时,接口应同时告知成功和失败数量。
|
||
"""
|
||
webpush_registry.upsert({"endpoint": "https://push.example/a", "keys": {}})
|
||
webpush_registry.upsert({"endpoint": "https://push.example/b", "keys": {}})
|
||
monkeypatch.setattr(
|
||
message_endpoint,
|
||
"get_api_runtime_config_snapshot",
|
||
lambda: SimpleNamespace(vapid_private_key="private", vapid_subject="mailto:test@example.com"),
|
||
)
|
||
|
||
from app.adapters.network.webpush import WebPushDeliveryError
|
||
|
||
attempts = iter([None, WebPushDeliveryError("failed")])
|
||
|
||
def deliver_once(**_):
|
||
"""
|
||
模拟一次成功和一次失败的设备发送。
|
||
"""
|
||
attempt = next(attempts)
|
||
if isinstance(attempt, Exception):
|
||
raise attempt
|
||
return attempt
|
||
|
||
monkeypatch.setattr("app.adapters.network.webpush.send_webpush", deliver_once)
|
||
|
||
response = message_endpoint.send_notification(SubscriptionMessage(title="测试"), object())
|
||
|
||
assert response.success is True
|
||
assert response.message == "消息已发送到 1 个设备,1 个设备发送失败"
|
||
|
||
|
||
def _webpush_send_client(monkeypatch, user) -> tuple[TestClient, list[dict]]:
|
||
"""
|
||
构造挂载真实消息路由的客户端,并记录实际发出的浏览器通知。
|
||
"""
|
||
deliveries = []
|
||
webpush_registry.upsert({"endpoint": "https://push.example/a", "keys": {}})
|
||
monkeypatch.setattr(access, "_token_identity_validator", lambda _payload: None)
|
||
monkeypatch.setattr(
|
||
message_endpoint,
|
||
"get_api_runtime_config_snapshot",
|
||
lambda: SimpleNamespace(vapid_private_key="private", vapid_subject="mailto:test@example.com"),
|
||
)
|
||
monkeypatch.setattr(
|
||
"app.adapters.network.webpush.send_webpush",
|
||
lambda **kwargs: deliveries.append(kwargs),
|
||
)
|
||
repository = SimpleNamespace(get_by_id=lambda user_id: user if user_id == user.id else None)
|
||
app = FastAPI()
|
||
app.include_router(message_endpoint.router, prefix="/message")
|
||
app.dependency_overrides[get_host_runtime] = lambda: SimpleNamespace(
|
||
authentication=SimpleNamespace(user_repository=lambda _db: repository)
|
||
)
|
||
app.dependency_overrides[get_sync_session] = lambda: object()
|
||
return TestClient(app), deliveries
|
||
|
||
|
||
def _bearer(user) -> dict[str, str]:
|
||
"""
|
||
为测试用户生成登录 JWT 请求头。
|
||
"""
|
||
token = create_access_token(
|
||
userid=user.id,
|
||
username=user.name,
|
||
super_user=user.is_superuser,
|
||
)
|
||
return {"Authorization": f"Bearer {token}"}
|
||
|
||
|
||
def _user(*, superuser: bool) -> SimpleNamespace:
|
||
"""
|
||
构造当前用户依赖所需的最小用户投影。
|
||
"""
|
||
return SimpleNamespace(id=7, name="tester", is_active=True, is_superuser=superuser)
|
||
|
||
|
||
def test_send_notification_rejects_regular_user_without_delivery(monkeypatch):
|
||
"""
|
||
普通用户调用 webpush/send 应返回 403,且不向任何订阅发送通知。
|
||
"""
|
||
user = _user(superuser=False)
|
||
client, deliveries = _webpush_send_client(monkeypatch, user)
|
||
|
||
with client:
|
||
response = client.post(
|
||
"/message/webpush/send",
|
||
json={"title": "测试", "url": "https://example.com"},
|
||
headers=_bearer(user),
|
||
)
|
||
|
||
assert response.status_code == 403
|
||
assert deliveries == []
|
||
|
||
|
||
def test_send_notification_allows_superuser(monkeypatch):
|
||
"""
|
||
超级管理员登录后可以向已订阅浏览器发送通知。
|
||
"""
|
||
user = _user(superuser=True)
|
||
client, deliveries = _webpush_send_client(monkeypatch, user)
|
||
|
||
with client:
|
||
response = client.post(
|
||
"/message/webpush/send",
|
||
json={"title": "测试"},
|
||
headers=_bearer(user),
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
assert response.json()["success"] is True
|
||
assert len(deliveries) == 1
|
||
assert deliveries[0]["subscription"]["endpoint"] == "https://push.example/a"
|
||
|
||
|
||
def test_send_notification_allows_api_token(monkeypatch):
|
||
"""
|
||
使用 API_TOKEN 的外部集成按超级管理员身份继续可以发送通知。
|
||
"""
|
||
user = _user(superuser=True)
|
||
client, deliveries = _webpush_send_client(monkeypatch, user)
|
||
monkeypatch.setattr(settings, "API_TOKEN", "webpush-test-api-token")
|
||
monkeypatch.setattr(
|
||
access,
|
||
"_superuser_token_payload_provider",
|
||
lambda: TokenPayload(sub=user.id, username=user.name, super_user=True, purpose="authentication"),
|
||
)
|
||
|
||
with client:
|
||
response = client.post(
|
||
"/message/webpush/send",
|
||
json={"title": "测试"},
|
||
headers={"X-API-KEY": "webpush-test-api-token"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
assert response.json()["success"] is True
|
||
assert len(deliveries) == 1
|