Generate comprehensive tests for ZoneAnalyzer utility class

Test all combinations of wildcard, ENT, DNAME, NS, and ordinary
TXT records.

Test zone and expected outputs are generated by another script which
encodes node content into node name. This encoding removes 'node
content' level of indirection and thus enables simpler implementation of
same logic which needs to be in ZoneAnalyzer itself.

For humans the generated zone file also lists expected 'categories' a
name belongs to as dot-separated list on right hand side of a generated
RR.
This commit is contained in:
Petr Špaček 2025-05-23 09:07:02 +02:00
parent cad48e56ab
commit 42b60a3819
2 changed files with 178 additions and 147 deletions

View file

@ -1,46 +0,0 @@
; Copyright (C) Internet Systems Consortium, Inc. ("ISC")
;
; SPDX-License-Identifier: MPL-2.0
;
; This Source Code Form is subject to the terms of the Mozilla Public
; License, v. 2.0. If a copy of the MPL was not distributed with this
; file, you can obtain one at https://mozilla.org/MPL/2.0/.
;
; See the COPYRIGHT file distributed with this work for additional
; information regarding copyright ownership.
$TTL 300 ; 5 minutes
@ IN SOA mname1. . (
2000042407 ; serial
20 ; refresh (20 seconds)
20 ; retry (20 seconds)
1814400 ; expire (3 weeks)
3600 ; minimum (1 hour)
)
NS ns
ns A 10.53.0.3
a A 10.0.0.1
b A 10.0.0.2
d A 10.0.0.4
z A 10.0.0.26
a.a.a.a A 10.0.0.3
*.wild A 10.0.0.6
underwild.wild TXT "not occluded"
insecure NS ns.insecure
secondns.insecure NS ns.insecure
insecure NS ns.insecure2
belowcut.insecure TXT "occluded"
belowcut2.insecure TXT "occluded" ; two RRs, just in case
dname.insecure DNAME occluded.
ns.insecure A 10.53.0.3
02HC3EM7BDD011A0GMS3HKKJT2IF5VP8 A 10.0.0.17
dname DNAME elsewhere.
*.dname TXT "occluded"
occluded.dname TXT "occluded"
occluded2.dname TXT "occluded"
nsunder.occluded.dname NS ns
nsunder.dname NS ns
*.nsunder.dname TXT "occluded"
test. TXT "out of zone"

View file

