mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 04:47:03 +08:00
feat: 自动登记中心推送实例并安全轮换凭据
This commit is contained in:
36
app/adapters/external/server.py
vendored
36
app/adapters/external/server.py
vendored
@@ -21,7 +21,7 @@ from typing import (
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from urllib.parse import parse_qs, quote, urlparse, urlsplit
|
||||
from urllib.parse import parse_qs, quote, urlparse, urlsplit, urlunsplit
|
||||
|
||||
from httpx2 import Response as AsyncResponse
|
||||
from requests import Response
|
||||
@@ -195,6 +195,21 @@ class MoviePilotServerHelper:
|
||||
_user_uid: Optional[str] = None
|
||||
_github_user: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def normalize_relay_url(server: str) -> str:
|
||||
"""
|
||||
校验中转地址并统一持久凭据的作用域,不允许凭据、查询参数或片段。
|
||||
|
||||
:param server: 管理员配置的 HTTPS 服务根地址,回环调试允许 HTTP
|
||||
:return: 规范化且去除尾部斜线的地址
|
||||
:raises ValueError: 地址不能安全接收实例凭据
|
||||
"""
|
||||
target = urlsplit(server)
|
||||
local = target.hostname in {"localhost", "127.0.0.1", "::1"}
|
||||
if (target.scheme != "https" and not (local and target.scheme == "http")) or not target.hostname or target.username or target.password or target.query or target.fragment:
|
||||
raise ValueError("中转地址必须使用 HTTPS,本机回环调试除外")
|
||||
return urlunsplit((target.scheme, target.netloc.lower(), target.path.rstrip("/"), "", ""))
|
||||
|
||||
@staticmethod
|
||||
def relay_push(server: str, key: str, method: str, path: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -207,21 +222,28 @@ class MoviePilotServerHelper:
|
||||
:param data: 已绑定当前用户的注册或消息数据
|
||||
:return: Server 返回的对象;失败抛出不含凭据或原始响应的 ValueError
|
||||
"""
|
||||
target = urlsplit(server)
|
||||
local = target.hostname in {"localhost", "127.0.0.1", "::1"}
|
||||
if (target.scheme != "https" and not (local and target.scheme == "http")) or not target.hostname or target.username or target.password or target.query or target.fragment:
|
||||
raise ValueError("中转地址必须使用 HTTPS,本机回环调试除外")
|
||||
if method not in {"GET", "POST", "DELETE"} or path not in {"devices", "pairings", "messages"}:
|
||||
server = MoviePilotServerHelper.normalize_relay_url(server)
|
||||
allowed = {("GET", "devices"), ("POST", "devices"), ("DELETE", "devices"),
|
||||
("POST", "pairings"), ("POST", "messages"), ("POST", "instances"),
|
||||
("PUT", "instances/credentials"), ("DELETE", "instances")}
|
||||
if (method, path) not in allowed:
|
||||
raise ValueError("不支持的通知接口")
|
||||
if len(key) < 32:
|
||||
raise ValueError("请配置至少 32 位的中转发送密钥")
|
||||
response = RequestUtils(timeout=60, headers={"Authorization": f"Bearer {key}"}).request(
|
||||
# 首次登记或恢复轮换之后还要执行原操作,两段请求均需落在 App 的65秒截止时间内。
|
||||
response = RequestUtils(timeout=20, headers={"Authorization": f"Bearer {key}"}).request(
|
||||
method, f"{server.rstrip('/')}/push/{path}", allow_redirects=False, verify=True,
|
||||
params=data if method == "GET" else None,
|
||||
json=data if method != "GET" else None,
|
||||
)
|
||||
if response is None or response.status_code != 200:
|
||||
code = response.status_code if response is not None else 0
|
||||
if code == 403 and path == "instances":
|
||||
raise ValueError("消息中心尚未开放自动接入,请联系中心运营者")
|
||||
if code == 401:
|
||||
raise ValueError("通知接入凭据无效或已停用,请联系中心运营者")
|
||||
if code == 429:
|
||||
raise ValueError("消息中心请求过于频繁,请稍后重试")
|
||||
raise ValueError(f"私有通知服务请求失败({code})")
|
||||
try:
|
||||
return _response_mapping(response)
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.api.response import ResponseAPIRouter
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.chain.notification import NotificationChain
|
||||
from app.schemas.common import ManageRequest as _SchemaManageRequest
|
||||
from app.schemas.push import AppPushRequest
|
||||
from app.schemas.push import AppPushInstanceRequest, AppPushRequest
|
||||
from app.schemas.response import Response as _SchemaResponse
|
||||
from app.schemas.system import NotificationConf
|
||||
from app.schemas.types import SystemConfigKey
|
||||
@@ -20,6 +20,17 @@ from app.schemas.types import SystemConfigKey
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.post("/app/instance", summary="轮换 App 通知实例凭据", response_model=_SchemaResponse[Dict[str, Any]])
|
||||
def manage_app_instance(request: AppPushInstanceRequest,
|
||||
current_user: ApiPrincipal = Depends(get_current_active_superuser)) -> _SchemaResponse[object]:
|
||||
"""
|
||||
仅容器管理员能轮换整个实例的发送凭据,响应只包含结果,不下发密钥。
|
||||
"""
|
||||
result = NotificationChain().manage_channel(channel="AppPush", action="rotate_instance", source=request.source,
|
||||
owner=current_user.notification_identity, administrative=True)
|
||||
return _SchemaResponse(success=bool(result.get("success")), message=result.get("message") or "", data=result.get("data"))
|
||||
|
||||
|
||||
@router.post("/app", summary="管理当前用户的 App 通知", response_model=_SchemaResponse[Dict[str, Any]])
|
||||
def manage_app_notification(
|
||||
request: AppPushRequest,
|
||||
|
||||
@@ -35,6 +35,8 @@ _SECRET_SETTING_NAMES = frozenset(
|
||||
{
|
||||
# CookieCloud 的用户 key 没有类型后缀,但与密码共同构成端到端加密凭据。
|
||||
"cookiecloud_key",
|
||||
# 自动推送接入是内部凭据状态,默认查询应整体脱敏,不能只根据嵌套字段名判断。
|
||||
"app_push_credentials",
|
||||
}
|
||||
)
|
||||
_SECRET_SETTING_ENDINGS = ("_encrypt_key",)
|
||||
|
||||
@@ -299,6 +299,9 @@ SYSTEMCONFIG_CONTRACTS: dict[SystemConfigKey, _SystemConfigContract] = {
|
||||
match_field="name",
|
||||
sensitive=True,
|
||||
),
|
||||
SystemConfigKey.AppPushCredentials: _internal_contract(
|
||||
"notifications", Optional[dict[str, JsonData]], sensitive=True
|
||||
),
|
||||
SystemConfigKey.NotificationSwitchs: _contract(
|
||||
"notification_switches",
|
||||
Optional[list[NotificationSwitchConf]],
|
||||
|
||||
@@ -1605,6 +1605,12 @@
|
||||
}
|
||||
},
|
||||
"systemconfig": {
|
||||
"AppPushCredentials": {
|
||||
"description": "App 中心推送自动接入的内部凭据状态;按服务地址隔离,不通过通知渠道配置下发",
|
||||
"enum_name": "AppPushCredentials",
|
||||
"source_file": "app/schemas/types.py",
|
||||
"source_line": 430
|
||||
},
|
||||
"AIAgentConfig": {
|
||||
"description": "AI智能体配置",
|
||||
"enum_name": "AIAgentConfig",
|
||||
|
||||
@@ -2,22 +2,31 @@
|
||||
将现有 MoviePilot 通知经私有 Server 投递到 App 或主屏幕通知网页。
|
||||
"""
|
||||
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.modules import _MessageBase, _ModuleBase
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.system import NotificationConf
|
||||
from app.schemas.types import ModuleType, NotificationChannel
|
||||
from app.schemas.types import ModuleType, NotificationChannel, SystemConfigKey
|
||||
|
||||
|
||||
class AppPushModule(_ModuleBase, _MessageBase[object]):
|
||||
"""
|
||||
复用现有渠道配置、消息类型开关和用户路由,不持有 Apple 或 VAPID 私钥。
|
||||
|
||||
:cvar _relay_lock: 同一进程内串行创建、恢复与轮换凭据,数据库写入使用配置服务原子操作
|
||||
"""
|
||||
|
||||
_notification_user_target_keys = ("apppush",)
|
||||
_relay_lock = threading.RLock()
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""
|
||||
@@ -73,11 +82,92 @@ class AppPushModule(_ModuleBase, _MessageBase[object]):
|
||||
|
||||
def _request(self, conf: NotificationConf, method: str, path: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
从渠道配置读取唯一中转地址和实例密钥,不将密钥返回客户端或写入日志。
|
||||
使用手工或自动接入凭据,不将内部凭据返回客户端或写入日志。
|
||||
"""
|
||||
config = conf.config or {}
|
||||
return MoviePilotServerHelper.relay_push(str(config.get("PUSH_SERVER_URL") or ""),
|
||||
str(config.get("PUSH_RELAY_KEY") or ""), method, path, data)
|
||||
server = self._server_url(conf)
|
||||
key = str(config.get("PUSH_RELAY_KEY") or "") or self._automatic_key(server)
|
||||
return MoviePilotServerHelper.relay_push(server, key, method, path, data)
|
||||
|
||||
@staticmethod
|
||||
def _server_url(conf: NotificationConf) -> str:
|
||||
"""
|
||||
空地址沿用既有中心服务配置,地址规范化同时决定本机凭据作用域。
|
||||
"""
|
||||
return MoviePilotServerHelper.normalize_relay_url(str((conf.config or {}).get("PUSH_SERVER_URL")
|
||||
or get_runtime_setting("MP_SERVER_HOST") or ""))
|
||||
|
||||
@staticmethod
|
||||
def _save_registration(server: str, change: Callable[[dict[str, str]], dict[str, str]]) -> dict[str, str]:
|
||||
"""
|
||||
在短事务内更新一个中心的凭据,不跨网络持有数据库事务或覆盖其他中心状态。
|
||||
|
||||
:param server: 已校验且规范化的服务根地址
|
||||
:param change: 纯状态变更;输入、输出只含内部字符串字段
|
||||
:return: 已提交的该中心状态,不得交给客户端
|
||||
"""
|
||||
def update(current: Any) -> tuple[dict[str, str], dict[str, Any]]:
|
||||
"""
|
||||
合并最新持久化值,拒绝损坏状态而非静默生成另一份实例身份。
|
||||
"""
|
||||
if current is not None and not isinstance(current, dict):
|
||||
raise ValueError("AppPush 接入状态损坏,请从备份恢复")
|
||||
values = dict(current or {})
|
||||
prior = values.get(server) or {}
|
||||
if not isinstance(prior, dict) or any(not isinstance(value, str) for value in prior.values()):
|
||||
raise ValueError("AppPush 接入状态损坏,请从备份恢复")
|
||||
registration = change(dict(prior))
|
||||
values[server] = registration
|
||||
return registration, values
|
||||
|
||||
return get_configured_system_config().update_atomically(SystemConfigKey.AppPushCredentials, update)
|
||||
|
||||
def _automatic_key(self, server: str, *, rotate: bool = False) -> str:
|
||||
"""
|
||||
先持久化随机凭据,再幂等登记;候选轮换凭据先写入,重启后接续相同请求。
|
||||
|
||||
:param server: 凭据所属的精确服务根地址
|
||||
:param rotate: 仅由容器管理员专用入口请求轮换;正常操作只恢复已有轮换
|
||||
:return: 当前已登记的内部发送凭据
|
||||
:raises ValueError: 身份或持久化失败、中心拒绝登记/轮换,不自动冒用 UID 恢复
|
||||
"""
|
||||
with self._relay_lock:
|
||||
registration = self._save_registration(server, lambda old: old or {
|
||||
"key": "mpa_" + secrets.token_urlsafe(32), "instance": "", "pending": "",
|
||||
})
|
||||
if not re.fullmatch(r"mpa_[a-zA-Z0-9_-]{43}", registration.get("key", "")):
|
||||
raise ValueError("AppPush 接入凭据损坏,请从备份恢复")
|
||||
if not registration.get("instance"):
|
||||
uid = MoviePilotServerHelper.get_user_uid()
|
||||
if not uid:
|
||||
raise ValueError("无法取得 MoviePilot 用户唯一 ID")
|
||||
result = MoviePilotServerHelper.relay_push(server, registration["key"], "POST", "instances", {"user_uid": uid})
|
||||
instance = result.get("instance")
|
||||
if not isinstance(instance, str) or not re.fullmatch(r"[a-f0-9]{32}", instance):
|
||||
raise ValueError("中心服务接入响应无效")
|
||||
registration = self._save_registration(server, lambda old: {**old, "instance": instance})
|
||||
if rotate:
|
||||
registration = self._save_registration(server, lambda old: {
|
||||
**old, "pending": old.get("pending") or "mpa_" + secrets.token_urlsafe(32),
|
||||
})
|
||||
if registration.get("pending"):
|
||||
pending = registration["pending"]
|
||||
result = MoviePilotServerHelper.relay_push(server, registration["key"], "PUT", "instances/credentials", {"key": pending})
|
||||
if result.get("instance") != registration["instance"] or result.get("rotated") is not True:
|
||||
raise ValueError("中心服务轮换响应无效")
|
||||
|
||||
def promote(old: dict[str, str]) -> dict[str, str]:
|
||||
"""
|
||||
比较当前世代再提交,避免跨进程迟到的响应覆盖下一次轮换。
|
||||
"""
|
||||
if old.get("key") == pending:
|
||||
return old
|
||||
if old.get("key") != registration["key"] or old.get("pending") != pending:
|
||||
raise ValueError("AppPush 凭据已被其他操作更新,请重试")
|
||||
return {**old, "key": pending, "pending": ""}
|
||||
|
||||
registration = self._save_registration(server, promote)
|
||||
return registration["key"]
|
||||
|
||||
def channel_manage(self, channel: str, action: str, **params: Any) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
@@ -96,10 +186,15 @@ class AppPushModule(_ModuleBase, _MessageBase[object]):
|
||||
try:
|
||||
if action == "status":
|
||||
data = self._request(conf, "GET", "devices", {"owner": owner})
|
||||
data.update(owner=owner, relay_url=str((conf.config or {}).get("PUSH_SERVER_URL") or "").rstrip("/") + "/push/app/")
|
||||
data.update(owner=owner, relay_url=self._server_url(conf) + "/push/app/")
|
||||
elif action == "pair":
|
||||
data = self._request(conf, "POST", "pairings", {"owner": owner, "origin": origin, "installation": installation})
|
||||
data["relay_url"] = str((conf.config or {}).get("PUSH_SERVER_URL") or "").rstrip("/") + "/push/app/"
|
||||
data["relay_url"] = self._server_url(conf) + "/push/app/"
|
||||
elif action == "rotate_instance" and params.get("administrative") is True:
|
||||
if (conf.config or {}).get("PUSH_RELAY_KEY"):
|
||||
return {"success": False, "message": "手工凭据请由中心运营者轮换"}
|
||||
self._automatic_key(self._server_url(conf), rotate=True)
|
||||
data = {"rotated": True}
|
||||
elif action == "register":
|
||||
if not params.get("token") or not params.get("topic"):
|
||||
return {"success": False, "message": "尚未取得系统推送令牌"}
|
||||
|
||||
@@ -8,6 +8,16 @@ from urllib.parse import urlsplit
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class AppPushInstanceRequest(BaseModel):
|
||||
"""
|
||||
仅容器管理员可轮换实例凭据,普通用户的设备管理请求不能触发该动作。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
action: Literal["rotate"] = Field(description="轮换当前已配置中心的自动接入凭据")
|
||||
source: str = Field(min_length=1, max_length=255, description="已保存并启用的 AppPush 渠道名称")
|
||||
|
||||
|
||||
class AppPushRequest(BaseModel):
|
||||
"""
|
||||
通知操作绑定当前登录身份;来源由启用的通知渠道配置选择。
|
||||
|
||||
@@ -426,6 +426,8 @@ class SystemConfigKey(Enum):
|
||||
MediaServers = "MediaServers"
|
||||
# 消息通知配置
|
||||
Notifications = "Notifications"
|
||||
# App 中心推送自动接入凭据,业务内部维护且不进入通知渠道配置
|
||||
AppPushCredentials = "AppPushCredentials"
|
||||
# 通知场景开关设置
|
||||
NotificationSwitchs = "NotificationSwitchs"
|
||||
# 目录配置
|
||||
|
||||
@@ -817,7 +817,7 @@ flowchart LR
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 1131 |
|
||||
| 内部导入边 | 9,727 |
|
||||
| 内部导入边 | 9,730 |
|
||||
| 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) |
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
请求包含 `action`(`status/register/unregister/pair/test`)、随机 `installation` 和实例 `origin`。
|
||||
`register` 另需 APNs `token/topic/environment`;`source` 可选,用于选择启用的 AppPush 渠道。
|
||||
账号通知身份由后台注入,客户端不能指定其他用户。注销同时撤销该安装配对的主屏幕通知网页。
|
||||
AppPush 配置只保存 `PUSH_SERVER_URL` 和实例专用 `PUSH_RELAY_KEY`,Apple/VAPID 私钥放在私有 Server。
|
||||
AppPush 地址 `PUSH_SERVER_URL` 留空时使用 `MP_SERVER_HOST`;首次操作自动生成并先行持久保存256位实例凭据,再向中心幂等登记现有 `user_uid`。UID 只关联来源,不能据此获取、重置或认领已有凭据。内部 `AppPushCredentials` 按中心地址隔离,不进入通知渠道配置;中心只保存凭据摘要。已有 `PUSH_RELAY_KEY` 手工配置继续兼容。
|
||||
`POST /api/v1/notification/app/instance` 仅容器超级管理员可调用,正文为 `{"action":"rotate","source":"已保存的渠道名称"}`。轮换保留设备,候选凭据先落库,响应丢失可在重启后接续;返回值不包含密钥。普通用户的 `/notification/app` 不接受该动作。
|
||||
Apple/VAPID 私钥只放在中心 Server,不放入 App 或用户容器。
|
||||
消息沿用现有类型开关和管理员/用户路由;无明确用户目标时不广播。部署与免费自签说明见
|
||||
[MoviePilot-Server](https://github.com/jxxghp/MoviePilot-Server#私有-app-消息推送)。
|
||||
|
||||
|
||||
@@ -959,8 +959,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 9727,
|
||||
"edge_sha256": "4c4361d6a435793000158fa6bf8896ac4a7dd3c5a15046a46556a46fba094245",
|
||||
"edge_count": 9730,
|
||||
"edge_sha256": "b5187460baedd0f182db4c52f2df0e3fb0f63af4912a969037b6cb3690e4b8b9",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -7161,9 +7161,12 @@
|
||||
"app.modules.apppush -> app.adapters",
|
||||
"app.modules.apppush -> app.adapters.external",
|
||||
"app.modules.apppush -> app.adapters.external.server",
|
||||
"app.modules.apppush -> app.application",
|
||||
"app.modules.apppush -> app.application.configuration",
|
||||
"app.modules.apppush -> app.modules",
|
||||
"app.modules.apppush -> app.runtime",
|
||||
"app.modules.apppush -> app.runtime.log",
|
||||
"app.modules.apppush -> app.runtime.settings",
|
||||
"app.modules.apppush -> app.schemas",
|
||||
"app.modules.apppush -> app.schemas.message",
|
||||
"app.modules.apppush -> app.schemas.system",
|
||||
|
||||
198
tests/test_apppush_enrollment.py
Normal file
198
tests/test_apppush_enrollment.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
中心自动接入的真实配置持久化、故障接续及管理员权限边界回归。
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.api.dependencies.auth import get_current_active_user
|
||||
from app.api.endpoints import notification
|
||||
from app.application.configuration import SystemConfigService
|
||||
from app.application.settings.service import SystemSettingsService
|
||||
from app.db.models.systemconfig import SystemConfig
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.db.session import SessionFactory
|
||||
from app.modules.apppush import AppPushModule
|
||||
from app.schemas.system import NotificationConf
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
SERVER = "https://relay.test"
|
||||
UID = "existing-deployment-user-unique-id"
|
||||
|
||||
|
||||
def fresh_service() -> SystemConfigService:
|
||||
"""
|
||||
创建独立配置快照并从测试数据库重载,模拟宿主重启后的实际持久化读取。
|
||||
"""
|
||||
oper = object.__new__(SystemConfigOper)
|
||||
SystemConfigOper.__init__(oper)
|
||||
oper.load_snapshot()
|
||||
return SystemConfigService(repository=oper)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configuration(monkeypatch):
|
||||
"""
|
||||
只替换本模块的配置入口,复用测试引导的临时 SQL,结束时还原本键。
|
||||
"""
|
||||
service = fresh_service()
|
||||
original = service.get(SystemConfigKey.AppPushCredentials)
|
||||
service.set(SystemConfigKey.AppPushCredentials, {})
|
||||
monkeypatch.setattr("app.modules.apppush.get_configured_system_config", lambda: service)
|
||||
monkeypatch.setattr("app.modules.apppush.get_runtime_setting", lambda _key: SERVER)
|
||||
monkeypatch.setattr(MoviePilotServerHelper, "get_user_uid", lambda: UID)
|
||||
try:
|
||||
yield service
|
||||
finally:
|
||||
service.set(SystemConfigKey.AppPushCredentials, original)
|
||||
|
||||
|
||||
def test_registration_persists_before_network_and_reuses_key_after_restart(monkeypatch, configuration):
|
||||
"""
|
||||
先落库再登记,渠道和普通状态响应均不包含密钥,重启不产生第二个实例。
|
||||
"""
|
||||
calls = []
|
||||
|
||||
class Transport:
|
||||
"""
|
||||
只替换最后 HTTP 边界,在发送时用独立 SQL 会话核对凭据已提交。
|
||||
"""
|
||||
|
||||
def __init__(self, **options):
|
||||
"""
|
||||
保存出站请求头里的私有凭据,不改变请求构造行为。
|
||||
"""
|
||||
self.key = options["headers"]["Authorization"].removeprefix("Bearer ")
|
||||
|
||||
def request(self, method, url, **options):
|
||||
"""
|
||||
验证已有 UID、密钥存放位置和先落库约束,再响应登记或设备查询。
|
||||
"""
|
||||
with SessionFactory() as db:
|
||||
stored = SystemConfig.get_by_key(db, SystemConfigKey.AppPushCredentials.value).value
|
||||
assert stored[SERVER]["key"] == self.key
|
||||
calls.append((method, url, self.key))
|
||||
assert options["allow_redirects"] is False
|
||||
if url.endswith('/instances'):
|
||||
assert options["json"] == {"user_uid": UID}
|
||||
payload = {"instance": "a" * 32, "registered": True}
|
||||
else:
|
||||
payload = {"devices": []}
|
||||
return SimpleNamespace(status_code=200, json=lambda: payload)
|
||||
|
||||
monkeypatch.setattr("app.adapters.external.server.RequestUtils", Transport)
|
||||
conf = NotificationConf(name="automatic", type="apppush", enabled=True, config={})
|
||||
module = AppPushModule.__new__(AppPushModule)
|
||||
monkeypatch.setattr(module, "get_configs", lambda: {"automatic": conf})
|
||||
result = module.channel_manage("AppPush", "status", owner="a" * 32)
|
||||
key = configuration.get(SystemConfigKey.AppPushCredentials)[SERVER]["key"]
|
||||
assert result["success"] is True and result["data"]["relay_url"] == SERVER + "/push/app/"
|
||||
assert key.startswith('mpa_') and len(key) == 47
|
||||
assert key not in str(result) and key not in str(conf.model_dump())
|
||||
restarted = fresh_service()
|
||||
monkeypatch.setattr("app.modules.apppush.get_configured_system_config", lambda: restarted)
|
||||
second_module = AppPushModule.__new__(AppPushModule)
|
||||
monkeypatch.setattr(second_module, "get_configs", lambda: {"automatic": conf})
|
||||
assert second_module.channel_manage("AppPush", "status", owner="a" * 32)["success"] is True
|
||||
assert sum(url.endswith('/instances') for _, url, _ in calls) == 1
|
||||
assert {request_key for _, _, request_key in calls} == {key}
|
||||
|
||||
|
||||
def test_rotation_response_loss_keeps_candidate_for_restart_recovery(monkeypatch, configuration):
|
||||
"""
|
||||
已生效轮换的响应丢失时保留两份本地状态,重启只重试相同候选,不新建空间。
|
||||
"""
|
||||
remote = {"key": "", "previous": "", "lost": False}
|
||||
calls = []
|
||||
|
||||
class Transport:
|
||||
"""
|
||||
模拟中心接受轮换后丢失响应,出站请求仍经过真实适配器。
|
||||
"""
|
||||
|
||||
def __init__(self, **options):
|
||||
"""
|
||||
提取真实构造的 Bearer 头用于服务端鉴权替身。
|
||||
"""
|
||||
self.key = options["headers"]["Authorization"].removeprefix("Bearer ")
|
||||
|
||||
def request(self, method, url, **options):
|
||||
"""
|
||||
按中心幂等规则模拟登记和轮换,丢响应不会回滚远端已接受的状态。
|
||||
"""
|
||||
calls.append((method, url, self.key))
|
||||
if url.endswith('/instances/credentials'):
|
||||
candidate = options['json']['key']
|
||||
stored = fresh_service().get(SystemConfigKey.AppPushCredentials)[SERVER]
|
||||
assert stored['pending'] == candidate
|
||||
assert (self.key == remote['key'] or
|
||||
(self.key == remote['previous'] and candidate == remote['key']))
|
||||
remote['previous'], remote['key'] = self.key, candidate
|
||||
if not remote['lost']:
|
||||
remote['lost'] = True
|
||||
return None
|
||||
payload = {"instance": "a" * 32, "rotated": True}
|
||||
elif url.endswith('/instances'):
|
||||
remote['key'] = self.key
|
||||
payload = {"instance": "a" * 32, "registered": True}
|
||||
else:
|
||||
assert self.key == remote['key']
|
||||
payload = {"devices": []}
|
||||
return SimpleNamespace(status_code=200, json=lambda: payload)
|
||||
|
||||
monkeypatch.setattr("app.adapters.external.server.RequestUtils", Transport)
|
||||
module = AppPushModule.__new__(AppPushModule)
|
||||
original = module._automatic_key(SERVER)
|
||||
with pytest.raises(ValueError, match="请求失败"):
|
||||
module._automatic_key(SERVER, rotate=True)
|
||||
stored = fresh_service().get(SystemConfigKey.AppPushCredentials)[SERVER]
|
||||
assert stored['key'] == original and stored['pending'] == remote['key'] != original
|
||||
restarted = fresh_service()
|
||||
monkeypatch.setattr("app.modules.apppush.get_configured_system_config", lambda: restarted)
|
||||
recovered = AppPushModule.__new__(AppPushModule)._automatic_key(SERVER)
|
||||
assert recovered == remote['key'] != original
|
||||
assert restarted.get(SystemConfigKey.AppPushCredentials)[SERVER]['pending'] == ''
|
||||
assert sum(url.endswith('/instances') for _, url, _ in calls) == 1
|
||||
assert sum(url.endswith('/instances/credentials') for _, url, _ in calls) == 2
|
||||
spec = SystemSettingsService(MagicMock(), restarted, MagicMock()).describe(setting_key=SystemConfigKey.AppPushCredentials.value)
|
||||
assert recovered not in str(spec) and spec['definition']['generic_write_allowed'] is False
|
||||
|
||||
|
||||
def test_instance_rotation_requires_container_superuser_and_does_not_accept_keys(monkeypatch):
|
||||
"""
|
||||
普通用户不能轮换整个容器的凭据,管理员请求不接受自选用户、密钥或 URL。
|
||||
"""
|
||||
calls = []
|
||||
|
||||
class Chain:
|
||||
"""
|
||||
记录已通过真实角色检查的专用管理调用。
|
||||
"""
|
||||
|
||||
def manage_channel(self, **params):
|
||||
"""
|
||||
只返回轮换结果,不向客户端返回凭据。
|
||||
"""
|
||||
calls.append(params)
|
||||
return {"success": True, "data": {"rotated": True}}
|
||||
|
||||
monkeypatch.setattr(notification, "NotificationChain", Chain)
|
||||
user = SimpleNamespace(notification_identity="a" * 32, is_superuser=False)
|
||||
app = FastAPI()
|
||||
app.include_router(notification.router, prefix="/notification")
|
||||
app.dependency_overrides[get_current_active_user] = lambda: user
|
||||
body = {"action": "rotate", "source": "automatic"}
|
||||
with TestClient(app) as client:
|
||||
assert client.post('/notification/app/instance', json=body).status_code == 403
|
||||
assert calls == []
|
||||
user.is_superuser = True
|
||||
assert client.post('/notification/app/instance', json={**body, "key": "private"}).status_code == 422
|
||||
response = client.post('/notification/app/instance', json=body)
|
||||
assert response.json()['data'] == {"rotated": True}
|
||||
assert calls == [{"channel": "AppPush", "action": "rotate_instance", "source": "automatic",
|
||||
"owner": "a" * 32, "administrative": True}]
|
||||
Reference in New Issue
Block a user