diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd61a7b..9bd8fc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,4 +52,9 @@ jobs: - name: Test # Network I/O is isolated in the *_utils modules and mocked there, so # the suite needs neither a live network nor any external binary. - run: pytest --tb=short -q + # + # --cov-fail-under lives here rather than in pyproject addopts: in + # addopts it also fires on targeted runs like + # `pytest tests/test_checker.py::TestX -v`, which measure the whole + # package while running one class and would always fail the gate. + run: pytest --tb=short -q --cov-fail-under=100 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c947e8..eb4aa96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ## [Unreleased] +### Added + +- Two `checker.py` tests covering the last uncovered paths: a malformed + `root-anchors.xml` body (distinct from the network failure already tested — + it reaches the parse `try`, not the fetch one), and an insecure delegation + leaving the target zone with no validated keys, where `check()` must finalise + and return `None` rather than validate the final RRset against `None`. + +### Changed + +- CI now enforces the 100% coverage target CLAUDE.md documents. Coverage was + reported but never gated, so the suite sat at 99% against a documented 100% + with nothing to catch it. The gate lives in the workflow's pytest step rather + than in `addopts`, because in `addopts` it also fires on targeted runs such as + `pytest tests/test_checker.py::TestX -v`, which measure the whole package + while running one class and would fail the gate every time. +- `README.md`: test count corrected to 285 (the badge and prose both still + claimed 274, stale by 11 before this change). + --- ## [0.1.6] — 2026-07-08 diff --git a/README.md b/README.md index 0a2f264..bf0fa9b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ $ chainvalidator check example.com ``` ![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue) -![Tests](https://img.shields.io/badge/tests-274%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-285%20passing-brightgreen) ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen) ![License](https://img.shields.io/badge/license-GPLv3-lightgrey) @@ -216,7 +216,7 @@ pytest tests/test_checker.py pytest tests/test_checker.py::TestValidateNsec3Nxdomain -v ``` -The test suite has **274 tests** and achieves **100% coverage** of all +The test suite has **285 tests** and achieves **100% coverage** of all testable code. The one `# pragma: no cover` annotation marks a defensive guard inside the `validate_nsec3_rrset` closure in `_validate_nsec3_nxdomain` — it is structurally unreachable because the closure is only ever called with diff --git a/tests/test_checker.py b/tests/test_checker.py index 5267bb5..039f971 100644 --- a/tests/test_checker.py +++ b/tests/test_checker.py @@ -312,6 +312,18 @@ def test_network_failure_records_error_and_returns_empty(self): assert ds_list == [] assert len(c.errors) == 1 + def test_malformed_xml_records_parse_error_and_returns_empty(self): + # The fetch succeeds but the body is not parseable XML, which is a + # distinct failure from the network error above: it reaches the parse + # try/except rather than the fetch one. + c = _make_checker() + mock_resp = MagicMock() + mock_resp.content = b"truncated" + with patch("requests.get", return_value=mock_resp): + ds_list = c._load_trust_anchor() + assert ds_list == [] + assert any("Failed to parse root-anchors.xml" in e for e in c.errors) + def test_expired_key_digest_skipped(self): c = _make_checker() mock_resp = MagicMock() @@ -1260,6 +1272,45 @@ def mock_check_final(zone, keys, **kwargs): patch.object(c, "_check_final_rrset", side_effect=mock_check_final), ) + def test_unsigned_target_zone_returns_none(self): + # An insecure delegation leaves the target zone with no validated keys. + # check() must finalise and report insecure rather than attempting the + # final RRset validation with a None key set. + c = _make_checker() + dnskey_rr = make_dnskey_rrset(".") + + def mock_build(fqdn): + c._zone_ns_map = { + ".": [("a.root", "1.1.1.1")], + "example.com.": [("ns1.example.com.", "2.2.2.2")], + } + return [".", "example.com."] + + def mock_check_root(ta, validated): + validated["."] = dnskey_rr + return True + + def mock_check_zone(**kwargs): + # Delegation proven unsigned: succeeds without recording keys for + # the child, exactly as _handle_insecure_delegation leaves it. + return True + + with ( + patch.object(c, "_build_zone_list", side_effect=mock_build), + patch.object(c, "_load_trust_anchor", return_value=[MagicMock()]), + patch.object(c, "_check_root", side_effect=mock_check_root), + patch.object(c, "_check_zone", side_effect=mock_check_zone), + patch.object(c, "_check_final_rrset") as final_rrset, + patch.object(c, "_finalise", wraps=c._finalise) as finalise, + ): + result = c.check() + + assert result is None + final_rrset.assert_not_called() + # Without this the test would also pass if check() returned None + # without finalising, leaving the report status unset. + finalise.assert_called_once() + def test_trust_anchor_failure_returns_false(self): c = _make_checker()