"""Magazine sources: parsing, host restriction, size/type checks. No network.""" import os import tempfile import unittest from pathlib import Path os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-test-config-")) from core import magazine_sources as ms class FakeResp: def __init__(self, status=200, body=b"", headers=None, json_data=None): self.status_code = status self._body = body self.headers = headers or {} self._json = json_data self.is_redirect = status in (301, 302, 303, 307, 308) def json(self): if self._json is None: raise ValueError("no json") return self._json def iter_content(self, n): for i in range(0, len(self._body), n): yield self._body[i:i + n] def close(self): pass class FakeSession: def __init__(self, routes): self.routes = routes self.calls = [] def get(self, url, **kw): self.calls.append(url) for prefix, resp in self.routes.items(): if url.startswith(prefix): return resp() if callable(resp) else resp return FakeResp(404) OPDS = b""" c't 12/2026HeiseIssue Elsewhere EPUB only """ class OpdsTests(unittest.TestCase): def provider(self, routes): return ms.OpdsProvider("opds-1", "NAS", "http://nas.local:8083/opds/", FakeSession(routes)) def test_search_keeps_only_same_host_pdf_entries(self): # Most specific route first: the fake session matches by prefix. p = self.provider({"http://nas.local:8083/opds/search": FakeResp(body=OPDS), "http://nas.local:8083/opds/": FakeResp(body=b'')}) items = p.search("ct") self.assertEqual([i.title for i in items], ["c't 12/2026"]) self.assertEqual(items[0].id, "http://nas.local:8083/dl/1.pdf") self.assertEqual(items[0].thumb_url, "http://nas.local:8083/thumb/1.jpg") def test_resolve_rejects_other_hosts(self): p = self.provider({}) with self.assertRaises(ms.SourceError): p.resolve_pdf("http://169.254.169.254/latest/meta-data") def test_doctype_and_entities_are_refused(self): evil = b']>' with self.assertRaises(ms.SourceError): ms.parse_xml_safely(evil) def test_malformed_feed_is_a_source_error(self): with self.assertRaises(ms.SourceError): ms.parse_xml_safely(b"login")}) with self.assertRaisesRegex(ms.SourceError, "PDF"): ms.download_pdf(self.p, "http://nas.local/dl/1.pdf", self.dir / "source.pdf", session=s) self.assertEqual(list(self.dir.iterdir()), []) def test_redirect_to_other_host_is_refused(self): s = FakeSession({"http://nas.local/dl": FakeResp(status=302, headers={"location": "http://evil.example/x"})}) with self.assertRaisesRegex(ms.SourceError, "different host"): ms.download_pdf(self.p, "http://nas.local/dl/1.pdf", self.dir / "source.pdf", session=s) def test_oversize_declared_is_refused(self): s = FakeSession({"http://nas.local/dl": FakeResp(body=b"%PDF-", headers={"content-length": str(ms.MAX_DOWNLOAD_BYTES + 1)})}) with self.assertRaisesRegex(ms.SourceError, "larger"): ms.download_pdf(self.p, "http://nas.local/dl/1.pdf", self.dir / "source.pdf", session=s) def test_http_error(self): s = FakeSession({"http://nas.local/dl": FakeResp(status=403)}) with self.assertRaisesRegex(ms.SourceError, "403"): ms.download_pdf(self.p, "http://nas.local/dl/1.pdf", self.dir / "source.pdf", session=s) class BuildProvidersTests(unittest.TestCase): def test_skips_bad_and_credentialed_catalogues(self): provs = ms.build_providers([ {"name": "NAS", "url": "http://nas.local/opds"}, {"name": "bad", "url": "ftp://x"}, {"name": "creds", "url": "http://user:pw@nas.local/opds"}, {"url": ""}]) self.assertEqual(sorted(provs), ["internet-archive", "opds-1"]) if __name__ == "__main__": unittest.main() class RouteTests(unittest.TestCase): """The HTTP layer over the providers, on a minimal app (no auth middleware, no network).""" def setUp(self): from fastapi import FastAPI from fastapi.testclient import TestClient from unittest import mock from routes import magazine_sources as routes self.routes = routes self.app = FastAPI() self.app.include_router(routes.router) self.client = TestClient(self.app) self.mock = mock def test_lists_archive_and_configured_catalogues(self): with self.mock.patch.object(self.routes, "_load_settings", return_value={"magazine_opds_catalogs": [{"name": "NAS", "url": "http://nas.local/opds"}]}): data = self.client.get("/api/magazine-sources").json() self.assertEqual([s["key"] for s in data["sources"]], ["internet-archive", "opds-1"]) def test_unknown_source_is_404(self): with self.mock.patch.object(self.routes, "_load_settings", return_value={}): self.assertEqual(self.client.get("/api/magazine-sources/nope/search?q=x").status_code, 404) self.assertEqual(self.client.get("/api/magazine-sources/..%2f/search?q=x").status_code, 404) def test_source_errors_become_502_with_a_message(self): class Boom: key = "internet-archive" label = "x" def search(self, q, page=1): raise ms.SourceError("The source is rate-limiting requests; try again in a minute") with self.mock.patch.object(self.routes, "_providers", return_value={"internet-archive": Boom()}): r = self.client.get("/api/magazine-sources/internet-archive/search?q=x") self.assertEqual(r.status_code, 502) self.assertIn("rate-limiting", r.json()["detail"]) def test_import_requires_an_item(self): with self.mock.patch.object(self.routes, "_load_settings", return_value={}): r = self.client.post("/api/magazine-sources/import", json={"source": "internet-archive"}) self.assertEqual(r.status_code, 400)