soulsync/tests/test_image_cache.py
BoulderBadgeDad c3f7cf795a Image cache: reject truncated downloads instead of caching broken covers (#750)
Reporter: album covers render as a top strip then solid grey ('break' on
import) — and it happens regardless of the album-art toggles. That ruled out
the import embed/cover.jpg paths (all toggle-gated) and pointed at the DISPLAY
cache, which every cover view goes through.

Root cause: ImageCache._fetch_and_store streamed the body to a tmp file and
committed it as status='ok' with only a 'total <= 0' (empty) guard. A
dropped/short connection makes requests' iter_content END EARLY WITHOUT
raising, so a PARTIAL image was cached permanently and served forever as a
half-decoded cover. The high-res art change in 2.6.4 (bigger images) makes a
mid-stream cutoff more likely, especially on the reporter's LXC.

Fix: capture the declared Content-Length and, after streaming, reject when
fewer bytes arrived (unlink the tmp file, raise ImageCacheError) so nothing
broken is cached and the next request retries fresh. When the server omits
Content-Length (chunked), we can't detect truncation, so we don't reject —
behavior unchanged there.

Tests (tests/test_image_cache.py): truncated download raises + caches nothing +
a later good fetch still works (differential-verified it's silently cached
without the guard); positive control (declared==actual) caches normally;
no-Content-Length still caches. 6 image-cache tests pass.

Strong-candidate fix: it's a real defect that produces exactly this symptom,
but I can't reproduce the reporter's LXC network to prove it's THE cause.
2026-05-30 17:45:01 -07:00

137 lines
4.7 KiB
Python

from __future__ import annotations
from core.image_cache import ImageCache
class FakeResponse:
def __init__(self, body: bytes, *, status_code: int = 200, content_type: str = "image/jpeg",
declared_length: int | None = None):
self.body = body
self.status_code = status_code
# declared_length lets a test simulate a truncated download: the server
# promises N bytes (Content-Length) but the body delivers fewer.
length = len(body) if declared_length is None else declared_length
self.headers = {
"Content-Type": content_type,
"Content-Length": str(length),
}
self.closed = False
def iter_content(self, chunk_size=65536):
yield self.body
def close(self):
self.closed = True
def test_cache_url_for_registers_hashed_browser_path(tmp_path):
cache = ImageCache(tmp_path)
url = "https://images.example.test/cover.jpg?token=secret"
cached_url = cache.cache_url_for(url)
assert cached_url == f"/api/image-cache/{ImageCache.key_for_url(url)}"
assert "secret" not in cached_url
def test_get_url_fetches_once_then_serves_cached_file(tmp_path):
calls = []
def fetcher(url, **kwargs):
calls.append((url, kwargs))
return FakeResponse(b"fake-jpeg-bytes")
cache = ImageCache(tmp_path, fetcher=fetcher)
url = "https://images.example.test/cover.jpg"
first = cache.get_url(url)
second = cache.get_url(url)
assert first.status == "miss"
assert second.status == "hit"
assert first.path == second.path
assert first.path.read_bytes() == b"fake-jpeg-bytes"
assert first.mime_type == "image/jpeg"
assert len(calls) == 1
def test_truncated_download_is_rejected_not_cached(tmp_path):
"""#750: a short/dropped download (body shorter than the declared
Content-Length) must NOT be committed as a good cache entry — otherwise the
half-decoded cover (top strip, rest grey) is served forever. It should raise
and leave nothing cached, so the next request retries fresh."""
calls = []
def fetcher(url, **kwargs):
calls.append(url)
# Server promises 5000 bytes but only delivers 800 (connection dropped).
return FakeResponse(b"x" * 800, declared_length=5000)
cache = ImageCache(tmp_path, fetcher=fetcher)
url = "https://images.example.test/big-cover.jpg"
raised = False
try:
cache.get_url(url)
except Exception as exc:
raised = True
assert "Truncated" in str(exc) or "truncated" in str(exc)
assert raised, "Expected a truncated download to raise"
# Nothing partial left on disk for this key.
import glob
key = ImageCache.key_for_url(url)
leftover = glob.glob(str(tmp_path / "**" / f"{key}*"), recursive=True)
leftover = [p for p in leftover if not p.endswith(".sqlite3")]
assert leftover == [], f"truncated file should not be cached, found: {leftover}"
# A subsequent SUCCESSFUL fetch works (not poisoned by the failed attempt).
cache2 = ImageCache(
tmp_path,
fetcher=lambda u, **k: FakeResponse(b"complete-jpeg-bytes"),
)
result = cache2.get_url(url)
assert result.path.read_bytes() == b"complete-jpeg-bytes"
def test_complete_download_with_content_length_succeeds(tmp_path):
"""Positive control: a full download whose body matches Content-Length is
cached normally (the truncation guard doesn't false-positive)."""
cache = ImageCache(
tmp_path,
fetcher=lambda u, **k: FakeResponse(b"a-real-cover"), # declared==actual
)
result = cache.get_url("https://images.example.test/ok-cover.jpg")
assert result.path.read_bytes() == b"a-real-cover"
assert result.status == "miss"
def test_no_content_length_still_caches(tmp_path):
"""Some CDNs omit Content-Length (chunked transfer). With no declared size
we can't detect truncation, so we must NOT reject — cache as before."""
class NoLengthResponse(FakeResponse):
def __init__(self, body):
super().__init__(body)
del self.headers["Content-Length"]
cache = ImageCache(
tmp_path,
fetcher=lambda u, **k: NoLengthResponse(b"chunked-cover-bytes"),
)
result = cache.get_url("https://images.example.test/chunked.jpg")
assert result.path.read_bytes() == b"chunked-cover-bytes"
def test_get_url_rejects_non_image_responses(tmp_path):
cache = ImageCache(
tmp_path,
fetcher=lambda url, **kwargs: FakeResponse(b"<html></html>", content_type="text/html"),
)
try:
cache.get_url("https://images.example.test/not-image")
except Exception as exc:
assert "not an image" in str(exc)
else:
raise AssertionError("Expected non-image response to fail")