git/orchard/main
tests/test_detail.py
429 lines14 KB last change raw · history
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
"""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())