git/orchard/main
tests/test_fetch.py
311 lines9 KB last change raw · history
"""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())