Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions tests/test_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<TrustAnchor><KeyDigest>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()
Expand Down Expand Up @@ -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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 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()

Expand Down