Files
MoviePilot/tests/test_login_wallpaper_proxy.py

410 lines
14 KiB
Python
Raw 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.
import asyncio
import io
import threading
from unittest.mock import AsyncMock, Mock
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi import HTTPException
from PIL import Image
from app.api.endpoints import login as login_endpoint
from app.helper import image as image_module
from app.helper.image import ImageHelper
def test_wallpapers_preserves_default_external_contract(monkeypatch):
urls = [
"https://images.example/one.jpg",
"/relative/two.jpg",
"https://images.example/one.jpg",
]
helper = Mock()
helper.get_wallpapers.return_value = urls
monkeypatch.setattr(login_endpoint, "WallpaperHelper", Mock(return_value=helper))
result = login_endpoint.wallpapers(same_origin=False)
assert result == urls
helper.get_wallpapers.assert_called_once_with()
def test_same_origin_wallpapers_preserve_order_duplicates_and_relative_urls(
monkeypatch,
):
urls = [
"https://images.example/one.jpg",
"/relative/two.jpg",
"https://images.example/one.jpg",
]
helper = Mock()
helper.get_wallpapers.return_value = urls
monkeypatch.setattr(login_endpoint, "WallpaperHelper", Mock(return_value=helper))
monkeypatch.setattr(login_endpoint.settings, "WALLPAPER", "customize")
result = login_endpoint.wallpapers(same_origin=True)
assert len(result) == len(urls)
assert result[1] == urls[1]
assert result[0] == result[2]
signed_source = parse_qs(urlparse(result[0]).query)["url"][0]
assert (
login_endpoint.SecurityUtils.verify_signed_url(
signed_source,
purpose=login_endpoint._LOGIN_WALLPAPER_PUBLIC_PURPOSE,
)
== urls[0]
)
def test_same_origin_wallpapers_do_not_truncate_custom_sources(monkeypatch):
urls = [f"https://images.example/{index}.jpg" for index in range(12)]
helper = Mock()
helper.get_wallpapers.return_value = urls
monkeypatch.setattr(login_endpoint, "WallpaperHelper", Mock(return_value=helper))
monkeypatch.setattr(login_endpoint.settings, "WALLPAPER", "customize")
result = login_endpoint.wallpapers(same_origin=True)
assert len(result) == len(urls)
decoded_sources = [
login_endpoint.SecurityUtils.verify_signed_url(
parse_qs(urlparse(item).query)["url"][0],
purpose=login_endpoint._LOGIN_WALLPAPER_PUBLIC_PURPOSE,
)
for item in result
]
assert decoded_sources == urls
def test_same_origin_wallpapers_convert_network_path_references(monkeypatch):
"""`//host/path` 会被浏览器解析成跨源地址,同源模式必须一并代理。"""
urls = ["//cdn.example/wallpaper.jpg", "/relative/two.jpg"]
helper = Mock()
helper.get_wallpapers.return_value = urls
monkeypatch.setattr(login_endpoint, "WallpaperHelper", Mock(return_value=helper))
monkeypatch.setattr(login_endpoint.settings, "WALLPAPER", "customize")
result = login_endpoint.wallpapers(same_origin=True)
assert result[1] == urls[1]
assert urlparse(result[0]).netloc == ""
signed_source = parse_qs(urlparse(result[0]).query)["url"][0]
assert (
login_endpoint.SecurityUtils.verify_signed_url(
signed_source,
purpose=login_endpoint._LOGIN_WALLPAPER_PUBLIC_PURPOSE,
)
== "https://cdn.example/wallpaper.jpg"
)
def test_url_origin_rejects_malformed_port():
assert login_endpoint._url_origin("https://images.example:invalid/one.jpg") is None
@pytest.mark.asyncio
async def test_wallpaper_image_uses_signed_public_source_without_credentials(
monkeypatch,
):
source_url = "https://images.example/one.jpg"
signed_url = login_endpoint.SecurityUtils.sign_url(
source_url,
purpose=login_endpoint._LOGIN_WALLPAPER_PUBLIC_PURPOSE,
)
image_helper = Mock()
image_helper.async_fetch_image_guarded = AsyncMock(return_value=b"image-bytes")
safety_check = AsyncMock(return_value=True)
monkeypatch.setattr(login_endpoint, "ImageHelper", Mock(return_value=image_helper))
monkeypatch.setattr(
login_endpoint.SecurityUtils,
"is_safe_image_url_async",
safety_check,
)
response = await login_endpoint.wallpaper_image(signed_url)
assert response.status_code == 200
assert response.body == b"image-bytes"
assert response.headers["cache-control"] == "public, max-age=86400"
fetch_options = image_helper.async_fetch_image_guarded.await_args.kwargs
assert fetch_options["url"] == source_url
assert fetch_options["use_cache"] is True
assert fetch_options["max_bytes"] == 32 * 1024 * 1024
assert (
fetch_options["redirect_policy"]
== login_endpoint._LOGIN_WALLPAPER_PUBLIC_PURPOSE
)
assert "cookies" not in fetch_options
assert "max_pixels" not in fetch_options
assert await fetch_options["redirect_validator"](
"https://cdn.example/redirected.jpg"
)
assert safety_check.await_count == 2
@pytest.mark.asyncio
async def test_wallpaper_image_serves_etag_and_conditional_requests(monkeypatch):
"""条件请求命中时只返回 304标准引号/弱标签/标签列表/`*` 都要匹配。"""
source_url = "https://images.example/one.jpg"
signed_url = login_endpoint.SecurityUtils.sign_url(
source_url,
purpose=login_endpoint._LOGIN_WALLPAPER_PUBLIC_PURPOSE,
)
image_helper = Mock()
image_helper.async_fetch_image_guarded = AsyncMock(return_value=b"image-bytes")
monkeypatch.setattr(login_endpoint, "ImageHelper", Mock(return_value=image_helper))
monkeypatch.setattr(
login_endpoint.SecurityUtils,
"is_safe_image_url_async",
AsyncMock(return_value=True),
)
response = await login_endpoint.wallpaper_image(signed_url)
etag = response.headers["etag"]
assert etag == f'"{login_endpoint.HashUtils.md5(b"image-bytes")}"'
for candidate in (etag, f"W/{etag}", f'"other", {etag}', "*"):
conditional = await login_endpoint.wallpaper_image(
signed_url, if_none_match=candidate
)
assert conditional.status_code == 304
assert conditional.headers["etag"] == etag
assert not conditional.body
stale = await login_endpoint.wallpaper_image(
signed_url, if_none_match='"stale-digest"'
)
assert stale.status_code == 200
assert stale.body == b"image-bytes"
@pytest.mark.asyncio
async def test_wallpaper_image_rejects_modified_or_unsafe_public_source(monkeypatch):
source_url = "https://images.example/one.jpg"
signed_url = login_endpoint.SecurityUtils.sign_url(
source_url,
purpose=login_endpoint._LOGIN_WALLPAPER_PUBLIC_PURPOSE,
)
with pytest.raises(HTTPException) as invalid_signature:
await login_endpoint.wallpaper_image(f"{signed_url}modified")
assert invalid_signature.value.status_code == 404
monkeypatch.setattr(
login_endpoint.SecurityUtils,
"is_safe_image_url_async",
AsyncMock(return_value=False),
)
with pytest.raises(HTTPException) as unsafe_source:
await login_endpoint.wallpaper_image(signed_url)
assert unsafe_source.value.status_code == 404
@pytest.mark.asyncio
async def test_media_wallpaper_only_inherits_private_authority_on_same_origin(
monkeypatch,
):
source_url = "http://mediaserver.local:8096/image.jpg"
signed_url = login_endpoint.SecurityUtils.sign_url(
source_url,
purpose=login_endpoint._LOGIN_WALLPAPER_MEDIA_PURPOSE,
)
image_helper = Mock()
image_helper.async_fetch_image_guarded = AsyncMock(return_value=b"image-bytes")
safety_check = AsyncMock(return_value=True)
monkeypatch.setattr(login_endpoint, "ImageHelper", Mock(return_value=image_helper))
monkeypatch.setattr(
login_endpoint.SecurityUtils,
"is_safe_image_url_async",
safety_check,
)
await login_endpoint.wallpaper_image(signed_url)
redirect_validator = (
image_helper.async_fetch_image_guarded.await_args.kwargs["redirect_validator"]
)
assert await redirect_validator(
"http://mediaserver.local:8096/redirected.jpg"
)
assert safety_check.await_count == 0
assert await redirect_validator("https://cdn.example/redirected.jpg")
assert safety_check.await_count == 1
# 私网授权范围写入合并键,公共来源的并发请求不会共享这次抓取
fetch_options = image_helper.async_fetch_image_guarded.await_args.kwargs
assert fetch_options["redirect_policy"] == (
f"{login_endpoint._LOGIN_WALLPAPER_MEDIA_PURPOSE}"
":http://mediaserver.local:8096"
)
class _StreamResponse:
"""提供受限图片抓取测试所需的最小流式响应合同。"""
def __init__(self, status_code, *, headers=None, chunks=None, gate=None):
self.status_code = status_code
self.headers = headers or {}
self._chunks = chunks or []
self._gate = gate
async def aiter_bytes(self):
if self._gate:
await self._gate.wait()
for chunk in self._chunks:
yield chunk
class _StreamContext:
"""模拟 AsyncRequestUtils.get_stream 返回的异步上下文。"""
def __init__(self, response):
self._response = response
async def __aenter__(self):
return self._response
async def __aexit__(self, *_):
return False
def _guarded_image_helper():
helper = object.__new__(ImageHelper)
helper.async_file_cache = Mock()
helper.async_file_cache.get = AsyncMock(return_value=None)
helper.async_file_cache.delete = AsyncMock()
helper.async_file_cache.set = AsyncMock()
helper._guarded_fetch_tasks = {}
helper._guarded_fetch_tasks_lock = threading.Lock()
return helper
def _png_bytes():
content = io.BytesIO()
Image.new("RGB", (1, 1), color="black").save(content, format="PNG")
return content.getvalue()
@pytest.mark.asyncio
async def test_guarded_image_fetch_rejects_redirect_before_second_request(
monkeypatch,
):
helper = _guarded_image_helper()
request = Mock()
request.get_stream.return_value = _StreamContext(
_StreamResponse(302, headers={"location": "http://127.0.0.1/private.jpg"})
)
request_factory = Mock(return_value=request)
redirect_validator = AsyncMock(return_value=False)
monkeypatch.setattr(image_module, "AsyncRequestUtils", request_factory)
content = await helper.async_fetch_image_guarded(
"https://images.example/one.jpg",
redirect_validator=redirect_validator,
redirect_policy="public",
max_bytes=1024,
use_cache=False,
)
assert content is None
redirect_validator.assert_awaited_once_with("http://127.0.0.1/private.jpg")
request_factory.assert_called_once()
@pytest.mark.asyncio
async def test_guarded_image_fetch_stops_chunked_response_over_byte_limit(
monkeypatch,
):
helper = _guarded_image_helper()
request = Mock()
request.get_stream.return_value = _StreamContext(
_StreamResponse(200, chunks=[b"1234", b"5678"])
)
monkeypatch.setattr(image_module, "AsyncRequestUtils", Mock(return_value=request))
content = await helper.async_fetch_image_guarded(
"https://images.example/one.jpg",
redirect_validator=AsyncMock(return_value=True),
redirect_policy="public",
max_bytes=6,
use_cache=False,
)
assert content is None
@pytest.mark.asyncio
async def test_guarded_image_fetch_coalesces_same_cache_key(monkeypatch):
helper = _guarded_image_helper()
gate = asyncio.Event()
request = Mock()
request.get_stream.return_value = _StreamContext(
_StreamResponse(200, chunks=[_png_bytes()], gate=gate)
)
request_factory = Mock(return_value=request)
monkeypatch.setattr(image_module, "AsyncRequestUtils", request_factory)
first = asyncio.create_task(
helper.async_fetch_image_guarded(
"https://images.example/one.png",
redirect_validator=AsyncMock(return_value=True),
redirect_policy="public",
max_bytes=1024,
use_cache=True,
)
)
second = asyncio.create_task(
helper.async_fetch_image_guarded(
"https://images.example/one.png",
redirect_validator=AsyncMock(return_value=True),
redirect_policy="public",
max_bytes=1024,
use_cache=True,
)
)
await asyncio.sleep(0)
gate.set()
first_result, second_result = await asyncio.gather(first, second)
assert first_result == second_result == _png_bytes()
assert request_factory.call_count == 1
helper.async_file_cache.set.assert_awaited_once()
@pytest.mark.asyncio
async def test_guarded_image_fetch_does_not_share_across_fetch_policies(monkeypatch):
"""字节上限或重定向策略不同的调用必须各自抓取,不能继承他人的限制。"""
helper = _guarded_image_helper()
gate = asyncio.Event()
request = Mock()
request.get_stream.return_value = _StreamContext(
_StreamResponse(200, chunks=[_png_bytes()], gate=gate)
)
request_factory = Mock(return_value=request)
monkeypatch.setattr(image_module, "AsyncRequestUtils", request_factory)
def fetch(*, max_bytes, redirect_policy):
return asyncio.create_task(
helper.async_fetch_image_guarded(
"https://images.example/one.png",
redirect_validator=AsyncMock(return_value=True),
redirect_policy=redirect_policy,
max_bytes=max_bytes,
use_cache=True,
)
)
larger_limit = fetch(max_bytes=4096, redirect_policy="public")
smaller_limit = fetch(max_bytes=8, redirect_policy="public")
other_policy = fetch(max_bytes=4096, redirect_policy="media:http://host:8096")
await asyncio.sleep(0)
gate.set()
results = await asyncio.gather(larger_limit, smaller_limit, other_policy)
assert request_factory.call_count == 3
# 8 字节上限的调用不能收到超出自身上限的共享结果
assert results[1] is None
assert results[0] == results[2] == _png_bytes()