@ -1,5 +1,4 @@
#!/usr/bin/python3
# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
#
# SPDX-License-Identifier: MPL-2.0
@ -10,125 +9,203 @@
#
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.
"""
isctest.name.ZoneAnalyzer self-test
Generate insane test zone and check expected output of ZoneAnalyzer utility class
"""
import os
import collections
import itertools
from pathlib import Path
import dns.name
from dns.name import Name
import pytest
pytest.importorskip("dns", minversion="2.5.0")
import dns.name
import isctest
import isctest.name
from typing import List
SUFFIX = dns.name.from_text("nsec3.example.")
ZONE = isctest.name.ZoneAnalyzer.read_path(
Path(os.environ["builddir"]) / "selftest/analyzer.db", origin=SUFFIX
# set of properies present in the tested zone - read by tests_zone_analyzer.py
CATEGORIES = frozenset(
[
"delegations",
"dnames",
"ents",
"occluded",
"reachable",
"reachable_delegations",
"reachable_dnames",
"reachable_wildcards",
"wildcards",
]
)
def text_to_names(texts: List[str]):
return frozenset(dns.name.from_text(text, origin=SUFFIX) for text in texts)
pytestmark = pytest.mark.extra_artifacts(["analyzer.db"])
SUFFIX = dns.name.from_text("nsec3.example.")
LABELS = (b"*", b"dname", b"ent", b"ns", b"txt")
LABEL2RRTYPE = { # leftmost label encodes RR type we will synthesize for given name
b"*": "TXT",
b"dname": "DNAME",
b"ent": None, # ENT is not really a 'type'
b"ns": "NS",
b"txt": "TXT",
}
LABEL2TAGS = { # leftmost label encodes 'initial' meaning of a complete name
b"*": {"wildcards"},
b"dname": {"dnames"},
b"ns": {"delegations"},
b"txt": set(), # perhaps reachable, perhaps not, we need to decide based on other labels
}
def test_analyzer_delegations():
assert ZONE.delegations == text_to_names(
[
"nsunder.dname",
"nsunder.occluded.dname",
"insecure",
"secondns.insecure",
]
def name2tags(name):
"""
Decode meaning hidden in labels and their relationships
and return all tags expected from ZoneAnalyzer
"""
tags = LABEL2TAGS[name[0]].copy()
parent_labels = name[1:]
if b"ns" in parent_labels or b"dname" in parent_labels:
tags.add("occluded")
if "occluded" not in tags:
if "delegations" in tags:
# delegations are ambiguous and don't count as 'reachable'
tags.add("reachable_delegations")
elif "dnames" in tags:
tags.add("reachable")
tags.add("reachable_dnames")
elif "wildcards" in tags:
tags.add("reachable")
tags.add("reachable_wildcards")
else:
tags.add("reachable")
return tags
def gen_node(nodes, labels):
name = Name(labels)
nodes[name] = name2tags(name)
def add_ents(nodes):
"""
Non-occluded nodes with 'ent' as a parent label imply existence of 'ent' nodes.
"""
new_ents = {}
for name, tags in nodes.items():
if "occluded" in tags:
continue
# check if any parent is ENT
entidx = 1
while True:
try:
entidx = name.labels.index(b"ent", entidx)
except ValueError:
break
entname = Name(name[entidx:])
new_ents[entname] = {"ents"}
entidx += 1
return new_ents
def is_non_ent(labels):
"""
Filter out nodes with 'ent' at leftmost position. To become ENT a name must
not have data by itself but have some other node defined underneath it,
and must not be occluded, which is something itertools.product() cannot
decide.
"""
return labels[0] != b"ent"
def gen_zone(nodes):
"""
Generate zone file in text format.
All names are relative.
Right-hand side of RRs contains dot-separated list of categories a node
belongs to (except for zone origin).
"""
for name, tags in sorted(nodes.items()):
if len(name) == 0:
# origin, very special case
yield "@\tSOA\treachable. origin-special-case. 0 0 0 0 0\n"
yield "@\tNS\treachable.\n"
yield "@\tA\t192.0.2.1\n"
continue
rrtype = LABEL2RRTYPE[name[0]]
if rrtype is None: # ENT
prefix = "; "
else:
prefix = ""
assert tags
yield f"{prefix}{name}\t{rrtype}\t{'.'.join(sorted(tags))}.\n"
def gen_expected_output(nodes):
"""
{category: set(names)} mapping used by the pytest check
"""
categories = collections.defaultdict(set)
for name, tags in nodes.items():
for tag in tags:
categories[tag].add(name)
assert set(categories.keys()) == CATEGORIES, (
"CATEGORIES needs updating",
CATEGORIES.symmetric_difference(set(categories.keys())),
)
def test_analyzer_dnames():
assert ZONE.dnames == text_to_names(
[
"dname",
"dname.insecure",
]
)
return categories
def test_analyzer_ents():
assert ZONE.ents == text_to_names(
[
"a.a",
"a.a.a",
"wild",
]
)
def generate_test_data():
"""
Prepare the analyzer.db zone file in the current working directory and
return the expected attribute values for the ZoneAnalyzer instance that
will be tested using that file.
"""
nodes = {}
for length in range(1, len(LABELS) + 1):
for labelseq in filter(is_non_ent, itertools.product(LABELS, repeat=length)):
gen_node(nodes, labelseq)
nodes.update(add_ents(nodes))
# special-case to make this look as a valid DNS zone - it needs zone origin node
nodes[Name([])] = {"reachable"}
with open("analyzer.db", "w", encoding="ascii") as outf:
outf.writelines(gen_zone(nodes))
return gen_expected_output(nodes)
def test_analyzer_occluded():
assert ZONE.occluded == text_to_names(
[
"*.dname",
"nsunder.dname",
"*.nsunder.dname",
"occluded.dname",
"nsunder.occluded.dname",
"occluded2.dname",
"belowcut.insecure",
"belowcut2.insecure",
"dname.insecure",
"ns.insecure",
"secondns.insecure",
]
)
if __name__ == "__main__":
generate_test_data()
def test_analyzer_reachable():
assert ZONE.reachable == text_to_names(
[
"@",
"02HC3EM7BDD011A0GMS3HKKJT2IF5VP8",
"a",
"a.a.a.a",
"b",
"d",
"dname",
"ns",
"*.wild",
"underwild.wild",
"z",
]
)
@pytest.fixture(scope="module")
def analyzer_fixture():
expected_results = generate_test_data() # creates the "analyzer.db" file
analyzer = isctest.name.ZoneAnalyzer.read_path(Path("analyzer.db"), origin=SUFFIX)
return expected_results, analyzer
def test_analyzer_reachable_delegations():
assert ZONE.reachable_delegations == text_to_names(
[
"insecure",
]
)
def test_analyzer_reachable_dnames():
assert ZONE.reachable_dnames == text_to_names(
[
"dname",
]
)
def test_analyzer_reachable_wildcards():
assert ZONE.reachable_wildcards == text_to_names(
[
"*.wild",
]
)
def test_analyzer_wildcards():
assert ZONE.wildcards == text_to_names(
[
"*.dname",
"*.nsunder.dname",
"*.wild",
]
)
# pylint: disable=redefined-outer-name
@pytest.mark.parametrize("category", sorted(CATEGORIES))
def test_analyzer_attrs(category, analyzer_fixture):
expected_results, analyzer = analyzer_fixture
# relativize results to zone name to make debugging easier
results = {name.relativize(SUFFIX) for name in getattr(analyzer, category)}
assert results == expected_results[category]