Files
MoviePilot/tests/test_site_chain_ports.py

567 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
SiteChain 技术端口、资源所有权与装配原子性回归。
"""
import ast
import json
from contextlib import contextmanager
from pathlib import Path
from types import SimpleNamespace
from typing import Iterator, Optional
import pytest
import app.application.torrent.download as torrent_module
from app.adapters.external import cookiecloud as cookiecloud_module
from app.application import rss as rss_module
from app.application.security import cookie as cookie_module
from app.application.site.contract import SiteSnapshot
from app.chain import site as site_module
from app.chain.site import SiteChain, configure_site_ports, reset_site_ports
from app.startup.composition import site as site_composition
from app.startup.initializers import site as site_initializer
class _FakeResponse:
"""
模拟站点链消费的最小 HTTP 响应。
"""
def __init__(
self,
status_code: int,
*,
text: str = "",
content: bytes = b"",
reason: str = "OK",
payload: Optional[dict] = None,
) -> None:
"""
保存响应状态、正文与可选 JSON。
"""
self.status_code = status_code
self.text = text
self.content = content
self.reason = reason
self._payload = payload or {}
def __bool__(self) -> bool:
"""
模拟 requests.Response 的成功状态真假语义。
"""
return self.status_code < 400
def json(self) -> dict:
"""
返回预设 JSON 数据。
"""
return self._payload
class _FakeHttpPort:
"""
按队列回放响应并记录上下文退出。
"""
def __init__(self, *responses: Optional[_FakeResponse]) -> None:
"""
保存将被依次消费的响应。
"""
self.responses = list(responses)
self.calls: list[dict] = []
self.closed = 0
@contextmanager
def open(self, **kwargs) -> Iterator[Optional[_FakeResponse]]:
"""
回放一个响应并在调用方离开时记录关闭。
"""
self.calls.append(kwargs)
response = self.responses.pop(0)
try:
yield response
finally:
self.closed += 1
class _FakeBrowserPort:
"""
回放浏览器页面源码。
"""
def __init__(self, source: Optional[str] = None) -> None:
"""
保存渲染结果。
"""
self.source = source
self.calls: list[dict] = []
def render(self, **kwargs) -> Optional[str]:
"""
记录调用并返回页面源码。
"""
self.calls.append(kwargs)
return self.source
class _FakeChallengePort:
"""
回放页面挑战识别结果。
"""
def __init__(self, detected: bool = False) -> None:
"""
保存识别结果。
"""
self.result = detected
self.calls: list[str] = []
def detected(self, html_text: str) -> bool:
"""
记录页面并返回识别结果。
"""
self.calls.append(html_text)
return self.result
class _FakeCookieCloudPort:
"""
回放 CookieCloud 下载二元结果。
"""
def __init__(self, result=(None, "未配置")) -> None:
"""
保存下载结果。
"""
self.result = result
self.calls = 0
def download(self):
"""
记录调用并返回预设结果。
"""
self.calls += 1
return self.result
@pytest.fixture(autouse=True)
def clean_site_ports():
"""
隔离每个用例修改的站点链进程级端口。
"""
reset_site_ports()
yield
reset_site_ports()
def _configure(
http: _FakeHttpPort,
*,
browser: Optional[_FakeBrowserPort] = None,
challenge: Optional[_FakeChallengePort] = None,
cookiecloud: Optional[_FakeCookieCloudPort] = None,
) -> None:
"""
装配一组完全离线的站点链假端口。
"""
configure_site_ports(
http=http,
browser=browser or _FakeBrowserPort(),
challenge=challenge or _FakeChallengePort(),
cookiecloud=cookiecloud or _FakeCookieCloudPort(),
)
def _chain() -> SiteChain:
"""
构造仅执行私有连通性逻辑的 SiteChain。
"""
chain = object.__new__(SiteChain)
chain.runtime_config = SimpleNamespace(
proxy=None,
proxy_host=None,
proxy_server=None,
user_agent="MoviePilot-Test",
)
return chain
def _public_site(*, render: bool = False) -> SiteSnapshot:
"""
构造无需登录标志判断的站点快照。
"""
return SiteSnapshot(
id=1,
name="Port Test",
domain="port.test",
url="https://port.test/",
public=True,
render=render,
)
def test_unconfigured_site_port_fails_without_implicit_adapter() -> None:
"""
未装配时真实访问应稳定失败,不能由 Chain 隐式回建 Adapter。
"""
with pytest.raises(RuntimeError, match="站点链访问端口"):
_chain()._SiteChain__test(_public_site())
@pytest.mark.parametrize(
("response", "expected"),
[
(None, (False, "无法打开网站!")),
(_FakeResponse(429, reason="rate"), (False, "错误:429 rate!")),
(_FakeResponse(200, text="ok"), (True, "连接成功")),
],
)
def test_http_port_preserves_failure_tristate_and_closes_response(
response: Optional[_FakeResponse], expected: tuple[bool, str]
) -> None:
"""
无响应、错误响应与成功响应保持原三态,且所有分支退出上下文。
"""
http = _FakeHttpPort(response)
_configure(http)
result = _chain()._SiteChain__test(_public_site())
assert result == expected
assert http.closed == 1
assert http.calls[0]["url"] == "https://port.test/"
def test_browser_and_challenge_ports_keep_render_failure_semantics() -> None:
"""
浏览器页面命中挑战时保留原 Cloudflare 失败提示。
"""
browser = _FakeBrowserPort("<title>Just a moment...</title>")
challenge = _FakeChallengePort(True)
_configure(_FakeHttpPort(), browser=browser, challenge=challenge)
site = SiteSnapshot(
id=2,
name="Protected",
domain="protected.test",
url="https://protected.test/",
public=False,
render=True,
)
result = _chain()._SiteChain__test(site)
assert result == (False, "无法通过Cloudflare!")
assert challenge.calls == ["<title>Just a moment...</title>"]
assert browser.calls[0]["url"] == "https://protected.test/"
def test_cookiecloud_failure_uses_fake_port_without_network() -> None:
"""
CookieCloud 下载失败应保持二元返回且只调用注入端口。
"""
cookiecloud = _FakeCookieCloudPort((None, "下载失败"))
_configure(_FakeHttpPort(), cookiecloud=cookiecloud)
result = _chain().sync_cookies()
assert result == (False, "下载失败")
assert cookiecloud.calls == 1
def test_configure_reset_is_repeatable_and_preserves_chain_identity() -> None:
"""
重复装配与 reset 必须原子替换快照且不改变 SiteChain 类身份。
"""
original_type = SiteChain
first = _FakeHttpPort(_FakeResponse(200))
second = _FakeHttpPort(_FakeResponse(200))
_configure(first)
first_snapshot = site_module._site_ports_snapshot()
_configure(second)
second_snapshot = site_module._site_ports_snapshot()
reset_site_ports()
reset_site_ports()
assert first_snapshot.http is first
assert second_snapshot.http is second
assert first_snapshot is not second_snapshot
assert site_module.SiteChain is original_type
with pytest.raises(RuntimeError, match="站点链访问端口"):
site_module._site_ports_snapshot()
def test_initializer_rolls_back_all_ports_when_site_chain_publish_fails(
monkeypatch,
) -> None:
"""
最后一组站点链端口发布失败时必须回滚此前三组端口。
"""
def fail_site_configuration(**_kwargs) -> None:
"""
模拟站点链端口发布失败。
"""
raise RuntimeError("site publish failed")
monkeypatch.setattr(
site_composition, "configure_site_ports", fail_site_configuration
)
with pytest.raises(RuntimeError, match="site publish failed"):
site_initializer.init_site_access_ports()
with pytest.raises(RuntimeError):
rss_module._require_rss_ports()
with pytest.raises(RuntimeError):
cookie_module._require_cookie_ports()
with pytest.raises(RuntimeError):
torrent_module._require_torrent_port()
with pytest.raises(RuntimeError):
site_module._site_ports_snapshot()
@pytest.mark.parametrize("json_body", [None, {}])
def test_real_initializer_http_adapter_closes_response(monkeypatch, json_body) -> None:
"""
组合根 HTTP adapter 必须在调用方返回后关闭 RequestUtils 响应。
"""
response = _FakeResponse(200, text="ok")
state = {"closed": False}
observed = {}
class FakeRequest:
"""
提供可观测关闭的 RequestUtils 替身。
"""
def __init__(self, **_kwargs) -> None:
"""
接受真实 adapter 传递的请求配置。
"""
@contextmanager
def response_manager(self, **kwargs):
"""
退出上下文时记录响应已释放。
"""
observed.update(kwargs)
try:
yield response
finally:
state["closed"] = True
monkeypatch.setattr(site_composition, "RequestUtils", FakeRequest)
adapter = site_composition._SiteHttpAdapter()
with adapter.open(method="POST", url="https://close.test/", json_body=json_body) as opened:
assert opened is response
assert not state["closed"]
assert state["closed"]
expected = {"method": "POST", "url": "https://close.test/"}
if json_body is not None:
expected["json"] = json_body
assert observed == expected
def test_cookiecloud_adapter_closes_remote_response(monkeypatch) -> None:
"""
CookieCloud 远端下载完成或提前返回时也必须关闭 HTTP 响应。
"""
state = {"closed": False}
response = _FakeResponse(200, payload={"encrypted": "cipher"})
class FakeRequest:
"""
提供可观测关闭的 CookieCloud HTTP 替身。
"""
def __init__(self, **_kwargs) -> None:
"""
接受 CookieCloud 传递的内容类型。
"""
@contextmanager
def response_manager(self, **_kwargs):
"""
退出下载上下文时记录响应已关闭。
"""
try:
yield response
finally:
state["closed"] = True
helper = cookiecloud_module.CookieCloudHelper.__new__(
cookiecloud_module.CookieCloudHelper
)
helper._server = "https://cookiecloud.test/"
helper._key = "key"
helper._password = "password"
helper._enable_local = False
monkeypatch.setattr(helper, "_CookieCloudHelper__sync_setting", lambda: None)
monkeypatch.setattr(cookiecloud_module, "RequestUtils", FakeRequest)
monkeypatch.setattr(
cookiecloud_module.CryptoJsUtils,
"decrypt",
lambda *_args: json.dumps(
{
"cookie_data": {
"source": [
{"domain": ".example.test", "name": "sid", "value": "1"}
]
}
}
).encode(),
)
cookies, message = helper.download()
assert cookies == {"example.test": "sid=1"}
assert message == ""
assert state["closed"]
def test_site_chain_has_no_concrete_adapter_imports() -> None:
"""
SiteChain 只能拥有 Port,不得重新静态依赖具体 Adapter。
"""
path = Path(site_module.__file__)
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
violations = [
node.module
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom)
and node.module
and node.module.startswith("app.adapters")
]
assert violations == []
def _redirect(location: Optional[str]) -> SimpleNamespace:
"""
构造带可选目标地址的重定向响应。
"""
return SimpleNamespace(status_code=302, headers={"Location": location} if location else {})
def _scripted_request(monkeypatch, responses: list) -> list[dict]:
"""
用按序回放的 RequestUtils 替身替换组合根传输,并记录每一跳的构造参数和请求参数。
"""
hops: list[dict] = []
queue = list(responses)
class ScriptedRequest:
"""
记录每一跳携带的 Cookie 与请求地址。
"""
def __init__(self, **kwargs) -> None:
"""
保存 adapter 传入的 Cookie 等构造参数。
"""
self.options = kwargs
@contextmanager
def response_manager(self, **kwargs):
"""
依次返回预设响应。
"""
hops.append({**self.options, **kwargs})
item = queue.pop(0)
if isinstance(item, Exception):
# 模拟 requests 在打开响应时即抛出的异常,例如预解析畸形 Location
raise item
yield item
monkeypatch.setattr(site_composition, "RequestUtils", ScriptedRequest)
return hops
@pytest.mark.parametrize("responses,reason", [
([_redirect("/dl/1/auth?X-Amz-Signature=sig")] * 2, "站点请求重定向出现环路"),
([_redirect(None)], "站点请求重定向缺少目标地址"),
([_redirect("ftp://site.test/file")], "站点请求重定向目标无效"),
([_redirect("https://user:pass@site.test/")], "站点请求重定向目标无效"),
([_redirect("https://[bad/path")], "站点请求重定向目标无效"),
([_redirect(f"/page{index}") for index in range(9)], "站点请求重定向次数超过上限"),
([ValueError("Invalid IPv6 URL")], "站点请求地址或重定向目标无效"),
([_redirect("/next"), ValueError("Invalid IPv6 URL")], "站点请求地址或重定向目标无效"),
], ids=[
"loop", "missing-location", "invalid-scheme", "userinfo", "malformed", "too-many-hops",
"transport-rejects-location", "transport-rejects-next-hop",
])
def test_site_adapter_redirect_failures_yield_none(monkeypatch, responses, reason) -> None:
"""
重定向失败按端口约定提供 None 并告警,不抛出异常打断 CookieCloud 同步等批量流程;
告警只含来源地址,路径与查询参数中的签名、passkey 等凭据都不写入日志。
"""
hops = _scripted_request(monkeypatch, responses)
warnings = []
monkeypatch.setattr(site_composition.logger, "warning", warnings.append)
with site_composition._SiteHttpAdapter().open(
method="GET", url="https://site.test/index.php?passkey=secret", cookie="uid=1",
) as response:
assert response is None
assert len(hops) == len(responses)
assert warnings == [f"{reason}:https://site.test"]
def test_cookiecloud_sync_continues_after_redirect_loop(monkeypatch) -> None:
"""
单个新站点的重定向环路只记为该站点添加失败,同步继续处理后续站点。
"""
hops = _scripted_request(monkeypatch, [_redirect("/")])
monkeypatch.setattr(site_composition.logger, "warning", lambda _message: None)
_configure(site_composition._SiteHttpAdapter())
chain = _chain()
chain.runtime_config.cookiecloud_blacklist = ""
result = chain._sync_cookiecloud_domain(
domain="loop.test",
cookie="uid=1",
indexer={"name": "环路站点", "domain": "https://loop.test/"},
site_info=None,
repository=SimpleNamespace(),
rsshelper=SimpleNamespace(),
)
# 跳回同一地址在第二跳发出前即判定为环路
assert result == (0, 0, 1, False)
assert [hop["url"] for hop in hops] == ["https://loop.test/"]
def test_site_adapter_does_not_swallow_caller_errors(monkeypatch) -> None:
"""
调用方在响应上下文内抛出的异常必须原样传播,不能被当作重定向失败吞掉。
"""
_scripted_request(monkeypatch, [_FakeResponse(200, text="ok")])
warnings = []
monkeypatch.setattr(site_composition.logger, "warning", warnings.append)
with pytest.raises(ValueError, match="调用方错误"):
with site_composition._SiteHttpAdapter().open(method="GET", url="https://site.test/") as response:
assert response is not None
raise ValueError("调用方错误")
assert warnings == []
def test_favicon_failure_logs_only_site_origin(monkeypatch) -> None:
"""
图标或页面读取失败的日志只记录来源地址,页面提供的图标 URL 中的令牌不能写入日志。
"""
_configure(_FakeHttpPort(_FakeResponse(200, text='<link rel="icon" href="/icon.png?token=secret">'), None))
errors = []
monkeypatch.setattr(site_module.logger, "error", errors.append)
favicon_url, content = SiteChain._SiteChain__parse_favicon("https://site.test/?passkey=secret", "uid=1", "UA")
assert favicon_url == "https://site.test/icon.png?token=secret"
assert content is None
assert errors == ["获取站点图标失败:https://site.test"]