mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
站点 RSS 地址把 passkey、sign 等凭据放在查询串里,请求失败时这些原文会随 错误日志落盘,并被 Doctor 摘录进可外发的诊断报告;连接串形式的 userinfo 口令 同样存在。新增 app.foundation.url.mask_url_credentials 纯函数与两条共享正则, RSS 失败日志改用遮盖后的地址,Doctor 的日志遮盖规则复用同一模式,避免两处实现分叉。
168 lines
5.4 KiB
Python
168 lines
5.4 KiB
Python
"""
|
||
验证RSS 地址校验、访问安全边界与失败日志的凭据遮盖。
|
||
"""
|
||
|
||
from types import SimpleNamespace
|
||
from unittest.mock import Mock
|
||
|
||
import pytest
|
||
|
||
import app.chain.torrents as torrents_module
|
||
from app.application import rss as rss_module
|
||
from app.application.rss import RssHelper, configure_rss_ports, reset_rss_ports
|
||
from app.chain.torrents import TorrentsChain
|
||
|
||
|
||
@pytest.fixture
|
||
def rss_ports():
|
||
"""
|
||
装配可记录调用的 RSS 端口,确保用例不产生真实网络请求。
|
||
"""
|
||
http_port = Mock()
|
||
parser_port = Mock()
|
||
parser_port.parse.return_value = None
|
||
configure_rss_ports(http=http_port, browser=Mock(), parser=parser_port)
|
||
yield http_port
|
||
reset_rss_ports()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"url",
|
||
[None, "", " ", 123, b"https://example.com/rss", "#", "#fragment",
|
||
"/rss", "rss.xml", "ftp://example.com/rss", "https://bad host/rss",
|
||
"https://example.com:abc/rss", "https://example.com:65536/rss"],
|
||
)
|
||
def test_rss_helper_rejects_invalid_urls_before_http_request(url, rss_ports):
|
||
"""
|
||
无效 RSS 地址应返回普通错误,并在端口层之前被拒绝。
|
||
"""
|
||
assert RssHelper().parse(url) is False
|
||
rss_ports.get.assert_not_called()
|
||
|
||
|
||
def test_rss_helper_strips_whitespace_and_allows_http_fragment(rss_ports):
|
||
"""
|
||
合法 HTTP(S) 地址即使带 fragment 也应去除外层空白后请求。
|
||
"""
|
||
rss_ports.get.return_value = SimpleNamespace(
|
||
status_code=200,
|
||
content=b"<rss />",
|
||
text="<rss />",
|
||
reason="OK",
|
||
)
|
||
rss_ports.decode_xml.return_value = "<rss />"
|
||
|
||
assert RssHelper().parse(" http://example.com:8080/rss#item\n") == []
|
||
assert rss_ports.get.call_args.kwargs["url"] == "http://example.com:8080/rss#item"
|
||
|
||
|
||
@pytest.mark.parametrize("parse_result, renew_expected", [(False, False), (None, True)])
|
||
def test_torrents_rss_preserves_false_and_none_contract(
|
||
monkeypatch, parse_result, renew_expected, rss_ports
|
||
):
|
||
"""
|
||
RSS 普通错误与过期结果应继续分别对应不续期和自动续期。
|
||
"""
|
||
site = {
|
||
"id": 1,
|
||
"name": "测试站点",
|
||
"rss": "https://example.com/rss",
|
||
"proxy": False,
|
||
"timeout": 30,
|
||
"ua": None,
|
||
}
|
||
sites_helper = Mock()
|
||
sites_helper.get_indexer.return_value = site
|
||
monkeypatch.setattr(torrents_module, "SitesHelper", lambda: sites_helper)
|
||
monkeypatch.setattr(RssHelper, "parse", lambda *_args, **_kwargs: parse_result)
|
||
|
||
chain = TorrentsChain()
|
||
renew = Mock()
|
||
monkeypatch.setattr(chain, "_TorrentsChain__renew_rss_url", renew)
|
||
|
||
assert chain.rss("example.com") == []
|
||
assert renew.called is renew_expected
|
||
|
||
|
||
def test_torrents_rss_warns_once_and_skips_invalid_site_url(monkeypatch, rss_ports):
|
||
"""
|
||
站点 RSS 配置无效时只记录简短告警,不请求或自动续期。
|
||
"""
|
||
site = {
|
||
"id": 1,
|
||
"name": "测试站点",
|
||
"rss": "not-a-url?passkey=secret",
|
||
"proxy": False,
|
||
"timeout": 30,
|
||
"ua": None,
|
||
}
|
||
sites_helper = Mock()
|
||
sites_helper.get_indexer.return_value = site
|
||
warnings = []
|
||
errors = []
|
||
monkeypatch.setattr(torrents_module, "SitesHelper", lambda: sites_helper)
|
||
monkeypatch.setattr(torrents_module.logger, "warning", warnings.append)
|
||
monkeypatch.setattr(torrents_module.logger, "error", errors.append)
|
||
|
||
chain = TorrentsChain()
|
||
renew = Mock()
|
||
monkeypatch.setattr(chain, "_TorrentsChain__renew_rss_url", renew)
|
||
|
||
assert chain.rss("example.com") == []
|
||
assert rss_ports.get.call_count == 0
|
||
assert errors == []
|
||
assert warnings == ["站点 example.com RSS地址无效,跳过获取"]
|
||
assert "passkey=secret" not in warnings[0]
|
||
renew.assert_not_called()
|
||
|
||
|
||
@pytest.fixture
|
||
def rss_error_logs(monkeypatch):
|
||
"""
|
||
收集 RSS 失败日志,避免用例输出真实错误日志。
|
||
|
||
:return: 收集到的 error 日志文本列表
|
||
"""
|
||
errors: list[str] = []
|
||
monkeypatch.setattr(rss_module.logger, "error", errors.append)
|
||
return errors
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("response", "expected_fragment"),
|
||
[
|
||
(None, "获取RSS失败:请求返回空值"),
|
||
(SimpleNamespace(status_code=503, content=b"", text="", reason="Service Unavailable"),
|
||
"RSS请求失败,状态码: 503"),
|
||
],
|
||
)
|
||
def test_rss_helper_error_logs_mask_query_passkey(rss_ports, rss_error_logs, response, expected_fragment):
|
||
"""
|
||
RSS 请求空响应与非 200 响应时,错误日志保留站点定位信息并遮盖 passkey。
|
||
"""
|
||
rss_ports.get.return_value = response
|
||
|
||
assert RssHelper().parse(
|
||
"https://example.com/torrentrss.php?id=1&linktype=1&passkey=SUPERSECRET"
|
||
) is False
|
||
assert len(rss_error_logs) == 1
|
||
message = rss_error_logs[0]
|
||
assert expected_fragment in message
|
||
assert "SUPERSECRET" not in message
|
||
assert "passkey=<REDACTED>" in message
|
||
assert "id=1" in message
|
||
|
||
|
||
def test_rss_helper_exception_log_masks_url_credentials(rss_ports, rss_error_logs):
|
||
"""
|
||
请求异常文本自带 RSS 相对地址时,错误日志同样不输出 passkey 原文。
|
||
"""
|
||
rss_ports.get.side_effect = RuntimeError(
|
||
"Max retries exceeded with url: /torrentrss.php?passkey=SUPERSECRET"
|
||
)
|
||
|
||
assert RssHelper().parse("https://example.com/torrentrss.php?passkey=SUPERSECRET") is False
|
||
assert len(rss_error_logs) == 1
|
||
assert "SUPERSECRET" not in rss_error_logs[0]
|
||
assert "passkey=<REDACTED>" in rss_error_logs[0]
|