"""Config, security advisories, baselines and the drill-down payload. These four modules are the contract the Go TUI reads, so the tests are mostly about shape and honesty: does the payload say which baseline it used, does the advisory sort put fixable first, does a corrupt config fall back rather than explode. """ from __future__ import annotations import json import sys import tempfile from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from updater import changes, config, detail, security # noqa: E402 CLONE = Path.home() / ".cache/paru/clone" # -------------------------------------------------------------------------- # config # -------------------------------------------------------------------------- def test_config_round_trips_and_persists_ignores(): with tempfile.TemporaryDirectory() as td: path = Path(td) / "config.json" cfg = config.Config.load(path) assert cfg.ignored == [] and not cfg.is_ignored("linux") cfg.ignore("linux") cfg.ignore("mesa") cfg.ignore("linux") # idempotent cfg.save(path) again = config.Config.load(path) assert again.ignored == ["linux", "mesa"] assert again.is_ignored("linux") again.unignore("linux") assert not again.is_ignored("linux") def test_config_toggle_reports_the_new_state(): cfg = config.Config() assert cfg.toggle_ignored("vim") is True assert cfg.is_ignored("vim") assert cfg.toggle_ignored("vim") is False assert not cfg.is_ignored("vim") def test_config_survives_a_corrupt_file(): with tempfile.TemporaryDirectory() as td: path = Path(td) / "config.json" path.write_text("{ this is not json at all") cfg = config.Config.load(path) assert cfg.ignored == [] cfg.ignore("x") cfg.save(path) assert config.Config.load(path).ignored == ["x"] def test_config_ignores_unknown_fields_from_a_newer_version(): with tempfile.TemporaryDirectory() as td: path = Path(td) / "config.json" path.write_text(json.dumps({"ignored": ["a"], "something_new": 42})) cfg = config.Config.load(path) assert cfg.ignored == ["a"] def test_config_save_is_atomic(): with tempfile.TemporaryDirectory() as td: path = Path(td) / "config.json" cfg = config.Config() cfg.ignore("a") cfg.save(path) assert path.is_file() assert not list(Path(td).glob("*.tmp")), "a temp file was left behind" def test_advisory_acknowledgements_round_trip(): with tempfile.TemporaryDirectory() as td: path = Path(td) / "config.json" cfg = config.Config.load(path) assert cfg.acked == [] and not cfg.is_acked("AVG-1") cfg.ack("AVG-1") cfg.ack("AVG-2") cfg.ack("AVG-1") # idempotent cfg.save(path) again = config.Config.load(path) assert again.acked == ["AVG-1", "AVG-2"], again.acked assert again.is_acked("AVG-2") again.unack("AVG-2") assert not again.is_acked("AVG-2") def test_clearing_acks_reports_how_many_there_were(): cfg = config.Config() cfg.ack("AVG-1") cfg.ack("AVG-2") assert cfg.clear_acks() == 2 assert cfg.acked == [] assert cfg.clear_acks() == 0 def test_acks_and_ignores_do_not_clobber_each_other(): """Both live in one file and are written by different code paths.""" with tempfile.TemporaryDirectory() as td: path = Path(td) / "config.json" cfg = config.Config.load(path) cfg.ignore("linux") cfg.ack("AVG-1") cfg.save(path) again = config.Config.load(path) assert again.ignored == ["linux"], again.ignored assert again.acked == ["AVG-1"], again.acked # and a Go-style write that only knows about one of them must not drop # the other: both are read back from the same document again.unack("AVG-1") again.save(path) final = config.Config.load(path) assert final.ignored == ["linux"] assert final.acked == [] def test_a_config_without_acks_loads_cleanly(): """Forward compatibility: the field is new.""" with tempfile.TemporaryDirectory() as td: path = Path(td) / "config.json" path.write_text('{"ignored": ["a"], "color_accent": "13"}') cfg = config.Config.load(path) assert cfg.acked == [] assert cfg.ignored == ["a"] assert cfg.color_accent == "13" def test_config_colours_are_ansi_slots_not_hex(): """The terminal decides what a slot looks like; a hex value would not.""" cfg = config.Config() for name in ( "color_accent", "color_muted", "color_ok", "color_warn", "color_alert", "color_new", "color_ver", ): value = getattr(cfg, name) assert value.isdigit() and 0 <= int(value) <= 15, (name, value) # -------------------------------------------------------------------------- # advisories # -------------------------------------------------------------------------- RAW = [ { "name": "AVG-1", "packages": ["libtiff"], "severity": "High risk", "status": "Unknown", "type": "unknown", "fixed": None, "issues": ["CVE-2022-1", "CVE-2022-2"], }, { "name": "AVG-2", "packages": ["linux-lts", "linux"], "severity": "Medium risk", "status": "Vulnerable", "type": "dos", "fixed": "6.16.9-1", "issues": ["CVE-2022-3"], }, { "name": "AVG-3", "packages": ["linux"], "severity": "Critical risk", "status": "Fixed", "type": "rce", "fixed": "6.16.10-1", "issues": ["CVE-2022-4"], }, ] def report(raw=None) -> security.Vulnerabilities: return security.Vulnerabilities( available=True, advisories=[security.Advisory.from_json(r) for r in (raw or RAW)] ) def test_advisory_parses_the_arch_audit_json_shape(): a = security.Advisory.from_json(RAW[1]) assert a.id == "AVG-2" assert a.packages == ("linux-lts", "linux") assert a.has_fix and a.fixed == "6.16.9-1" assert a.url == "https://security.archlinux.org/AVG-2" assert a.issues == ("CVE-2022-3",) assert not security.Advisory.from_json(RAW[0]).has_fix def test_fixable_and_unfixable_are_derived_from_the_advisories(): v = report() assert v.fixable == ["linux", "linux-lts"] assert v.unfixable == ["libtiff"] assert v.total == 3 def test_advisories_sort_fixable_first_then_severity(): """An update can only resolve the fixable ones, so those belong at the top.""" order = [a.id for a in report().sorted_advisories()] # both fixable ones first, worst severity leading, unfixable last assert order == ["AVG-3", "AVG-2", "AVG-1"], order def test_severity_ranking_is_worst_first(): rank = lambda sev: ( security.Advisory.from_json( # noqa: E731 {"name": "x", "severity": sev} ).severity_rank ) ordered = [ rank(s) for s in ("Critical risk", "High risk", "Medium risk", "Low risk", "Unknown") ] assert ordered == sorted(ordered), ordered assert ordered[0] == 0 # anything unrecognised sorts last rather than silently ranking as safe assert rank("nonsense") == len(security.SEVERITY_ORDER) def test_by_package_groups_multi_package_advisories(): grouped = report().by_package assert len(grouped["linux"]) == 2 assert len(grouped["linux-lts"]) == 1 assert [a.id for a in report().for_package("linux")] == ["AVG-3", "AVG-2"] def test_an_empty_advisory_list_is_clean_not_broken(): v = security.Vulnerabilities(available=True) assert v.clean and v.total == 0 and v.advisories == [] def test_unavailable_security_is_reported_not_silently_clean(): v = security.Vulnerabilities(available=False, reason="arch-audit is not installed") assert not v.clean, "an unavailable scan must not look like a clean bill of health" assert v.reason def test_scan_never_raises_even_when_arch_audit_is_missing(monkeypatch=None): v = security.scan(timeout=5) assert isinstance(v, security.Vulnerabilities) if not v.available: assert v.reason # -------------------------------------------------------------------------- # baselines # -------------------------------------------------------------------------- def test_split_version_handles_epoch_and_pkgrel(): assert changes.split_version("1.2.3-4") == ("1.2.3", "4", "") assert changes.split_version("1:2.3.4-5") == ("2.3.4", "5", "1") assert changes.split_version("r893.96ab414-1") == ("r893.96ab414", "1", "") assert changes.split_version("1.0") == ("1.0", "", "") def test_recorded_baseline_wins_over_the_version_search(): if not (CLONE / "android-studio").is_dir(): return head = changes.head(CLONE / "android-studio") commit, how = changes.baseline(CLONE, "android-studio", "2026.1.4.8-2", head.sha) assert how == "recorded" and commit == head.sha def test_version_baseline_is_reconstructed_from_git_history(): """This is what makes a diff possible before the tool has ever been run.""" if not (CLONE / "aescrypt").is_dir(): return repo = CLONE / "aescrypt" older = changes.history(repo, "PKGBUILD", limit=6)[2] body = changes.show(repo, older.sha, "PKGBUILD") from updater.srcinfo import parse_pkgbuild version = parse_pkgbuild(body).one("pkgver") assert version commit, how = changes.baseline(CLONE, "aescrypt", f"{version}-1") assert how == "version" and commit # the contract is "the newest commit that declares this pkgver", not a # specific sha: several commits may share a pkgver and differ only in pkgrel from updater.srcinfo import parse_pkgbuild as parse declared = parse(changes.show(repo, commit, "PKGBUILD")).one("pkgver") assert declared == version, f"returned a commit declaring {declared!r}" def test_no_baseline_is_reported_as_such_rather_than_guessed(): commit, how = changes.baseline(Path("/definitely/not/here"), "nope", "1.0-1") assert commit == "" assert how == "missing", "no clone at all is distinct from a history miss" def test_a_history_miss_is_distinct_from_a_missing_clone(): """These read the same to the user only if the tool is being lazy. 'paru never downloaded this' and 'your installed version is not in the history' call for different things: one is worth fetching, the other is not. """ if not (CLONE / "aescrypt").is_dir(): return _, missing = changes.baseline(Path("/definitely/not/here"), "aescrypt", "1.0-1") _, miss = changes.baseline(CLONE, "aescrypt", "0.0.1-does-not-exist") assert missing == "missing" assert miss == "", f"expected a history miss, got {miss!r}" assert missing != miss def test_diff_against_returns_both_sides(): if not (CLONE / "aescrypt").is_dir(): return repo = CLONE / "aescrypt" older = changes.history(repo, "PKGBUILD", limit=6)[2] old, new = changes.diff_against(CLONE, "aescrypt", older.sha) assert old and new assert old != new # -------------------------------------------------------------------------- # the drill-down payload # -------------------------------------------------------------------------- def test_diff_lines_are_lines_not_characters(): """The first version sliced the original string and emitted one char per row.""" lines = detail.diff_lines("a\nb\nc\n", "a\nB\nc\n") texts = [l["text"] for l in lines if l["kind"] != "gap"] assert texts == ["a", "b", "c", "B"] or "b" in texts and "B" in texts assert all(len(l["text"]) <= 2 for l in lines), "a multi-char line leaked" assert any(l["kind"] == "del" and l["old"] == 2 for l in lines) assert any(l["kind"] == "add" and l["new"] == 2 for l in lines) def test_diff_lines_marks_gaps_between_hunks(): old = "\n".join(f"line{i}" for i in range(40)) new = old.replace("line0", "CHANGED").replace("line39", "ALSO") lines = detail.diff_lines(old, new) assert any(l["kind"] == "gap" for l in lines) assert sum(1 for l in lines if l["kind"] == "add") == 2 assert sum(1 for l in lines if l["kind"] == "del") == 2 def test_package_detail_shape_for_a_real_package(): if not (CLONE / "aescrypt").is_dir(): return payload = detail.package_detail(CLONE, "aescrypt", installed="4.5.0-1", available="4.6.0-1") for key in ( "package", "installed", "available", "baseline_how", "baseline_note", "diff", "findings", "verdict", "meta", "advisories", ): assert key in payload, key assert payload["baseline_how"] in ("recorded", "version", "") assert payload["baseline_note"], "an empty baseline must explain itself" def test_package_detail_without_a_baseline_offers_a_reference_listing(): if not (CLONE / "aescrypt").is_dir(): return payload = detail.package_detail(CLONE, "aescrypt", installed="0.0.1-1") assert payload["baseline_how"] == "" assert payload["diff"]["lines"] or payload.get("pkgbuild"), "nothing to show at all" if not payload["diff"]["lines"]: assert payload["pkgbuild"], "no diff and no reference listing" def test_package_detail_is_json_serialisable(): if not (CLONE / "aescrypt").is_dir(): return payload = detail.package_detail(CLONE, "aescrypt", installed="4.5.0-1") assert json.loads(json.dumps(payload)) == payload def test_advisory_detail_is_json_serialisable(): payload = detail.advisory_detail(security.Advisory.from_json(RAW[1])) assert json.loads(json.dumps(payload)) == payload assert payload["url"].endswith("AVG-2") assert payload["packages"] == ["linux-lts", "linux"] def test_summary_line_describes_the_change(): assert "finding" in detail.summary_line({"findings": [{"severity": "BLOCK"}], "diff": {}}) assert "-1" in detail.summary_line({"findings": [], "diff": {"added": 2, "removed": 1}}) assert detail.summary_line({"findings": [], "diff": {}}) == "" 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())