|
| 1 | +"""Cross-source reconciliation of the built tlds.json. |
| 2 | +
|
| 3 | +These tests assert that the shipped tlds.json is consistent with the |
| 4 | +authoritative IANA source for each field. Per the .merck drift incident |
| 5 | +(commit 9a2c1b9, March 2026), we follow per-field source-of-truth: |
| 6 | +
|
| 7 | +- Root DB (iana-root.html) is authoritative for the TLD set, delegation |
| 8 | + status, manager, and type. |
| 9 | +- IANA RDAP bootstrap is authoritative for gTLD RDAP server URLs. |
| 10 | +- iana-tlds.txt is advisory only; it drifts from root DB in normal |
| 11 | + operation. test_source_drift.py surfaces the drift as warnings; the |
| 12 | + build must not depend on it. |
| 13 | +
|
| 14 | +These tests prefer the committed data/generated/tlds.json artifact when |
| 15 | +present so CI catches corruption in the file that would actually ship, |
| 16 | +not just in live build logic. On a fresh checkout the fixture builds |
| 17 | +into a tmp dir so the safeguard still runs. |
| 18 | +""" |
| 19 | + |
| 20 | +import ast |
| 21 | +import inspect |
| 22 | +import json |
| 23 | +from pathlib import Path |
| 24 | + |
| 25 | +import pytest |
| 26 | +from _pytest.monkeypatch import MonkeyPatch |
| 27 | + |
| 28 | +import src.build.tlds as build_module |
| 29 | +from src.build.tlds import OutputPaths, build_tlds_json |
| 30 | +from src.config import TLDS_OUTPUT_FILE |
| 31 | +from src.parse.rdap_json import parse_rdap_json |
| 32 | +from src.parse.root_db_html import parse_root_db_html |
| 33 | + |
| 34 | + |
| 35 | +@pytest.fixture(scope="module") |
| 36 | +def tlds_data(tmp_path_factory): |
| 37 | + """Return parsed tlds.json: prefer committed artifact, else build fresh. |
| 38 | +
|
| 39 | + Normal operation: the committed data/generated/tlds.json always exists, |
| 40 | + so the build-fresh branch is exercised only on fresh checkouts that |
| 41 | + haven't been built yet. It is intentionally kept rather than replaced |
| 42 | + with pytest.skip so the safeguard runs even in that case. |
| 43 | + """ |
| 44 | + committed = Path(TLDS_OUTPUT_FILE) |
| 45 | + if committed.exists(): |
| 46 | + with open(committed) as f: |
| 47 | + return json.load(f) |
| 48 | + |
| 49 | + tmp = tmp_path_factory.mktemp("reconciliation_build") |
| 50 | + mp = MonkeyPatch() |
| 51 | + mp.setattr("src.utilities.metadata.METADATA_FILE", str(tmp / "metadata.json")) |
| 52 | + paths = OutputPaths( |
| 53 | + tlds_json=tmp / "tlds.json", |
| 54 | + tlds_index=tmp / "tlds-index.json", |
| 55 | + tld_dir=tmp / "tld", |
| 56 | + ) |
| 57 | + try: |
| 58 | + build_tlds_json(paths) |
| 59 | + with open(paths.tlds_json) as f: |
| 60 | + return json.load(f) |
| 61 | + finally: |
| 62 | + mp.undo() |
| 63 | + |
| 64 | + |
| 65 | +def test_built_tlds_set_equals_root_db_set(tlds_data): |
| 66 | + """Every TLD in root DB is in tlds.json and nothing extra. |
| 67 | +
|
| 68 | + Stronger than count equality: catches the "drop entry X, duplicate |
| 69 | + entry Y, count unchanged" regression that count-based tests miss. |
| 70 | +
|
| 71 | + Asserts against root DB only, not against tlds.txt or the union of |
| 72 | + sources, because per-field source-of-truth means tlds.txt drift is |
| 73 | + not a build error (see .merck incident, commit 9a2c1b9). |
| 74 | + """ |
| 75 | + expected = {entry["domain"].lstrip(".").lower() for entry in parse_root_db_html()} |
| 76 | + actual = {entry["tld"].lower() for entry in tlds_data["tlds"]} |
| 77 | + |
| 78 | + missing = expected - actual |
| 79 | + extra = actual - expected |
| 80 | + assert not missing and not extra, ( |
| 81 | + f"TLD set mismatch vs root DB. " |
| 82 | + f"Missing from tlds.json: {sorted(missing)}. " |
| 83 | + f"Extra in tlds.json: {sorted(extra)}." |
| 84 | + ) |
| 85 | + |
| 86 | + |
| 87 | +def test_built_rdap_entries_present_and_iana_sourced(tlds_data): |
| 88 | + """Every TLD in the IANA RDAP bootstrap appears in tlds.json with an IANA-sourced rdap_server. |
| 89 | +
|
| 90 | + Does NOT exact-match URL strings because the build's source precedence |
| 91 | + is page_data > bootstrap > supplemental, and the two IANA sources can |
| 92 | + disagree on URL normalization (e.g., trailing slash). The invariants |
| 93 | + that actually matter: the TLD entry exists, rdap_server is populated, |
| 94 | + and annotations.rdap_source is "IANA" (not "supplemental"). |
| 95 | + """ |
| 96 | + rdap_lookup = parse_rdap_json() |
| 97 | + by_tld = {entry["tld"].lower(): entry for entry in tlds_data["tlds"]} |
| 98 | + |
| 99 | + missing_entries = [] |
| 100 | + missing_rdap_server = [] |
| 101 | + wrong_source = [] |
| 102 | + for tld in rdap_lookup: |
| 103 | + entry = by_tld.get(tld.lower()) |
| 104 | + if entry is None: |
| 105 | + missing_entries.append(tld) |
| 106 | + continue |
| 107 | + if not entry.get("rdap_server"): |
| 108 | + missing_rdap_server.append(tld) |
| 109 | + rdap_source = entry.get("annotations", {}).get("rdap_source") |
| 110 | + if rdap_source != "IANA": |
| 111 | + wrong_source.append((tld, rdap_source)) |
| 112 | + |
| 113 | + assert not missing_entries and not missing_rdap_server and not wrong_source, ( |
| 114 | + f"RDAP reconciliation failed. " |
| 115 | + f"Missing entries: {sorted(missing_entries)}. " |
| 116 | + f"Missing rdap_server: {sorted(missing_rdap_server)}. " |
| 117 | + f"Wrong rdap_source (first 5): {wrong_source[:5]}." |
| 118 | + ) |
| 119 | + |
| 120 | + |
| 121 | +def test_built_delegation_flag_matches_root_db(tlds_data): |
| 122 | + """For every TLD, delegated flag matches manager-not-not-assigned in root DB.""" |
| 123 | + root_db_by_tld = { |
| 124 | + entry["domain"].lstrip(".").lower(): (entry["manager"] != "Not assigned") |
| 125 | + for entry in parse_root_db_html() |
| 126 | + } |
| 127 | + |
| 128 | + mismatches = [] |
| 129 | + for entry in tlds_data["tlds"]: |
| 130 | + tld = entry["tld"].lower() |
| 131 | + if tld not in root_db_by_tld: |
| 132 | + continue |
| 133 | + expected_delegated = root_db_by_tld[tld] |
| 134 | + if entry["delegated"] != expected_delegated: |
| 135 | + mismatches.append((tld, entry["delegated"], expected_delegated)) |
| 136 | + |
| 137 | + assert not mismatches, ( |
| 138 | + f"delegated flag mismatch vs root DB for {len(mismatches)} TLD(s) " |
| 139 | + f"(first 5: {mismatches[:5]})" |
| 140 | + ) |
| 141 | + |
| 142 | + |
| 143 | +def test_built_tld_count_within_baseline_threshold(tlds_data): |
| 144 | + """Defend against systematic parser regression by comparing to a recorded baseline. |
| 145 | +
|
| 146 | + All the other reconciliation tests above compare parse_root_db_html() |
| 147 | + against the built tlds.json. Both sides flow through the same parser, |
| 148 | + so a parser regression that silently shrinks the set (e.g., a regex |
| 149 | + change that fails to match some root-zone rows) would not be caught. |
| 150 | + Both sides shrink together and the cross-source equality still holds. |
| 151 | +
|
| 152 | + This test guards against that by comparing the current TLD count |
| 153 | + against a baseline recorded inline below. The 5% threshold (~80 TLDs |
| 154 | + out of ~1594) is well above IANA's annual TLD growth rate. Update |
| 155 | + the baseline constants when a legitimate IANA expansion crosses the |
| 156 | + threshold (e.g., ICANN's next-round gTLD additions). |
| 157 | + """ |
| 158 | + baseline_as_of = "2026-05-16" |
| 159 | + baseline_total = 1594 |
| 160 | + baseline_delegated = 1437 |
| 161 | + threshold = 0.05 |
| 162 | + |
| 163 | + actual_total = len(tlds_data["tlds"]) |
| 164 | + actual_delegated = sum(1 for e in tlds_data["tlds"] if e["delegated"]) |
| 165 | + |
| 166 | + total_drift = abs(actual_total - baseline_total) / baseline_total |
| 167 | + delegated_drift = abs(actual_delegated - baseline_delegated) / baseline_delegated |
| 168 | + |
| 169 | + failures = [] |
| 170 | + if total_drift >= threshold: |
| 171 | + failures.append( |
| 172 | + f"total TLD count drifted {total_drift:.1%} " |
| 173 | + f"(baseline {baseline_total} as of {baseline_as_of}, now {actual_total})" |
| 174 | + ) |
| 175 | + if delegated_drift >= threshold: |
| 176 | + failures.append( |
| 177 | + f"delegated TLD count drifted {delegated_drift:.1%} " |
| 178 | + f"(baseline {baseline_delegated} as of {baseline_as_of}, now {actual_delegated})" |
| 179 | + ) |
| 180 | + |
| 181 | + assert not failures, ( |
| 182 | + f"TLD count outside baseline threshold ({threshold:.0%}): {failures}. " |
| 183 | + f"If this is legitimate IANA growth, update the baseline constants " |
| 184 | + f"in this test." |
| 185 | + ) |
| 186 | + |
| 187 | + |
| 188 | +def test_build_does_not_import_tlds_txt(): |
| 189 | + """Architectural invariant: build must not depend on the tlds_txt module. |
| 190 | +
|
| 191 | + tlds.txt drifts from root DB in normal operation (see .merck |
| 192 | + incident, commit 9a2c1b9, where the build was rewritten to derive |
| 193 | + delegation status from root DB alone). Re-introducing any tlds_txt |
| 194 | + module symbol into the build path would resurrect that |
| 195 | + drift-as-build-failure class of bug. |
| 196 | +
|
| 197 | + Uses AST inspection rather than string grep so the check ignores |
| 198 | + occurrences of "tlds_txt" inside comments, docstrings, or unrelated |
| 199 | + identifiers. |
| 200 | + """ |
| 201 | + tree = ast.parse(inspect.getsource(build_module)) |
| 202 | + |
| 203 | + violations = [] |
| 204 | + for node in ast.walk(tree): |
| 205 | + if isinstance(node, ast.ImportFrom): |
| 206 | + module = node.module or "" |
| 207 | + for alias in node.names: |
| 208 | + if "tlds_txt" in module or "tlds_txt" in alias.name: |
| 209 | + violations.append(f"from {module} import {alias.name}") |
| 210 | + elif isinstance(node, ast.Import): |
| 211 | + for alias in node.names: |
| 212 | + if "tlds_txt" in alias.name: |
| 213 | + violations.append(f"import {alias.name}") |
| 214 | + |
| 215 | + assert not violations, ( |
| 216 | + f"src/build/tlds.py imports from tlds_txt module: {violations}. " |
| 217 | + "Per .merck incident (commit 9a2c1b9), build must derive TLD " |
| 218 | + "set membership from root DB alone." |
| 219 | + ) |
0 commit comments