Files
ExploreDNS/internal/traverse/stats_test.go
T
Gary HansenandClaude Fable 5 d71c7fbef2 feat: rework engine and CLI for dnstraverse parity
Port the traversal engine to the Ruby dnstraverse model so behaviour and
output match dns.squish.net:

- dns: single RD=0 query path (RD=1 only for upstream root discovery),
  per-run packet cache, EDNS0 512-fallback with warnings, UDP->TCP on
  truncation; fix --retries 0 and --root-server IP-literal handling;
  drop all hardcoded 127.0.0.1:53 resolvers
- traverse: hierarchical per-branch InfoCache, 7-step response
  classification with the full 10-status vocabulary, bailiwick
  partitioning, strictly-deeper lame-referral rule, refid grammar with
  .0 resolve subtrees and childset digits, per-IP branching at 1/n
  weight, cache-based glue resolution with noglue/loop dead ends, CNAME
  restarts from the deepest cached zone, fast-mode memoization,
  probability aggregation with Ruby-identical stats keys (sums to 1.0)
- output: byte-for-byte reference text format pinned by a golden test,
  reference CLI defaults, working --quiet/--show-X=false, TTY-aware
  colour, deduplicated deterministic JSON
- web: adapt API/SPA to the new engine, SSE events carry refid/status,
  fix subscribe/snapshot duplicate-event race and a statusCls TDZ bug,
  align SPA type list with the backend
- delete the old engine and dead code (net -4,350 lines)

Verified against live runs of the reference Ruby engine across five
domains (answers, NXDOMAIN, null MX, CNAME restart, glueless resolve)
with no divergences beyond the documented typo fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:42:06 +10:00

303 lines
11 KiB
Go

