"""Tests for the PR-C cover picker's server side: the /art/candidates assembly (current + pack + Cover Art Archive index candidates), the `caa_index_{id}.json` TTL-less cache around the new `_caa_release_index` seam, the `?source=pack` art-route variant, and the redirect-following art-by-URL fetch that lets a CAA pick apply through the existing override lane. Both network seams (`_caa_release_index`, `requests.get` under `_fetch_art_url`) are faked — nothing here opens a socket, and the offline default is itself asserted. Fixture patterns mirror tests/test_art_layer.py. """ import importlib import enrichment from routers import art import io as _io import sys import pytest from fastapi.testclient import TestClient from PIL import Image @pytest.fixture() def server(tmp_path, monkeypatch, isolate_logging): monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config")) dlc = tmp_path / "dlc" dlc.mkdir() monkeypatch.setenv("DLC_DIR", str(dlc)) monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1") sys.modules.pop("server", None) srv = importlib.import_module("server") try: yield srv finally: conn = getattr(getattr(srv, "meta_db", None), "conn", None) if conn is not None: getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)() conn.close() sys.modules.pop("server", None) @pytest.fixture() def client(server): return TestClient(server.app) def png_bytes(color=(200, 30, 30)): buf = _io.BytesIO() Image.new("RGB", (4, 4), color).save(buf, "PNG") return buf.getvalue() def b64(data): import base64 return base64.b64encode(data).decode() def make_sloppak(server, name, with_cover=False, title="Song", artist="Artist"): d = server.DLC_DIR / name d.mkdir(parents=True) (d / "manifest.yaml").write_text( f"title: {title}\nartist: {artist}\nduration: 100\n" "arrangements: []\nstems: []\n", encoding="utf-8") if with_cover: (d / "cover.jpg").write_bytes(png_bytes((10, 200, 10))) server.meta_db.put(name, 0, 0, { "title": title, "artist": artist, "album": "", "year": "", "duration": 100, "arrangements": [{"name": "Lead", "index": 0}], }) return d def _match_row(server, fn, release_id="rel-1", state="matched"): """Seed a matched/manual enrichment row with a release id (as the P8 matcher would have written).""" song = server.meta_db.enrichment_song_row(fn) h = server.meta_db.enrichment_content_hash( song["artist"], song["title"], song["album"], song["duration"]) server.meta_db.apply_enrichment_match( fn, h, state, source="text", score=1.0, cand={"recording_id": "rec-1", "release_id": release_id, "title": song["title"], "artist": song["artist"]}) def _review_row(server, fn, candidates): """Seed a review-tier row: no canonical release of its own, releases live only in the stored candidates JSON.""" song = server.meta_db.enrichment_song_row(fn) h = server.meta_db.enrichment_content_hash( song["artist"], song["title"], song["album"], song["duration"]) server.meta_db.apply_enrichment_match( fn, h, "review", source="text", score=0.75, candidates=candidates) def _img(img_id, *, front=False, approved=True, sizes=("500",)): """One CAA index image dict, with thumbnails for the given size keys.""" return { "id": img_id, "front": front, "approved": approved, "types": ["Front"] if front else ["Back"], "image": f"https://caa.example/full/{img_id}.jpg", "thumbnails": {s: f"https://caa.example/{img_id}-{s}.jpg" for s in sizes}, } @pytest.fixture() def caa_index(server, monkeypatch): """Fake CAA index transport + network flag on (mirrors the art-layer `caa` fixture; this is the picker's own seam).""" calls = [] indexes = { "rel-1": {"images": [_img(101, front=True), _img(102, approved=False, sizes=("250",))]}, "rel-2": {"images": [_img(201, front=True)]}, } def fake(release_id): calls.append(release_id) return indexes.get(release_id) # unknown release → None (a CAA 404) fake.calls, fake.indexes = calls, indexes monkeypatch.setattr(enrichment, "_caa_release_index", fake) monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True) return fake def _get(client, fn="a.sloppak"): r = client.get(f"/api/song/{fn}/art/candidates") assert r.status_code == 200 return r.json() def _caa(body): return [c for c in body["candidates"] if c["kind"] == "caa"] def _current(body): return next(c for c in body["candidates"] if c["kind"] == "current") # ── candidate assembly ──────────────────────────────────────────────────────── def test_matched_row_lists_index_images(server, client, caa_index): make_sloppak(server, "a.sloppak") # no pack art _match_row(server, "a.sloppak", release_id="rel-1") body = _get(client) assert body["pending"] is False cur = _current(body) assert cur["provenance"] == "none" # nothing served yet assert not any(c["kind"] == "pack" for c in body["candidates"]) caa = _caa(body) assert [c["thumb_url"] for c in caa] == [ "https://caa.example/101-500.jpg", # front, 500px "https://caa.example/102-250.jpg", # 250 fallback ] assert caa[0]["provenance"] == "matched" assert caa[0]["approved"] is True and caa[1]["approved"] is False assert caa[0]["release_id"] == "rel-1" assert caa_index.calls == ["rel-1"] # one index fetch def test_review_row_includes_candidate_releases(server, client, caa_index): make_sloppak(server, "a.sloppak") _review_row(server, "a.sloppak", [ {"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"}, {"recording_id": "rec-2", "title": "Song", "release_id": "rel-2"}, {"recording_id": "rec-3", "title": "Song", "release_id": "rel-1"}, # dupe {"recording_id": "rec-4", "title": "Song"}, # no release — skipped ]) body = _get(client) assert caa_index.calls == ["rel-1", "rel-2"] # deduped, in order assert {c["release_id"] for c in _caa(body)} == {"rel-1", "rel-2"} assert len(_caa(body)) == 3 def test_rejected_row_skips_caa_fetch(server, client, caa_index): """A row the user rejected (failed/rejected) has no accepted match, so the picker must not spend the shared CAA budget on its stale candidates. The Current tile still serves; the index seam is never asked.""" make_sloppak(server, "a.sloppak") _review_row(server, "a.sloppak", [ {"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"}]) assert server.meta_db.set_enrichment_rejected("a.sloppak") body = _get(client) assert _caa(body) == [] assert caa_index.calls == [] assert _current(body)["kind"] == "current" def test_unmatched_instant_tiles_only(server, client, caa_index): """No enrichment row at all → current (+ pack when it exists), empty caa list, and the index seam is never asked.""" make_sloppak(server, "a.sloppak", with_cover=True) body = _get(client) kinds = [c["kind"] for c in body["candidates"]] assert kinds == ["current", "pack"] assert _current(body)["provenance"] == "pack" pack = body["candidates"][1] assert pack["thumb_url"].endswith("?source=pack") assert caa_index.calls == [] def test_override_provenance_is_yours(server, client, caa_index): make_sloppak(server, "a.sloppak", with_cover=True) assert client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"] body = _get(client) assert _current(body)["provenance"] == "yours" # Pack original stays offered even while the override is what serves. assert any(c["kind"] == "pack" for c in body["candidates"]) def test_offline_empty_caa_list_no_error(server, client): """Under the plain test env the REAL index seam refuses (offline guard); the endpoint still answers 200 with the instant tiles and caches nothing (a later open retries).""" make_sloppak(server, "a.sloppak") _match_row(server, "a.sloppak", release_id="rel-1") body = _get(client) assert _caa(body) == [] assert _current(body)["kind"] == "current" assert list(server.ART_CACHE_DIR.glob("caa_index_*.json")) == [] def test_index_cached_second_call_no_refetch(server, client, caa_index): make_sloppak(server, "a.sloppak") _match_row(server, "a.sloppak", release_id="rel-1") first = _get(client) assert len(caa_index.calls) == 1 cache = server.ART_CACHE_DIR / "caa_index_rel-1.json" assert cache.is_file() # TTL-less on-disk cache # Even a changed upstream index is not re-asked — indexes are stable. caa_index.indexes["rel-1"] = {"images": []} second = _get(client) assert len(caa_index.calls) == 1 # no refetch assert _caa(second) == _caa(first) def test_404_release_cached_as_empty(server, client, caa_index): """A coverless release (CAA 404 → seam returns None) yields no tiles and is never re-asked either.""" make_sloppak(server, "a.sloppak") _match_row(server, "a.sloppak", release_id="rel-missing") assert _caa(_get(client)) == [] assert _caa(_get(client)) == [] assert caa_index.calls == ["rel-missing"] def test_caa_candidates_capped_at_12(server, client, caa_index): make_sloppak(server, "a.sloppak") caa_index.indexes["rel-big"] = { "images": [_img(300 + i, front=(i == 0)) for i in range(20)]} _match_row(server, "a.sloppak", release_id="rel-big") assert len(_caa(_get(client))) == art._ART_PICKER_MAX_CAA == 12 def test_demo_mode_blocks_candidates(server, client, monkeypatch): """Read-only, but it spends the shared CAA rate budget — blocked in demo like enrichment search/kick.""" make_sloppak(server, "a.sloppak") monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1") r = client.get("/api/song/a.sloppak/art/candidates") assert r.status_code == 403 assert r.json() == {"error": "demo mode: read-only"} def test_unknown_song_404(server, client): assert client.get("/api/song/ghost.sloppak/art/candidates").status_code == 404 # ── traversal / injection hardening ─────────────────────────────────────────── def test_malicious_release_id_rejected_no_fetch_no_write(server, caa_index): """A crafted release id (path traversal) never matches _CAA_ID_RE, so it yields no images, opens no socket, and writes no cache file — inside the art dir or anywhere else.""" art_dir = enrichment._enrichment_art_dir() before = set(art_dir.glob("*")) assert not enrichment._CAA_ID_RE.match("../../etc/x") assert enrichment._caa_index_cached("../../etc/x") == [] assert caa_index.calls == [] # the seam was never asked assert set(art_dir.glob("*")) == before # nothing written # And nothing landed at the traversal target beside the cache dir either. assert not (art_dir.parent / "etc").exists() def test_candidates_route_rejects_traversal_filename(server, client, caa_index): """A traversal filename resolves outside DLC_DIR → _resolve_dlc_path refuses it, the route 404s, and the CAA seam is never touched.""" for path in ("..%2F..%2Fsecret", "%2e%2e%2f%2e%2e%2fsecret", "../../secret"): r = client.get(f"/api/song/{path}/art/candidates") assert r.status_code == 404, path assert caa_index.calls == [] # ── the ?source=pack serve variant ──────────────────────────────────────────── def test_pack_source_serves_pack_under_override(server, client): """The Pack-original tile's thumb must show the pack's own art even while an override is what the plain route serves — and 404 when the song ships no art of its own.""" make_sloppak(server, "a.sloppak", with_cover=True) assert client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"] assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png" r = client.get("/api/song/a.sloppak/art?source=pack") assert r.status_code == 200 assert r.headers["content-type"] == "image/jpeg" # the pack cover, not the override make_sloppak(server, "bare.sloppak") assert client.get("/api/song/bare.sloppak/art?source=pack").status_code == 404 # ── art-by-URL redirect handling (what makes a CAA pick applyable) ──────────── class _FakeResp: def __init__(self, status, headers=None, chunks=()): self.status_code = status self.headers = headers or {} self._chunks = chunks def iter_content(self, _size): return iter(self._chunks) def __enter__(self): return self def __exit__(self, *a): return False def test_fetch_art_url_follows_redirects_validating_each_hop(server, monkeypatch): import requests fetched, checked = [], [] def fake_get(url, **kw): fetched.append(url) assert kw.get("allow_redirects") is False # hops stay manual if "coverartarchive.example" in url: return _FakeResp(307, {"Location": "https://archive.example/img.png"}) return _FakeResp(200, chunks=[b"IMGDATA"]) monkeypatch.setattr(requests, "get", fake_get) monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True) monkeypatch.setattr(art, "_url_host_is_internal", lambda u: (checked.append(u), False)[1]) data = art._fetch_art_url("https://coverartarchive.example/release/x/front-500") assert data == b"IMGDATA" assert fetched == ["https://coverartarchive.example/release/x/front-500", "https://archive.example/img.png"] assert checked == fetched # every hop was gated def test_fetch_art_url_blocks_redirect_to_internal(server, monkeypatch): import requests monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp( 302, {"Location": "http://internal.example/x.png"})) monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True) monkeypatch.setattr(art, "_url_host_is_internal", lambda u: "internal" in u) with pytest.raises(ValueError): art._fetch_art_url("https://public.example/x.png") def test_fetch_art_url_redirect_budget(server, monkeypatch): import requests monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp( 307, {"Location": "https://public.example/next.png"})) monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True) monkeypatch.setattr(art, "_url_host_is_internal", lambda u: False) with pytest.raises(enrichment.EnrichTransportError): art._fetch_art_url("https://public.example/x.png")