]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Generate comprehensive tests for ZoneAnalyzer utility class
authorPetr Špaček <pspacek@isc.org>
Fri, 23 May 2025 07:07:02 +0000 (09:07 +0200)
committerPetr Špaček <pspacek@isc.org>
Tue, 29 Jul 2025 08:00:45 +0000 (10:00 +0200)
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.

bin/tests/system/selftest/analyzer.db [deleted file]
bin/tests/system/selftest/tests_zone_analyzer.py

diff --git a/bin/tests/system/selftest/analyzer.db b/bin/tests/system/selftest/analyzer.db
deleted file mode 100644 (file)
index 298de3a..0000000
+++ /dev/null
@@ -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"
index 1808cd4beb582ea703a7dfa471d2c9d121e52029..9115d3ca64bc9a6e64ec6f5b9dc5a15c54cd6f3b 100755 (executable)
@@ -1,5 +1,4 @@
 #!/usr/bin/python3
-
 # Copyright (C) Internet Systems Consortium, Inc. ("ISC")
 #
 # SPDX-License-Identifier: MPL-2.0
 #
 # 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
-from pathlib import Path
 
-import pytest
+import collections
+import itertools
+from pathlib import Path
 
-pytest.importorskip("dns", minversion="2.5.0")
 import dns.name
+from dns.name import Name
+import pytest
 
 import isctest
-
-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
+import isctest.name
+
+# 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)
-
-
-def test_analyzer_delegations():
-    assert ZONE.delegations == text_to_names(
-        [
-            "nsunder.dname",
-            "nsunder.occluded.dname",
-            "insecure",
-            "secondns.insecure",
-        ]
-    )
-
+pytestmark = pytest.mark.extra_artifacts(["analyzer.db"])
+SUFFIX = dns.name.from_text("nsec3.example.")
 
-def test_analyzer_dnames():
-    assert ZONE.dnames == text_to_names(
-        [
-            "dname",
-            "dname.insecure",
-        ]
+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 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())),
     )
 
+    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 = {}
 
-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",
-        ]
-    )
+    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))
 
-def test_analyzer_reachable():
-    assert ZONE.reachable == text_to_names(
-        [
-            "@",
-            "02HC3EM7BDD011A0GMS3HKKJT2IF5VP8",
-            "a",
-            "a.a.a.a",
-            "b",
-            "d",
-            "dname",
-            "ns",
-            "*.wild",
-            "underwild.wild",
-            "z",
-        ]
-    )
+    # 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))
 
-def test_analyzer_reachable_delegations():
-    assert ZONE.reachable_delegations == text_to_names(
-        [
-            "insecure",
-        ]
-    )
+    return gen_expected_output(nodes)
 
 
-def test_analyzer_reachable_dnames():
-    assert ZONE.reachable_dnames == text_to_names(
-        [
-            "dname",
-        ]
-    )
+if __name__ == "__main__":
+    generate_test_data()
 
 
-def test_analyzer_reachable_wildcards():
-    assert ZONE.reachable_wildcards == text_to_names(
-        [
-            "*.wild",
-        ]
-    )
+@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_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]