package traverse
import (
"math"
"net"
"strconv"
"strings"
"testing"
"github.com/miekg/dns"
)
// mockCaptureTopology reproduces the delegation behind
// docs/captures/dnstraverse-ruby-www.example.com-A.txt: one root, thirteen
// com gTLD servers, example.com served by two NS with three IPs each, every
// endpoint answering the same two A records (two endpoints return them in
// the opposite order, as in the capture).
func mockCaptureTopology() *mockExchange {
m := newMockExchange()
gtlds := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m"}
var comNS, comGlue []dns.RR
gtldIPs := make([]string, len(gtlds))
for i, l := range gtlds {
ip := "192.0.2." + strconv.Itoa(i+1)
gtldIPs[i] = ip
comNS = append(comNS, nsRR("com", l+".gtld-servers.net"))
comGlue = append(comGlue, aRR(l+".gtld-servers.net", ip))
}
m.on("202.12.27.33", "www.example.com", dns.TypeA, referralMsg(comNS, comGlue...))
heraIPs := []string{"108.162.192.162", "172.64.32.162", "173.245.58.162"}
elliottIPs := []string{"108.162.195.228", "162.159.44.228", "172.64.35.228"}
exampleReferral := referralMsg(
[]dns.RR{
nsRR("example.com", "hera.ns.cloudflare.com"),
nsRR("example.com", "elliott.ns.cloudflare.com"),
},
aRR("hera.ns.cloudflare.com", heraIPs[0]),
aRR("hera.ns.cloudflare.com", heraIPs[1]),
aRR("hera.ns.cloudflare.com", heraIPs[2]),
aRR("elliott.ns.cloudflare.com", elliottIPs[0]),
aRR("elliott.ns.cloudflare.com", elliottIPs[1]),
aRR("elliott.ns.cloudflare.com", elliottIPs[2]),
)
for _, ip := range gtldIPs {
m.on(ip, "www.example.com", dns.TypeA, exampleReferral)
}
forward := answerMsg(
aRR("www.example.com", "104.20.23.154"),
aRR("www.example.com", "172.66.147.243"),
)
reversed := answerMsg(
aRR("www.example.com", "172.66.147.243"),
aRR("www.example.com", "104.20.23.154"),
)
for _, ip := range []string{heraIPs[0], elliottIPs[0], elliottIPs[1], elliottIPs[2]} {
m.on(ip, "www.example.com", dns.TypeA, forward)
}
for _, ip := range []string{heraIPs[1], heraIPs[2]} {
m.on(ip, "www.example.com", dns.TypeA, reversed)
}
return m
}
// TestCapturePerEndpointFractions asserts the 16.7%-per-endpoint result of
// the www.example.com capture: 13 gTLD paths collapse (fast mode) into six
// endpoint leaves of 1/6 each, and the summary merges the differently
// ordered RRsets into a single 100% answered line.
func TestCapturePerEndpointFractions(t *testing.T) {
m := mockCaptureTopology()
cfg := testConfig(true)
cfg.RootAddrs = []net.IP{net.ParseIP("202.12.27.33")}
_, root := runTraversal(t, cfg, m, "www.example.com")
assertSumsToOne(t, root)
answered := leavesByStatus(root, StatusAnswered)
if len(answered) != 6 {
t.Fatalf("expected 6 answered leaves (one per endpoint), got %d: %v", len(answered), root.StatsList())
}
for _, leaf := range answered {
if math.Abs(leaf.Prob-1.0/6) > 1e-9 {
t.Errorf("leaf %s prob = %v, want 1/6", leaf.Key, leaf.Prob)
}
}
stats := root.SummaryStats()
if stats == nil {
t.Fatal("expected summary stats after calculation")
}
if prob := stats.ByStatus[StatusAnswered]; math.Abs(prob-1.0) > 1e-9 {
t.Errorf("answered summary prob = %v, want 1.0", prob)
}
// Both RR orders share the same sorted-rdata key: one summary line.
if len(stats.Answers) != 1 {
t.Fatalf("expected 1 distinct answered RRset, got %d: %v", len(stats.Answers), stats.Answers)
}
if math.Abs(stats.Answers[0].Prob-1.0) > 1e-9 {
t.Errorf("answer group prob = %v, want 1.0", stats.Answers[0].Prob)
}
if !strings.Contains(stats.Answers[0].Key, "@@@") {
t.Errorf("answer key should join rdata with @@@, got %q", stats.Answers[0].Key)
}
if len(stats.Answers[0].RRs) != 2 {
t.Errorf("answer RRs = %v", stats.Answers[0].RRs)
}
}
// TestDistinctRRsetsSeparateGroups asserts the converse of the capture test:
// two endpoints answering DIFFERENT content produce two separate summary
// groups, each carrying its own share of the answered probability.
func TestDistinctRRsetsSeparateGroups(t *testing.T) {
m := newMockExchange()
m.on("198.41.0.4", "www.example.com", dns.TypeA, referralMsg(
[]dns.RR{nsRR("example.com", "ns1.example.com"), nsRR("example.com", "ns2.example.com")},
aRR("ns1.example.com", "1.1.1.1"),
aRR("ns2.example.com", "2.2.2.2"),
))
// Both RRsets share their first sorted rdata so grouping must consider
// the full content, not just the first record.
m.on("1.1.1.1", "www.example.com", dns.TypeA, answerMsg(
aRR("www.example.com", "1.0.0.1"),
aRR("www.example.com", "9.9.9.9"),
))
m.on("2.2.2.2", "www.example.com", dns.TypeA, answerMsg(
aRR("www.example.com", "1.0.0.1"),
aRR("www.example.com", "8.8.8.8"),
))
_, root := runTraversal(t, testConfig(false), m, "www.example.com")
assertSumsToOne(t, root)
stats := root.SummaryStats()
if stats == nil {
t.Fatal("expected summary stats after calculation")
}
if prob := stats.ByStatus[StatusAnswered]; math.Abs(prob-1.0) > 1e-9 {
t.Errorf("answered summary prob = %v, want 1.0", prob)
}
if len(stats.Answers) != 2 {
t.Fatalf("expected 2 distinct answered RRsets, got %d: %v", len(stats.Answers), stats.Answers)
}
if stats.Answers[0].Key == stats.Answers[1].Key {
t.Errorf("answer groups share key %q, want distinct keys", stats.Answers[0].Key)
}
for i, ans := range stats.Answers {
if math.Abs(ans.Prob-0.5) > 1e-9 {
t.Errorf("answer group %d (%q) prob = %v, want 0.5", i, ans.Key, ans.Prob)
}
}
// Answers are sorted by key: "1.0.0.1@@@8.8.8.8" then "1.0.0.1@@@9.9.9.9".
wantRdata := []string{"8.8.8.8", "9.9.9.9"}
for i, ans := range stats.Answers {
if !strings.Contains(ans.Key, "1.0.0.1@@@"+wantRdata[i]) {
t.Errorf("answer group %d key = %q, want it to contain %q", i, ans.Key, "1.0.0.1@@@"+wantRdata[i])
}
if len(ans.RRs) != 2 {
t.Errorf("answer group %d RRs = %v, want 2 records", i, ans.RRs)
}
}
}
func TestServfailErrorLeaf(t *testing.T) {
m := newMockExchange()
m.on("198.41.0.4", "www.example.com", dns.TypeA, referralMsg(
[]dns.RR{nsRR("example.com", "ns1.example.com"), nsRR("example.com", "ns2.example.com")},
aRR("ns1.example.com", "1.1.1.1"),
aRR("ns2.example.com", "2.2.2.2"),
))
m.on("1.1.1.1", "www.example.com", dns.TypeA, answerMsg(aRR("www.example.com", "9.9.9.9")))
m.on("2.2.2.2", "www.example.com", dns.TypeA, rcodeMsg(dns.RcodeServerFailure))
_, root := runTraversal(t, testConfig(false), m, "www.example.com")
assertSumsToOne(t, root)
errs := leavesByStatus(root, StatusError)
if len(errs) != 1 {
t.Fatalf("expected 1 error leaf, got %v", root.StatsList())
}
if errs[0].Response.DQ.ErrorMessage != "Server failure (SERVFAIL)" {
t.Errorf("error message = %q", errs[0].Response.DQ.ErrorMessage)
}
if math.Abs(errs[0].Prob-0.5) > 1e-9 {
t.Errorf("error prob = %v, want 0.5", errs[0].Prob)
}
stats := root.SummaryStats()
if math.Abs(stats.ByStatus[StatusError]-0.5) > 1e-9 ||
math.Abs(stats.ByStatus[StatusAnswered]-0.5) > 1e-9 {
t.Errorf("summary by status = %v", stats.ByStatus)
}
total := 0.0
for _, prob := range stats.ByStatus {
total += prob
}
if math.Abs(total-1.0) > 1e-9 {
t.Errorf("summary probabilities sum to %v, want 1.0", total)
}
}
// TestResolveSubtreeLeavesExcluded asserts that resolve-subtree leaves (the
// A <servername> lookups for glueless NS) never reach the main aggregation:
// they surface only through server weights.
func TestResolveSubtreeLeavesExcluded(t *testing.T) {
m := newMockExchange()
m.on("198.41.0.4", "www.example.com", dns.TypeA, referralMsg(
[]dns.RR{nsRR("com", "a.gtld-servers.net")},
aRR("a.gtld-servers.net", "192.5.6.30"),
))
m.on("192.5.6.30", "www.example.com", dns.TypeA, referralMsg(
[]dns.RR{nsRR("example.com", "ns1.example.com"), nsRR("example.com", "ns.other.net")},
aRR("ns1.example.com", "1.1.1.1"),
))
m.on("1.1.1.1", "www.example.com", dns.TypeA, answerMsg(aRR("www.example.com", "9.9.9.9")))
m.on("198.41.0.4", "ns.other.net", dns.TypeA, answerMsg(aRR("ns.other.net", "4.4.4.4")))
m.on("4.4.4.4", "www.example.com", dns.TypeA, answerMsg(aRR("www.example.com", "9.9.9.9")))
_, root := runTraversal(t, testConfig(false), m, "www.example.com")
assertSumsToOne(t, root)
for _, leaf := range root.StatsList() {
if leaf.Response.Qname == "ns.other.net" {
t.Errorf("resolve-subtree leaf leaked into main aggregation: %s", leaf.Key)
}
}
if prob := root.SummaryStats().ByStatus[StatusAnswered]; math.Abs(prob-1.0) > 1e-9 {
t.Errorf("answered summary prob = %v, want 1.0", prob)
}
}
// TestResolveFailurePseudoIPCarriesMass asserts that a failed glue resolution
// keeps its probability: the failure becomes a "key:" pseudo-IP whose mass
// surfaces in the main aggregation as the failing (resolve) query.
func TestResolveFailurePseudoIPCarriesMass(t *testing.T) {
m := newMockExchange()
m.on("198.41.0.4", "www.example.com", dns.TypeA, referralMsg(
[]dns.RR{nsRR("com", "a.gtld-servers.net")},
aRR("a.gtld-servers.net", "192.5.6.30"),
))
m.on("192.5.6.30", "www.example.com", dns.TypeA, referralMsg(
[]dns.RR{nsRR("example.com", "ns1.example.com"), nsRR("example.com", "ns.other.net")},
aRR("ns1.example.com", "1.1.1.1"),
))
m.on("1.1.1.1", "www.example.com", dns.TypeA, answerMsg(aRR("www.example.com", "9.9.9.9")))
// The resolve of A ns.other.net fails at the root: SERVFAIL.
m.on("198.41.0.4", "ns.other.net", dns.TypeA, rcodeMsg(dns.RcodeServerFailure))
_, root := runTraversal(t, testConfig(false), m, "www.example.com")
assertSumsToOne(t, root)
errs := leavesByStatus(root, StatusError)
if len(errs) != 1 {
t.Fatalf("expected 1 error leaf from the failed resolve, got %v", root.StatsList())
}
leaf := errs[0]
if math.Abs(leaf.Prob-0.5) > 1e-9 {
t.Errorf("failed-resolve prob = %v, want 0.5", leaf.Prob)
}
if leaf.Response.Qname != "ns.other.net" {
t.Errorf("failed-resolve leaf qname = %q, want the resolve target", leaf.Response.Qname)
}
if !strings.HasPrefix(leaf.Key, "key:error:") {
t.Errorf("failed-resolve key = %q", leaf.Key)
}
// The leaf's referral is the resolve-subtree node; its parent is the
// glueless referral, which carries the mass as a pseudo-IP server entry.
glueless := leaf.Referral.Parent
if glueless.Server != "ns.other.net" {
t.Fatalf("glueless referral server = %q", glueless.Server)
}
hasPseudo := false
for ip, weight := range glueless.ServerWeights {
if strings.HasPrefix(ip, "key:") && math.Abs(weight-1.0) <= 1e-9 {
hasPseudo = true
}
}
if !hasPseudo {
t.Errorf("expected a key: pseudo-IP with weight 1.0, got %v", glueless.ServerWeights)
}
}
func TestSummaryStatsNilAndMemoised(t *testing.T) {
var nilRef *Referral
if nilRef.SummaryStats() != nil {
t.Error("nil referral should produce nil summary")
}
uncalculated := newTestReferral("ns1.example.com", []string{"1.1.1.1"})
if uncalculated.SummaryStats() != nil {
t.Error("uncalculated referral should produce nil summary")
}
m := mockSimpleDelegation()
_, root := runTraversal(t, testConfig(false), m, "www.example.com")
first := root.SummaryStats()
if first == nil {
t.Fatal("expected summary stats")
}
if root.SummaryStats() != first {
t.Error("summary stats should be memoised")
}
}