""" 验证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"", text="", reason="OK", ) rss_ports.decode_xml.return_value = "" 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=" 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=" in rss_error_logs[0]