mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
fix(logos): one hardened logo download path; shared, real HTTP headers (#612)
* fix(logos): harden the plugin logo download and share core HTTP headers download_missing_logo / LogoDownloader.download_logo, the path the scoreboard plugins use, read response.content with no size cap and wrote straight to the final path, so a failed or corrupt download could be left in place and cached as the logo. It now goes through fetch_logo: streamed with a 10 MB cap, image/* only, decoded by Pillow, converted to RGBA once, and moved into place atomically. A failure leaves no partial or temp file and keeps any logo already on disk. LogoHelper._download_logo delegates to the same code. Public signatures and return values are unchanged; saved files are pixel-identical to before (RGBA, palette+tRNS, L+tRNS, LA, JPEG). download_missing_logo reuses one downloader per thread instead of a new Session per logo. Per thread rather than behind a lock: Session is not documented thread-safe, and a lock would serialise every plugin's downloads behind the slowest one. Placeholders are written atomically, without the test_write.tmp probe. The logo downloader and background data service now send the real ChuckBuilds User-Agent from src.common.api_helper (USER_AGENT, DEFAULT_HTTP_HEADERS) instead of a yourusername/contact@example.com placeholder, and no longer hand-set Accept-Encoding: br (brotli is not installed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(http): drop APIHelper's hand-set brotli encoding; LogoHelper sends the real UA APIHelper advertised `br` though brotli isn't installed, so a server that honoured it would send a body requests can't decode. LogoHelper sent a bare `LEDMatrix-Common/1.0`, the kind of User-Agent ESPN has been rejecting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
"""One set of HTTP headers for core's ESPN/data requests.
|
||||
|
||||
The logo downloader and the background data service each carried their own
|
||||
header dict with a placeholder User-Agent (``yourusername/LEDMatrix;
|
||||
contact@example.com``) -- the kind of nonconforming token ESPN began 403ing
|
||||
around 2026-08-04 -- and a hand-set ``Accept-Encoding: ... br`` although brotli
|
||||
is not installed, so a ``br`` body could not have been decoded.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS, USER_AGENT, APIHelper
|
||||
|
||||
|
||||
def _lower_keys(headers):
|
||||
return {k.lower(): v for k, v in headers.items()}
|
||||
|
||||
|
||||
class TestSharedHeaders:
|
||||
def test_user_agent_names_the_project(self):
|
||||
assert USER_AGENT == 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'
|
||||
assert DEFAULT_HTTP_HEADERS['User-Agent'] == USER_AGENT
|
||||
|
||||
def test_no_hand_set_accept_encoding(self):
|
||||
assert 'accept-encoding' not in _lower_keys(DEFAULT_HTTP_HEADERS)
|
||||
|
||||
def test_is_read_only(self):
|
||||
with pytest.raises(TypeError):
|
||||
DEFAULT_HTTP_HEADERS['User-Agent'] = 'x' # type: ignore[index]
|
||||
|
||||
def test_api_helper_sends_the_same_user_agent(self):
|
||||
assert APIHelper().session.headers['User-Agent'] == USER_AGENT
|
||||
|
||||
def test_api_helper_does_not_hand_set_brotli(self):
|
||||
assert 'br' not in APIHelper().session.headers.get('Accept-Encoding', '')
|
||||
|
||||
def test_logo_helper_sends_the_same_user_agent(self):
|
||||
from src.common.logo_helper import LogoHelper
|
||||
assert LogoHelper(display_width=64, display_height=32).session.headers['User-Agent'] == USER_AGENT
|
||||
|
||||
|
||||
class TestLogoDownloaderHeaders:
|
||||
def test_uses_the_shared_headers(self):
|
||||
from src.logo_downloader import LogoDownloader
|
||||
headers = LogoDownloader().headers
|
||||
assert headers['User-Agent'] == USER_AGENT
|
||||
assert 'accept-encoding' not in _lower_keys(headers)
|
||||
assert 'yourusername' not in str(headers)
|
||||
|
||||
def test_instance_headers_are_a_private_copy(self):
|
||||
from src.logo_downloader import LogoDownloader
|
||||
downloader = LogoDownloader()
|
||||
downloader.headers['X-Test'] = '1'
|
||||
assert 'X-Test' not in DEFAULT_HTTP_HEADERS
|
||||
assert 'X-Test' not in LogoDownloader().headers
|
||||
|
||||
def test_logo_request_sends_the_user_agent_and_asks_for_an_image(self, tmp_path):
|
||||
from src.logo_downloader import LogoDownloader
|
||||
downloader = LogoDownloader()
|
||||
downloader.session.get = MagicMock(side_effect=RuntimeError("stop"))
|
||||
downloader.download_logo("http://x/a.png", tmp_path / "A.png", "A")
|
||||
sent = _lower_keys(downloader.session.get.call_args.kwargs['headers'])
|
||||
assert sent['user-agent'] == USER_AGENT
|
||||
assert sent['accept'].startswith('image/')
|
||||
assert 'accept-encoding' not in sent
|
||||
|
||||
|
||||
class TestBackgroundDataServiceHeaders:
|
||||
def test_uses_the_shared_headers(self):
|
||||
from src.background_data_service import BackgroundDataService
|
||||
service = BackgroundDataService(MagicMock(), max_workers=1)
|
||||
try:
|
||||
headers = service.default_headers
|
||||
assert headers['User-Agent'] == USER_AGENT
|
||||
assert 'accept-encoding' not in _lower_keys(headers)
|
||||
assert 'yourusername' not in str(headers)
|
||||
finally:
|
||||
service.shutdown(wait=False)
|
||||
@@ -7,16 +7,20 @@ ensure_logo_directory, and the download_missing_logo function path
|
||||
(with HTTP mocked).
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, Mock, MagicMock
|
||||
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
import src.logo_downloader as logo_downloader_module
|
||||
from src.logo_downloader import (
|
||||
PLACEHOLDER_BG,
|
||||
PLACEHOLDER_MARKER,
|
||||
@@ -351,3 +355,266 @@ class TestRefreshPlaceholderTimestamp:
|
||||
|
||||
def test_missing_file_is_not_an_error(self, tmp_path):
|
||||
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# download_logo: the download the scoreboard plugins actually use
|
||||
#
|
||||
# It used to read response.content with no size cap, write straight to the
|
||||
# final path (so a failed or corrupt download could be left there and then be
|
||||
# cached as the logo), and build a fresh Session for every logo. It now goes
|
||||
# through fetch_logo, the hardened download LogoHelper also uses.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _png(img: Image.Image) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _stream(body: bytes, content_type: str = "image/png", chunk: int = 1024,
|
||||
die_after: int | None = None):
|
||||
"""A streamed requests.Response stand-in.
|
||||
|
||||
``die_after`` makes the transfer fail after that many bytes, the way a
|
||||
reset connection does mid-body.
|
||||
"""
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.__exit__.return_value = False
|
||||
response.raise_for_status = MagicMock()
|
||||
response.headers = {"content-type": content_type}
|
||||
|
||||
def _iter_content(*_args, **_kwargs):
|
||||
sent = 0
|
||||
for i in range(0, len(body), chunk):
|
||||
if die_after is not None and sent >= die_after:
|
||||
raise requests.exceptions.ChunkedEncodingError("connection reset")
|
||||
piece = body[i:i + chunk]
|
||||
sent += len(piece)
|
||||
yield piece
|
||||
|
||||
response.iter_content = _iter_content
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_thread_state(monkeypatch):
|
||||
"""Each test gets its own per-thread downloader cache."""
|
||||
monkeypatch.setattr(logo_downloader_module, "_thread_state", threading.local())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def downloader():
|
||||
return LogoDownloader()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def old_logo(tmp_path):
|
||||
"""A logo already on disk that a failed re-download must not damage."""
|
||||
path = tmp_path / "PHI.png"
|
||||
Image.new("RGBA", (30, 30), (1, 2, 3, 255)).save(path)
|
||||
return path, path.read_bytes()
|
||||
|
||||
|
||||
def _leftovers(directory: Path, keep: str | None = None):
|
||||
return sorted(p.name for p in directory.iterdir() if p.name != keep)
|
||||
|
||||
|
||||
class TestDownloadLogoHardening:
|
||||
def test_valid_logo_is_saved_as_rgba_png(self, downloader, tmp_path):
|
||||
target = tmp_path / "PHI.png"
|
||||
downloader.session.get = MagicMock(
|
||||
return_value=_stream(_png(Image.new("RGB", (20, 10), (9, 8, 7)))))
|
||||
assert downloader.download_logo("http://x/phi.png", target, "PHI") is True
|
||||
with Image.open(target) as img:
|
||||
assert img.format == "PNG" and img.mode == "RGBA"
|
||||
assert img.getpixel((0, 0)) == (9, 8, 7, 255)
|
||||
assert _leftovers(tmp_path, keep="PHI.png") == []
|
||||
# Streamed, so the size cap applies before the body is buffered.
|
||||
assert downloader.session.get.call_args.kwargs["stream"] is True
|
||||
|
||||
def test_oversized_response_is_rejected_and_leaves_no_file(
|
||||
self, downloader, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(logo_downloader_module, "MAX_LOGO_BYTES", 4096)
|
||||
target = tmp_path / "BIG.png"
|
||||
body = _png(Image.new("RGB", (8, 8))) + b"\x00" * 8192
|
||||
downloader.session.get = MagicMock(return_value=_stream(body))
|
||||
assert downloader.download_logo("http://x/big.png", target, "BIG") is False
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
def test_oversized_response_does_not_replace_an_existing_logo(
|
||||
self, downloader, old_logo, monkeypatch):
|
||||
path, before = old_logo
|
||||
monkeypatch.setattr(logo_downloader_module, "MAX_LOGO_BYTES", 4096)
|
||||
downloader.session.get = MagicMock(
|
||||
return_value=_stream(b"\x89PNG" + b"\x00" * 8192))
|
||||
assert downloader.download_logo("http://x/big.png", path, "PHI") is False
|
||||
assert path.read_bytes() == before
|
||||
assert _leftovers(path.parent, keep=path.name) == []
|
||||
|
||||
def test_mid_download_failure_leaves_no_partial_file(self, downloader, tmp_path):
|
||||
target = tmp_path / "CUT.png"
|
||||
body = _png(Image.new("RGB", (200, 200), (5, 5, 5))) + b"\x00" * 4096
|
||||
downloader.session.get = MagicMock(
|
||||
return_value=_stream(body, chunk=256, die_after=512))
|
||||
assert downloader.download_logo("http://x/cut.png", target, "CUT") is False
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
def test_mid_download_failure_keeps_the_previous_logo(self, downloader, old_logo):
|
||||
# The old code opened the final path for writing before the body
|
||||
# arrived, so a dropped connection truncated the logo it was replacing.
|
||||
path, before = old_logo
|
||||
body = _png(Image.new("RGB", (200, 200), (5, 5, 5))) + b"\x00" * 4096
|
||||
downloader.session.get = MagicMock(
|
||||
return_value=_stream(body, chunk=256, die_after=512))
|
||||
assert downloader.download_logo("http://x/cut.png", path, "PHI") is False
|
||||
assert path.read_bytes() == before
|
||||
assert _leftovers(path.parent, keep=path.name) == []
|
||||
|
||||
def test_non_image_content_type_is_rejected(self, downloader, old_logo):
|
||||
# Rejected on the label alone, before the body is trusted: these bytes
|
||||
# would decode, so only the content-type check stops them.
|
||||
path, before = old_logo
|
||||
body = _png(Image.new("RGB", (8, 8), (250, 0, 0)))
|
||||
downloader.session.get = MagicMock(
|
||||
return_value=_stream(body, content_type="text/html"))
|
||||
assert downloader.download_logo("http://x/404", path, "PHI") is False
|
||||
assert path.read_bytes() == before
|
||||
assert _leftovers(path.parent, keep=path.name) == []
|
||||
|
||||
def test_bytes_that_do_not_decode_are_rejected(self, downloader, old_logo):
|
||||
# A server that labels an error page image/png must not get it cached.
|
||||
path, before = old_logo
|
||||
downloader.session.get = MagicMock(
|
||||
return_value=_stream(b"<html>oops</html>", content_type="image/png"))
|
||||
assert downloader.download_logo("http://x/lie.png", path, "PHI") is False
|
||||
assert path.read_bytes() == before
|
||||
assert _leftovers(path.parent, keep=path.name) == []
|
||||
|
||||
def test_http_error_keeps_the_previous_logo(self, downloader, old_logo):
|
||||
path, before = old_logo
|
||||
response = _stream(b"")
|
||||
response.raise_for_status.side_effect = requests.exceptions.HTTPError("503")
|
||||
downloader.session.get = MagicMock(return_value=response)
|
||||
assert downloader.download_logo("http://x/phi.png", path, "PHI") is False
|
||||
assert path.read_bytes() == before
|
||||
assert _leftovers(path.parent, keep=path.name) == []
|
||||
|
||||
def test_unwritable_directory_returns_false(self, downloader, tmp_path):
|
||||
downloader.session.get = MagicMock()
|
||||
with patch("src.logo_downloader.tempfile.mkstemp",
|
||||
side_effect=PermissionError("read-only")):
|
||||
assert downloader.download_logo(
|
||||
"http://x/phi.png", tmp_path / "PHI.png", "PHI") is False
|
||||
downloader.session.get.assert_not_called()
|
||||
|
||||
|
||||
class TestDownloadLogoTransparency:
|
||||
"""Plugins paste logos with the image as its own mask; alpha must survive."""
|
||||
|
||||
def _download(self, downloader, tmp_path, body, content_type="image/png"):
|
||||
target = tmp_path / "LOGO.png"
|
||||
downloader.session.get = MagicMock(return_value=_stream(body, content_type))
|
||||
assert downloader.download_logo("http://x/logo", target, "LOGO") is True
|
||||
with Image.open(target) as img:
|
||||
img.load()
|
||||
return img.copy(), img.format
|
||||
|
||||
def test_rgba_alpha_is_kept_exactly(self, downloader, tmp_path):
|
||||
src = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
|
||||
for x in range(16):
|
||||
src.putpixel((x, 3), (200, 100, 50, x * 16))
|
||||
out, _ = self._download(downloader, tmp_path, _png(src))
|
||||
assert out.mode == "RGBA"
|
||||
assert list(out.getdata()) == list(src.getdata())
|
||||
|
||||
def test_palette_transparency_becomes_alpha(self, downloader, tmp_path):
|
||||
src = Image.new("P", (8, 8), 0)
|
||||
src.putpalette([0, 0, 0, 255, 0, 0] + [0] * (254 * 3))
|
||||
src.putpixel((4, 4), 1)
|
||||
buf = io.BytesIO()
|
||||
src.save(buf, "PNG", transparency=0)
|
||||
out, _ = self._download(downloader, tmp_path, buf.getvalue())
|
||||
assert out.mode == "RGBA"
|
||||
assert out.getpixel((0, 0))[3] == 0
|
||||
assert out.getpixel((4, 4)) == (255, 0, 0, 255)
|
||||
|
||||
def test_greyscale_transparency_becomes_alpha(self, downloader, tmp_path):
|
||||
src = Image.new("L", (8, 8), 0)
|
||||
src.putpixel((2, 2), 255)
|
||||
buf = io.BytesIO()
|
||||
src.save(buf, "PNG", transparency=0)
|
||||
out, _ = self._download(downloader, tmp_path, buf.getvalue())
|
||||
assert out.getpixel((0, 0))[3] == 0
|
||||
assert out.getpixel((2, 2)) == (255, 255, 255, 255)
|
||||
|
||||
def test_jpeg_is_stored_as_opaque_rgba_png(self, downloader, tmp_path):
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), (10, 200, 30)).save(buf, "JPEG", quality=95)
|
||||
out, fmt = self._download(downloader, tmp_path, buf.getvalue(), "image/jpeg")
|
||||
assert fmt == "PNG" and out.mode == "RGBA"
|
||||
assert out.getchannel("A").getextrema() == (255, 255)
|
||||
|
||||
|
||||
class TestSharedDownloader:
|
||||
def test_download_missing_logo_reuses_one_session(self, tmp_path, fresh_thread_state):
|
||||
sessions = []
|
||||
real_session = requests.Session
|
||||
|
||||
def counting_session(*args, **kwargs):
|
||||
s = real_session(*args, **kwargs)
|
||||
sessions.append(s)
|
||||
return s
|
||||
|
||||
with patch("src.logo_downloader.requests.Session", side_effect=counting_session):
|
||||
with patch.object(LogoDownloader, "download_logo", return_value=True) as dl:
|
||||
for abbr in ("AAA", "BBB", "CCC"):
|
||||
assert download_missing_logo(
|
||||
"nfl", "1", abbr, tmp_path / f"{abbr}.png",
|
||||
logo_url=f"http://x/{abbr}.png",
|
||||
create_placeholder=False) is True
|
||||
assert dl.call_count == 3
|
||||
assert len(sessions) == 1
|
||||
|
||||
def test_each_thread_gets_its_own_downloader(self, fresh_thread_state):
|
||||
here = logo_downloader_module.shared_downloader()
|
||||
assert logo_downloader_module.shared_downloader() is here
|
||||
seen = []
|
||||
t = threading.Thread(target=lambda: seen.append(
|
||||
logo_downloader_module.shared_downloader()))
|
||||
t.start()
|
||||
t.join()
|
||||
assert seen and seen[0] is not here
|
||||
assert seen[0].session is not here.session
|
||||
|
||||
|
||||
class TestPlaceholderWrite:
|
||||
def test_no_write_probe_file_is_created(self, tmp_path):
|
||||
created = []
|
||||
real_touch = Path.touch
|
||||
|
||||
def spy_touch(self, *args, **kwargs):
|
||||
created.append(self.name)
|
||||
return real_touch(self, *args, **kwargs)
|
||||
|
||||
with patch.object(Path, "touch", spy_touch):
|
||||
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path)) is True
|
||||
assert "test_write.tmp" not in created
|
||||
assert sorted(p.name for p in tmp_path.iterdir()) == ["COLL.png"]
|
||||
|
||||
def test_unwritable_directory_returns_false(self, tmp_path):
|
||||
with patch.object(LogoDownloader, "ensure_logo_directory", return_value=True), \
|
||||
patch("src.logo_downloader.tempfile.mkstemp",
|
||||
side_effect=PermissionError("read-only")):
|
||||
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path)) is False
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
def test_failed_save_keeps_the_previous_file(self, tmp_path):
|
||||
path = tmp_path / "COLL.png"
|
||||
Image.new("RGBA", (30, 30), (1, 2, 3, 255)).save(path)
|
||||
before = path.read_bytes()
|
||||
with patch("src.logo_downloader.os.replace", side_effect=OSError("disk full")):
|
||||
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path)) is False
|
||||
assert path.read_bytes() == before
|
||||
assert sorted(p.name for p in tmp_path.iterdir()) == ["COLL.png"]
|
||||
|
||||
@@ -33,7 +33,7 @@ def _no_real_chmod(monkeypatch):
|
||||
# Keep the permission helpers out of the way: their own env detection
|
||||
# is not what these tests are about.
|
||||
monkeypatch.setattr("src.common.logo_helper.ensure_directory_permissions", MagicMock())
|
||||
monkeypatch.setattr("src.common.logo_helper.ensure_file_permissions", MagicMock())
|
||||
monkeypatch.setattr("src.logo_downloader.ensure_file_permissions", MagicMock())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -48,7 +48,8 @@ def write_logo(path: Path, size=(20, 20), color=(255, 0, 0), fmt="PNG") -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def fake_response(content: bytes, chunk_size: int = 64 * 1024):
|
||||
def fake_response(content: bytes, chunk_size: int = 64 * 1024,
|
||||
content_type: str = "image/png"):
|
||||
"""Stand-in for a streamed requests.Response.
|
||||
|
||||
_download_logo opens `with session.get(..., stream=True)` and reads
|
||||
@@ -61,6 +62,7 @@ def fake_response(content: bytes, chunk_size: int = 64 * 1024):
|
||||
response.__enter__.return_value = response
|
||||
response.__exit__.return_value = False
|
||||
response.raise_for_status = MagicMock()
|
||||
response.headers = {"content-type": content_type}
|
||||
|
||||
def _iter_content(*_args, **_kwargs):
|
||||
for i in range(0, len(content), chunk_size):
|
||||
@@ -80,6 +82,7 @@ def endless_response(chunk: bytes = b"\x00" * 65536):
|
||||
response.__enter__.return_value = response
|
||||
response.__exit__.return_value = False
|
||||
response.raise_for_status = MagicMock()
|
||||
response.headers = {"content-type": "image/png"}
|
||||
|
||||
def _iter_content(*_args, **_kwargs):
|
||||
while True:
|
||||
@@ -208,7 +211,7 @@ class TestLoadLogoWithDownload:
|
||||
# stream=True is load-bearing: it is what lets the size cap apply
|
||||
# before the body is buffered.
|
||||
helper.session.get.assert_called_once_with(
|
||||
"http://x/logo.png", timeout=30, stream=True)
|
||||
"http://x/logo.png", headers=None, timeout=30, stream=True)
|
||||
|
||||
def test_download_failure_falls_back_to_placeholder(self, helper, tmp_path):
|
||||
helper.session.get = MagicMock(
|
||||
@@ -241,7 +244,7 @@ class TestDownloadLogo:
|
||||
path.parent.mkdir()
|
||||
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
|
||||
with patch("src.common.logo_helper.ensure_directory_permissions") as dirs, \
|
||||
patch("src.common.logo_helper.ensure_file_permissions") as files:
|
||||
patch("src.logo_downloader.ensure_file_permissions") as files:
|
||||
helper._download_logo("http://x/logo.png", path)
|
||||
assert path.exists()
|
||||
dirs.assert_called_once()
|
||||
@@ -284,6 +287,7 @@ class TestDownloadLogo:
|
||||
response.__enter__.return_value = response
|
||||
response.__exit__.return_value = False
|
||||
response.raise_for_status = MagicMock()
|
||||
response.headers = {"content-type": "image/png"}
|
||||
response.iter_content = _dies_midway
|
||||
helper.session.get = MagicMock(return_value=response)
|
||||
|
||||
@@ -305,7 +309,7 @@ class TestDownloadLogo:
|
||||
seen.append(name)
|
||||
return fd, name
|
||||
|
||||
with patch("src.common.logo_helper.tempfile.mkstemp", side_effect=record):
|
||||
with patch("src.logo_downloader.tempfile.mkstemp", side_effect=record):
|
||||
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
|
||||
helper._download_logo("http://x/logo.png", path)
|
||||
helper.session.get = MagicMock(return_value=fake_response(png_bytes()))
|
||||
@@ -351,7 +355,7 @@ class TestDownloadLogo:
|
||||
def load(self):
|
||||
raise Image.DecompressionBombError("too many pixels")
|
||||
|
||||
monkeypatch.setattr("src.common.logo_helper.Image.open", lambda *a, **kw: Bomb())
|
||||
monkeypatch.setattr("src.logo_downloader.Image.open", lambda *a, **kw: Bomb())
|
||||
with pytest.raises(Image.DecompressionBombError):
|
||||
helper._download_logo("http://x/bomb.png", path)
|
||||
assert not path.exists()
|
||||
@@ -420,7 +424,8 @@ class TestPlaceholderLogo:
|
||||
|
||||
class TestSessionConfiguration:
|
||||
def test_user_agent_and_accept_headers(self, helper):
|
||||
assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0"
|
||||
from src.common.api_helper import USER_AGENT
|
||||
assert helper.session.headers["User-Agent"] == USER_AGENT
|
||||
assert helper.session.headers["Accept"] == "image/*"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user