Skip to content

Commit a0a5e8b

Browse files
committed
fix: reject malformed link audit reports
1 parent 33b2b37 commit a0a5e8b

2 files changed

Lines changed: 88 additions & 22 deletions

File tree

tests/test_check_links.py

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -332,23 +332,68 @@ def test_mode_selects_internal_external_or_all() -> None:
332332

333333

334334
def test_exit_code_fails_every_actionable_result() -> None:
335-
review_report = {
336-
"counts": {
337-
"working": 0,
338-
"redirect": 0,
339-
"review": 21,
340-
"broken": 0,
341-
"blocked": 0,
342-
"error": 0,
343-
"total": 21,
344-
}
345-
}
346-
assert exit_code_for_report(review_report) == 1
347-
assert exit_code_for_report({"counts": {"broken": 1, "blocked": 0}}) == 1
348-
assert exit_code_for_report({"counts": {"broken": 0, "blocked": 1}}) == 1
349-
assert exit_code_for_report({"counts": {"error": 1}}) == 1
350-
assert exit_code_for_report({"counts": {"working": 1, "total": 1}}) == 0
351-
assert exit_code_for_report({"counts": {"total": 0}}) == 1
335+
for status in ("review", "broken", "blocked", "error"):
336+
result = LinkResult("id", "path", "Title", "https://example.com", status)
337+
report = build_report(catalog=Path("catalog.yml"), mode="all", results=[result])
338+
assert exit_code_for_report(report) == 1
339+
340+
working = LinkResult(
341+
"id", "path", "Title", "https://example.com", "working"
342+
)
343+
report = build_report(catalog=Path("catalog.yml"), mode="all", results=[working])
344+
assert exit_code_for_report(report) == 0
345+
346+
347+
@pytest.mark.parametrize(
348+
"report",
349+
[
350+
{},
351+
{"counts": {}},
352+
{"counts": {"total": 0}},
353+
{
354+
"counts": {
355+
"working": 1,
356+
"redirect": 0,
357+
"review": 0,
358+
"broken": 0,
359+
"blocked": 0,
360+
"error": 0,
361+
}
362+
},
363+
{
364+
"counts": {
365+
"working": 1,
366+
"redirect": 0,
367+
"review": 0,
368+
"broken": 0,
369+
"blocked": 0,
370+
"error": 0,
371+
"unknown": 1,
372+
"total": 1,
373+
}
374+
},
375+
{
376+
"counts": {
377+
"working": 0,
378+
"redirect": 0,
379+
"review": 0,
380+
"broken": 0,
381+
"blocked": 0,
382+
"error": 0,
383+
"total": 1,
384+
}
385+
},
386+
],
387+
)
388+
def test_exit_code_rejects_malformed_reports(report: dict) -> None:
389+
assert exit_code_for_report(report) == 2
390+
391+
392+
def test_unknown_result_status_cannot_fail_open() -> None:
393+
result = LinkResult("id", "path", "Title", "https://example.com", "typo")
394+
report = build_report(catalog=Path("catalog.yml"), mode="all", results=[result])
395+
396+
assert exit_code_for_report(report) == 2
352397

353398

354399
def test_unexpected_checker_error_is_fatal() -> None:

tools/check_links.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
REVIEW_STATUS_CODES = {403, 408, 425, 429}
5050
RETRY_STATUS_CODES = {408, 425, 429, 500, 502, 503, 504}
5151
REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
52+
REPORT_STATUSES = ("working", "redirect", "review", "broken", "blocked", "error")
53+
ACTIONABLE_STATUSES = ("review", "broken", "blocked", "error")
5254
MAX_REDIRECTS = 5
5355
MAX_BACKOFF_SECONDS = 5.0
5456
KNOWN_METADATA_HOSTS = {
@@ -610,8 +612,10 @@ def select_links(
610612
def build_report(
611613
*, catalog: Path, mode: str, results: list[LinkResult]
612614
) -> dict[str, Any]:
613-
statuses = ("working", "redirect", "review", "broken", "blocked", "error")
614-
counts = {status: sum(item.status == status for item in results) for status in statuses}
615+
counts = {
616+
status: sum(item.status == status for item in results)
617+
for status in REPORT_STATUSES
618+
}
615619
counts["total"] = len(results)
616620
return {
617621
"schema_version": 1,
@@ -624,13 +628,30 @@ def build_report(
624628

625629

626630
def exit_code_for_report(report: Mapping[str, Any]) -> int:
631+
if not isinstance(report, Mapping):
632+
return 2
627633
counts = report.get("counts", {})
628634
if not isinstance(counts, Mapping):
629635
return 2
630-
actionable_statuses = ("review", "broken", "blocked", "error")
631-
if any(counts.get(status, 0) for status in actionable_statuses):
636+
expected_keys = {*REPORT_STATUSES, "total"}
637+
if set(counts) != expected_keys:
638+
return 2
639+
values = [counts[status] for status in REPORT_STATUSES]
640+
total = counts["total"]
641+
if (
642+
not isinstance(total, int)
643+
or isinstance(total, bool)
644+
or total <= 0
645+
or any(
646+
not isinstance(value, int) or isinstance(value, bool) or value < 0
647+
for value in values
648+
)
649+
or sum(values) != total
650+
):
651+
return 2
652+
if any(counts[status] for status in ACTIONABLE_STATUSES):
632653
return 1
633-
return 1 if counts.get("total") == 0 else 0
654+
return 0
634655

635656

636657
def _positive_float(value: str) -> float:

0 commit comments

Comments
 (0)