"""Network fetches. Nothing here touches the real internet: a local HTTP server stands in for the tracker and the AUR, so the actual urllib path is exercised rather than mocked away. The failure modes matter as much as the happy one, because "the network is down" is a normal state for this tool. """ from __future__ import annotations import http.server import json import os import sys import tempfile import threading import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from updater import fetch # noqa: E402 ADVISORY = { "name": "AVG-9999", "packages": ["libfoo", "libbar"], "status": "Vulnerable", "severity": "High risk", "type": "arbitrary code execution", "affected": "1.2.3-1", "fixed": "1.2.4-1", "ticket": "https://bugs.archlinux.org/task/1", "issues": ["CVE-2024-1", "CVE-2024-2"], "advisories": ["ASA-2024-1"], "references": ["https://example.invalid/report"], "notes": "reported upstream", } PKGBUILD = "# Maintainer: someone\npkgname=demo-bin\npkgver=1.0\npkgrel=1\n" class Handler(http.server.BaseHTTPRequestHandler): """Serves the two endpoints the module knows about, and 404 for the rest.""" def do_GET(self) -> None: # noqa: N802 if self.path == "/AVG-9999.json": body = json.dumps(ADVISORY).encode() elif self.path == "/AVG-BAD.json": body = b"{ this is not json" elif self.path.startswith("/cgit/aur.git/plain/PKGBUILD"): body = PKGBUILD.encode() else: self.send_response(404) self.end_headers() return self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args) -> None: pass # keep the suite output clean class Server: def __enter__(self): self.httpd = http.server.HTTPServer(("127.0.0.1", 0), Handler) self.port = self.httpd.server_address[1] self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) self.thread.start() self.base = f"http://127.0.0.1:{self.port}" return self def __exit__(self, *exc): self.httpd.shutdown() self.httpd.server_close() def isolated_cache() -> Path: """Point the cache somewhere disposable so tests never share state.""" directory = Path(tempfile.mkdtemp()) os.environ["XDG_CACHE_HOME"] = str(directory) return directory # -------------------------------------------------------------------------- # the happy path, over real HTTP # -------------------------------------------------------------------------- def test_an_advisory_comes_back_structured(): isolated_cache() with Server() as server: saved = fetch.TRACKER fetch.TRACKER = server.base try: got = fetch.advisory_detail("AVG-9999") finally: fetch.TRACKER = saved assert got["available"], got assert got["id"] == "AVG-9999" assert got["severity"] == "High risk" assert got["affected"] == "1.2.3-1" assert got["fixed"] == "1.2.4-1" assert got["issues"] == ["CVE-2024-1", "CVE-2024-2"] assert got["references"] == ["https://example.invalid/report"] assert got["notes"] == "reported upstream" assert got["url"].endswith("AVG-9999") def test_everything_the_modal_needs_is_present(): """If a field is missing here the overlay would show a blank row.""" isolated_cache() with Server() as server: saved = fetch.TRACKER fetch.TRACKER = server.base try: got = fetch.advisory_detail("AVG-9999") finally: fetch.TRACKER = saved for key in ( "id", "available", "reason", "packages", "status", "severity", "kind", "affected", "fixed", "ticket", "issues", "references", "notes", "url", ): assert key in got, f"missing {key}" def test_a_pkgbuild_comes_back_for_a_package_with_no_clone(): isolated_cache() with Server() as server: saved = fetch.AUR fetch.AUR = server.base try: body, error = fetch.pkgbuild("demo-bin") finally: fetch.AUR = saved assert error == "" assert "pkgver=1.0" in body # -------------------------------------------------------------------------- # failure modes # -------------------------------------------------------------------------- def test_a_404_is_reported_not_raised(): isolated_cache() with Server() as server: saved = fetch.TRACKER fetch.TRACKER = server.base try: got = fetch.advisory_detail("AVG-NOT-THERE") finally: fetch.TRACKER = saved assert got["available"] is False assert "404" in got["reason"] def test_an_unreachable_host_is_reported_not_raised(): isolated_cache() saved = fetch.TRACKER # port 1 on loopback refuses immediately fetch.TRACKER = "http://127.0.0.1:1" try: got = fetch.advisory_detail("AVG-9999") finally: fetch.TRACKER = saved assert got["available"] is False assert got["reason"], "an unreachable tracker must explain itself" def test_unparseable_json_is_reported_not_raised(): isolated_cache() with Server() as server: saved = fetch.TRACKER fetch.TRACKER = server.base try: got = fetch.advisory_detail("AVG-BAD") finally: fetch.TRACKER = saved assert got["available"] is False assert "unreadable" in got["reason"] def test_a_path_traversal_id_is_rejected_without_a_request(): got = fetch.advisory_detail("../../etc/passwd") assert got["available"] is False assert "usable" in got["reason"] def test_an_empty_id_is_rejected(): assert fetch.advisory_detail("")["available"] is False # -------------------------------------------------------------------------- # caching # -------------------------------------------------------------------------- def test_a_second_fetch_is_served_from_the_cache(): isolated_cache() calls = {"n": 0} real = fetch.get def counting(*args, **kwargs): calls["n"] += 1 return real(*args, **kwargs) with Server() as server: saved = fetch.TRACKER fetch.TRACKER = server.base fetch.get = counting try: fetch.advisory_detail("AVG-9999") fetch.advisory_detail("AVG-9999") finally: fetch.get = real fetch.TRACKER = saved assert calls["n"] == 2, f"expected two calls (both cached paths), got {calls['n']}" # the cache file must exist so the second was a hit rather than a re-request assert (fetch.cache_dir() / "advisories" / "AVG-9999.json").is_file() def test_a_stale_cache_entry_is_ignored(): isolated_cache() path = fetch.cache_dir() / "advisories" / "AVG-9999.json" path.parent.mkdir(parents=True, exist_ok=True) path.write_text("{}") old = time.time() - (fetch.ADVISORY_TTL + 60) os.utime(path, (old, old)) assert fetch._cached(path, fetch.ADVISORY_TTL) is None, "a stale entry was used" os.utime(path, None) assert fetch._cached(path, fetch.ADVISORY_TTL) == "{}" def test_a_missing_cache_file_is_not_an_error(): isolated_cache() assert fetch._cached(fetch.cache_dir() / "nope.json", 60) is None def test_an_unwritable_cache_does_not_fail_the_fetch(): """A cache is an optimisation; failing to write it must not lose the data.""" isolated_cache() saved = fetch.cache_dir fetch.cache_dir = lambda: Path("/proc/definitely-not-writable") try: with Server() as server: saved_tracker = fetch.TRACKER fetch.TRACKER = server.base try: got = fetch.advisory_detail("AVG-9999") finally: fetch.TRACKER = saved_tracker finally: fetch.cache_dir = saved assert got["available"], "an unwritable cache broke the fetch" def test_a_stale_copy_is_preferred_to_nothing_when_the_network_is_down(): isolated_cache() path = fetch.cache_dir() / "advisories" / "AVG-9999.json" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(ADVISORY)) old = time.time() - (fetch.ADVISORY_TTL + 60) os.utime(path, (old, old)) saved = fetch.TRACKER fetch.TRACKER = "http://127.0.0.1:1" try: got = fetch.advisory_detail("AVG-9999") finally: fetch.TRACKER = saved assert got["available"], "a stale copy was thrown away for nothing" assert got["id"] == "AVG-9999" def main() -> int: tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)] failed = 0 for name, fn in tests: try: fn() except Exception as exc: # noqa: BLE001 failed += 1 print(f"FAIL {name}\n {type(exc).__name__}: {exc}") else: print(f"ok {name}") print(f"\n{len(tests) - failed}/{len(tests)} passed") return 1 if failed else 0 if __name__ == "__main__": raise SystemExit(main())