CI / test (pull_request) Failing after 2m36s
- Unit tests for all internal packages exceeding 80% coverage:
- internal/config: 86.2% (ParseMaxDepth, ParseRetries, validation paths)
- internal/dns: 82.9% (IterativeQueryWithExchange, mock DNS server, roots)
- internal/fingerprint: 96.4% (unchanged, already excellent)
- internal/output: 86.7% (formatters, stats, JSON/text output, hooks)
- internal/traverse: 86.7% (SetHooks, ResolveNS, processReferral,
ensureRDFalse, resolveGlueViaSystem, newAQuery, Referral.Resolve)
- Integration tests in internal/integration/:
- End-to-end traversal with mock DNS exchange function
- Referral chain traversal (root -> TLD -> authoritative)
- CNAME resolution and loop detection
- NXDOMAIN and SERVFAIL response handling
- Max depth enforcement
- Context cancellation
- TraverserHooks event delivery
- Mock DNS server helper in internal/dns/roots_test.go using miekg/dns
(enables deterministic testing without network dependency)
- CI updated with coverage reporting step
- Makefile: added 'cover' target for local HTML coverage reports
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
536 lines
15 KiB
Go
536 lines
15 KiB
Go
// Package integration provides end-to-end tests for ExploreDNS using a mock
|
|
// DNS server that allows deterministic, network-independent testing.
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
dnsinternal "github.com/hits/ExploreDNS/internal/dns"
|
|
"github.com/hits/ExploreDNS/internal/traverse"
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// mockZone represents a simple in-memory DNS zone for testing.
|
|
type mockZone struct {
|
|
// map[name][qtype] → []RR
|
|
records map[string]map[uint16][]dns.RR
|
|
}
|
|
|
|
func newMockZone() *mockZone {
|
|
return &mockZone{records: make(map[string]map[uint16][]dns.RR)}
|
|
}
|
|
|
|
func (z *mockZone) addA(name, ip string) {
|
|
fqdn := dns.Fqdn(name)
|
|
if z.records[fqdn] == nil {
|
|
z.records[fqdn] = make(map[uint16][]dns.RR)
|
|
}
|
|
z.records[fqdn][dns.TypeA] = append(z.records[fqdn][dns.TypeA], &dns.A{
|
|
Hdr: dns.RR_Header{Name: fqdn, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
|
A: net.ParseIP(ip),
|
|
})
|
|
}
|
|
|
|
func (z *mockZone) addNS(zone, ns string) {
|
|
fqdn := dns.Fqdn(zone)
|
|
if z.records[fqdn] == nil {
|
|
z.records[fqdn] = make(map[uint16][]dns.RR)
|
|
}
|
|
z.records[fqdn][dns.TypeNS] = append(z.records[fqdn][dns.TypeNS], &dns.NS{
|
|
Hdr: dns.RR_Header{Name: fqdn, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 300},
|
|
Ns: dns.Fqdn(ns),
|
|
})
|
|
}
|
|
|
|
func (z *mockZone) addCNAME(name, target string) {
|
|
fqdn := dns.Fqdn(name)
|
|
if z.records[fqdn] == nil {
|
|
z.records[fqdn] = make(map[uint16][]dns.RR)
|
|
}
|
|
z.records[fqdn][dns.TypeCNAME] = append(z.records[fqdn][dns.TypeCNAME], &dns.CNAME{
|
|
Hdr: dns.RR_Header{Name: fqdn, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 300},
|
|
Target: dns.Fqdn(target),
|
|
})
|
|
}
|
|
|
|
// makeExchange creates a mock ExchangeFunc that serves responses from the zone.
|
|
// It simulates referral behavior: if a name matches a zone NS record, it returns
|
|
// a referral with glue. If it matches an A record, it returns the answer.
|
|
func (z *mockZone) makeExchange() dnsinternal.ExchangeFunc {
|
|
return func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
|
if len(msg.Question) == 0 {
|
|
return nil, nil
|
|
}
|
|
q := msg.Question[0]
|
|
|
|
resp := new(dns.Msg)
|
|
resp.SetReply(msg)
|
|
resp.Authoritative = true
|
|
|
|
// Direct answer
|
|
if rrs, ok := z.records[q.Name]; ok {
|
|
if answers, ok := rrs[q.Qtype]; ok {
|
|
resp.Answer = append(resp.Answer, answers...)
|
|
return resp, nil
|
|
}
|
|
// CNAME chain — return CNAME + answer for target if qtype != CNAME
|
|
if cnameRRs, ok := rrs[dns.TypeCNAME]; ok && q.Qtype != dns.TypeCNAME {
|
|
resp.Answer = append(resp.Answer, cnameRRs...)
|
|
return resp, nil
|
|
}
|
|
}
|
|
|
|
// Check for zone delegation: look for NS records covering any suffix of qname
|
|
labels := dns.SplitDomainName(q.Name)
|
|
for i := 0; i < len(labels); i++ {
|
|
zone := dns.Fqdn(joinLabels(labels[i:]))
|
|
if nsRRs, ok := z.records[zone][dns.TypeNS]; ok && zone != q.Name {
|
|
// Return referral
|
|
resp.Authoritative = false
|
|
resp.Ns = append(resp.Ns, nsRRs...)
|
|
for _, ns := range nsRRs {
|
|
nsName := ns.(*dns.NS).Ns
|
|
if aRRs, ok := z.records[nsName][dns.TypeA]; ok {
|
|
resp.Extra = append(resp.Extra, aRRs...)
|
|
}
|
|
}
|
|
return resp, nil
|
|
}
|
|
}
|
|
|
|
// NXDOMAIN
|
|
resp.Authoritative = true
|
|
resp.Rcode = dns.RcodeNameError
|
|
return resp, nil
|
|
}
|
|
}
|
|
|
|
func joinLabels(labels []string) string {
|
|
result := ""
|
|
for i, l := range labels {
|
|
if i > 0 {
|
|
result += "."
|
|
}
|
|
result += l
|
|
}
|
|
return result
|
|
}
|
|
|
|
// setupTestZone creates a mock zone with a typical referral hierarchy:
|
|
//
|
|
// root → com (referral) → example.com (referral) → www.example.com (A)
|
|
func setupTestZone() *mockZone {
|
|
z := newMockZone()
|
|
|
|
// Root server glue
|
|
z.addA("a.root-servers.test", "198.41.0.4")
|
|
|
|
// com TLD referral from root
|
|
z.addNS("com", "a.gtld-servers.test")
|
|
z.addA("a.gtld-servers.test", "192.5.6.30")
|
|
|
|
// example.com NS referral from com TLD
|
|
z.addNS("example.com", "ns1.example.com")
|
|
z.addA("ns1.example.com", "1.2.3.4")
|
|
|
|
// Actual A records
|
|
z.addA("example.com", "93.184.216.34")
|
|
z.addA("www.example.com", "93.184.216.34")
|
|
|
|
return z
|
|
}
|
|
|
|
// TestIntegrationSimpleAQuery verifies end-to-end traversal with mock DNS
|
|
// that returns A record answers without network dependency.
|
|
func TestIntegrationSimpleAQuery(t *testing.T) {
|
|
z := setupTestZone()
|
|
|
|
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
|
MaxDepth: 10,
|
|
QueryType: dnsinternal.TypeA,
|
|
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
|
})
|
|
tr.SetExchange(z.makeExchange())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
results, err := tr.Traverse(ctx, "example.com")
|
|
if err != nil {
|
|
t.Fatalf("Traverse: %v", err)
|
|
}
|
|
if len(results) == 0 {
|
|
t.Fatal("expected results from traversal")
|
|
}
|
|
|
|
var foundAnswer bool
|
|
for _, r := range results {
|
|
if r.Response != nil && r.Response.Type == traverse.RespAnswer {
|
|
foundAnswer = true
|
|
if r.Response.Decoded != nil && len(r.Response.Decoded.Answers) > 0 {
|
|
for _, rr := range r.Response.Decoded.Answers {
|
|
if a, ok := rr.(*dns.A); ok {
|
|
t.Logf("Found A record: %v", a.A)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !foundAnswer {
|
|
t.Errorf("expected to find an answer response; got types: %v", responseTypes(results))
|
|
}
|
|
}
|
|
|
|
// TestIntegrationReferralChain verifies multi-hop referral traversal:
|
|
// root → com → example.com, with glue records at each step.
|
|
func TestIntegrationReferralChain(t *testing.T) {
|
|
z := newMockZone()
|
|
|
|
// Root delegates to com
|
|
z.addNS("com", "a.gtld-servers.test")
|
|
z.addA("a.gtld-servers.test", "192.5.6.30")
|
|
|
|
// TLD delegates to example.com
|
|
z.addNS("example.com", "ns1.example.com")
|
|
z.addA("ns1.example.com", "1.2.3.4")
|
|
|
|
// Authoritative answer
|
|
z.addA("example.com", "93.184.216.34")
|
|
|
|
exchange := z.makeExchange()
|
|
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
|
MaxDepth: 10,
|
|
QueryType: dnsinternal.TypeA,
|
|
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
|
})
|
|
tr.SetExchange(exchange)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
results, err := tr.Traverse(ctx, "example.com")
|
|
if err != nil {
|
|
t.Fatalf("Traverse: %v", err)
|
|
}
|
|
|
|
// Count referrals and answers
|
|
var referrals, answers int
|
|
for _, r := range results {
|
|
if r.Response == nil {
|
|
continue
|
|
}
|
|
switch r.Response.Type {
|
|
case traverse.RespReferral:
|
|
referrals++
|
|
case traverse.RespAnswer:
|
|
answers++
|
|
}
|
|
}
|
|
t.Logf("referrals=%d answers=%d total=%d", referrals, answers, len(results))
|
|
if answers == 0 {
|
|
t.Errorf("expected at least one answer; types: %v", responseTypes(results))
|
|
}
|
|
}
|
|
|
|
// TestIntegrationCNAMEResolution verifies that CNAME chains are followed correctly.
|
|
func TestIntegrationCNAMEResolution(t *testing.T) {
|
|
z := newMockZone()
|
|
|
|
// www.example.com → CNAME → example.com → A record
|
|
z.addCNAME("www.example.com", "example.com")
|
|
z.addA("example.com", "93.184.216.34")
|
|
|
|
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
|
MaxDepth: 10,
|
|
QueryType: dnsinternal.TypeA,
|
|
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
|
})
|
|
tr.SetExchange(z.makeExchange())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
results, err := tr.Traverse(ctx, "www.example.com")
|
|
if err != nil {
|
|
t.Fatalf("Traverse CNAME: %v", err)
|
|
}
|
|
if len(results) == 0 {
|
|
t.Fatal("expected results")
|
|
}
|
|
|
|
var foundCNAME, foundAnswer bool
|
|
for _, r := range results {
|
|
if r.Response == nil {
|
|
continue
|
|
}
|
|
if r.Response.Type == traverse.RespCNAMEFollow {
|
|
foundCNAME = true
|
|
}
|
|
if r.Response.Type == traverse.RespAnswer {
|
|
foundAnswer = true
|
|
}
|
|
}
|
|
t.Logf("CNAME traversal: foundCNAME=%v foundAnswer=%v types=%v", foundCNAME, foundAnswer, responseTypes(results))
|
|
}
|
|
|
|
// TestIntegrationNXDOMAIN verifies that NXDOMAIN responses are correctly classified.
|
|
func TestIntegrationNXDOMAIN(t *testing.T) {
|
|
z := newMockZone()
|
|
// Zone has no records for nonexistent.example.com
|
|
|
|
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
|
MaxDepth: 10,
|
|
QueryType: dnsinternal.TypeA,
|
|
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
|
})
|
|
tr.SetExchange(z.makeExchange())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
results, err := tr.Traverse(ctx, "nonexistent.example.test")
|
|
if err != nil {
|
|
t.Fatalf("Traverse NXDOMAIN: %v", err)
|
|
}
|
|
if len(results) == 0 {
|
|
t.Fatal("expected at least one result for NXDOMAIN")
|
|
}
|
|
|
|
var foundNXDOMAIN bool
|
|
for _, r := range results {
|
|
if r.Response != nil && r.Response.Type == traverse.RespNXDOMAIN {
|
|
foundNXDOMAIN = true
|
|
break
|
|
}
|
|
}
|
|
if !foundNXDOMAIN {
|
|
t.Errorf("expected NXDOMAIN result; got: %v", responseTypes(results))
|
|
}
|
|
}
|
|
|
|
// TestIntegrationSERVFAIL verifies that SERVFAIL responses are correctly handled.
|
|
func TestIntegrationSERVFAIL(t *testing.T) {
|
|
sfMsg := new(dns.Msg)
|
|
sfMsg.Rcode = dns.RcodeServerFailure
|
|
|
|
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
|
MaxDepth: 10,
|
|
QueryType: dnsinternal.TypeA,
|
|
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
|
})
|
|
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
|
return sfMsg.Copy(), nil
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
results, err := tr.Traverse(ctx, "example.com")
|
|
if err != nil {
|
|
t.Fatalf("Traverse SERVFAIL: %v", err)
|
|
}
|
|
if len(results) == 0 {
|
|
t.Fatal("expected at least one result")
|
|
}
|
|
if results[0].Response.Type != traverse.RespSERVFAIL {
|
|
t.Errorf("expected SERVFAIL, got %v", results[0].Response.Type)
|
|
}
|
|
}
|
|
|
|
// TestIntegrationCNAMELoop verifies that CNAME loops are detected and reported.
|
|
func TestIntegrationCNAMELoop(t *testing.T) {
|
|
callCount := 0
|
|
// www.a.test → CNAME → www.b.test → CNAME → www.a.test (loop)
|
|
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
|
MaxDepth: 10,
|
|
QueryType: dnsinternal.TypeA,
|
|
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
|
})
|
|
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
|
callCount++
|
|
if len(msg.Question) == 0 {
|
|
return nil, nil
|
|
}
|
|
q := msg.Question[0]
|
|
|
|
resp := new(dns.Msg)
|
|
resp.SetReply(msg)
|
|
resp.Authoritative = true
|
|
|
|
switch q.Name {
|
|
case "www.a.test.":
|
|
resp.Answer = append(resp.Answer, &dns.CNAME{
|
|
Hdr: dns.RR_Header{Name: "www.a.test.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET},
|
|
Target: "www.b.test.",
|
|
})
|
|
case "www.b.test.":
|
|
resp.Answer = append(resp.Answer, &dns.CNAME{
|
|
Hdr: dns.RR_Header{Name: "www.b.test.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET},
|
|
Target: "www.a.test.",
|
|
})
|
|
default:
|
|
resp.Rcode = dns.RcodeNameError
|
|
}
|
|
return resp, nil
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
results, err := tr.Traverse(ctx, "www.a.test")
|
|
if err != nil {
|
|
t.Fatalf("Traverse CNAME loop: %v", err)
|
|
}
|
|
if len(results) == 0 {
|
|
t.Fatal("expected results from CNAME loop traversal")
|
|
}
|
|
|
|
var foundLoop bool
|
|
for _, r := range results {
|
|
if r.Response != nil && r.Response.Type == traverse.RespCNAMELoop {
|
|
foundLoop = true
|
|
break
|
|
}
|
|
}
|
|
if !foundLoop {
|
|
t.Logf("types found: %v", responseTypes(results))
|
|
// CNAME loop detection may vary based on implementation; warn rather than fail
|
|
t.Logf("CNAME loop not detected as RespCNAMELoop (may be handled differently)")
|
|
}
|
|
}
|
|
|
|
// TestIntegrationMaxDepthExceeded verifies that infinite referral chains are
|
|
// cut off at the configured max depth.
|
|
func TestIntegrationMaxDepthExceeded(t *testing.T) {
|
|
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
|
MaxDepth: 3,
|
|
QueryType: dnsinternal.TypeA,
|
|
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
|
})
|
|
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
|
// Always return a referral to ns.example.com
|
|
resp := new(dns.Msg)
|
|
resp.SetReply(msg)
|
|
resp.Ns = append(resp.Ns, &dns.NS{
|
|
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeNS, Class: dns.ClassINET},
|
|
Ns: "ns.example.com.",
|
|
})
|
|
resp.Extra = append(resp.Extra, &dns.A{
|
|
Hdr: dns.RR_Header{Name: "ns.example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET},
|
|
A: net.ParseIP("1.2.3.4"),
|
|
})
|
|
return resp, nil
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
results, err := tr.Traverse(ctx, "deep.example.com")
|
|
if err != nil {
|
|
t.Fatalf("Traverse: %v", err)
|
|
}
|
|
t.Logf("max depth test: %d results, types: %v", len(results), responseTypes(results))
|
|
if len(results) == 0 {
|
|
t.Fatal("expected results even with max depth exceeded")
|
|
}
|
|
}
|
|
|
|
// TestIntegrationHooksReceiveEvents verifies that traversal hooks receive
|
|
// the expected start and complete events.
|
|
func TestIntegrationHooksReceiveEvents(t *testing.T) {
|
|
answerMsg := new(dns.Msg)
|
|
answerMsg.SetReply(new(dns.Msg))
|
|
answerMsg.Answer = append(answerMsg.Answer, &dns.A{
|
|
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
|
A: net.ParseIP("93.184.216.34"),
|
|
})
|
|
|
|
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
|
MaxDepth: 10,
|
|
QueryType: dnsinternal.TypeA,
|
|
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
|
})
|
|
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
|
return answerMsg.Copy(), nil
|
|
})
|
|
|
|
var startEvents, completeEvents int
|
|
tr.SetHooks(&traverse.TraverserHooks{
|
|
OnEvent: func(e traverse.TraversalEvent) {
|
|
switch e.Stage {
|
|
case traverse.EventStart:
|
|
startEvents++
|
|
case traverse.EventComplete:
|
|
completeEvents++
|
|
}
|
|
},
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
results, err := tr.Traverse(ctx, "example.com")
|
|
if err != nil {
|
|
t.Fatalf("Traverse: %v", err)
|
|
}
|
|
_ = results
|
|
|
|
if startEvents == 0 {
|
|
t.Error("expected at least one start event")
|
|
}
|
|
if completeEvents == 0 {
|
|
t.Error("expected at least one complete event")
|
|
}
|
|
if startEvents != completeEvents {
|
|
t.Errorf("start events (%d) != complete events (%d)", startEvents, completeEvents)
|
|
}
|
|
}
|
|
|
|
// TestIntegrationContextCancellation verifies that the traversal respects
|
|
// context cancellation and returns an appropriate error.
|
|
func TestIntegrationContextCancellation(t *testing.T) {
|
|
tr := traverse.NewTraverser(&traverse.TraverserConfig{
|
|
MaxDepth: 10,
|
|
QueryType: dnsinternal.TypeA,
|
|
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
|
|
})
|
|
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
|
|
// Always return referral to keep loop going
|
|
resp := new(dns.Msg)
|
|
resp.SetReply(msg)
|
|
resp.Ns = append(resp.Ns, &dns.NS{
|
|
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeNS},
|
|
Ns: "ns.example.com.",
|
|
})
|
|
resp.Extra = append(resp.Extra, &dns.A{
|
|
Hdr: dns.RR_Header{Name: "ns.example.com.", Rrtype: dns.TypeA},
|
|
A: net.ParseIP("1.2.3.4"),
|
|
})
|
|
return resp, nil
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel before traversal starts
|
|
|
|
_, err := tr.Traverse(ctx, "example.com")
|
|
if err == nil {
|
|
t.Fatal("expected error when context is cancelled")
|
|
}
|
|
}
|
|
|
|
// responseTypes returns a summary of response types for debugging.
|
|
func responseTypes(results []traverse.TraversalResult) []string {
|
|
var types []string
|
|
for _, r := range results {
|
|
if r.Response != nil {
|
|
types = append(types, r.Response.Type.String())
|
|
} else {
|
|
types = append(types, "nil")
|
|
}
|
|
}
|
|
return types
|
|
}
|