"""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())