Files
dnswatcher/internal/resolver/resolver_test.go
user 9b93385f6f
All checks were successful
Check / check (pull_request) Successful in 10m10s
fix: mock DNS client in resolver tests to avoid network calls
Replace real DNS lookups in resolver_test.go with a mock DNSClient
that returns canned responses. Tests now run in ~1s instead of >30s
with no network dependency.

Uses the existing NewFromLoggerWithClient constructor and DNSClient
interface — no production code changes needed.
2026-02-21 02:57:23 -08:00

955 lines
20 KiB
Go

package resolver_test
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"os"
"sort"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/resolver"
)
// ----------------------------------------------------------------
// Mock DNS client
// ----------------------------------------------------------------
// errNoQuestion is returned when a DNS message has no question.
var errNoQuestion = errors.New("no question in DNS message")
// mockExchange represents a canned DNS response for a query.
type mockExchange struct {
resp *dns.Msg
err error
}
// mockDNSClient implements resolver.DNSClient with canned responses.
// It matches on (qname, qtype) and returns pre-configured responses.
// If no match is found it returns SERVFAIL.
type mockDNSClient struct {
// responses keyed by "QNAME/QTYPE" e.g. "google.com./NS"
responses map[string]*mockExchange
}
func newMockClient() *mockDNSClient {
return &mockDNSClient{
responses: make(map[string]*mockExchange),
}
}
func mockKey(name string, qtype uint16) string {
return fmt.Sprintf("%s/%s",
strings.ToLower(dns.Fqdn(name)),
dns.TypeToString[qtype],
)
}
// ExchangeContext implements resolver.DNSClient for testing.
func (m *mockDNSClient) ExchangeContext(
_ context.Context,
msg *dns.Msg,
_ string,
) (*dns.Msg, time.Duration, error) {
if len(msg.Question) == 0 {
return nil, 0, errNoQuestion
}
q := msg.Question[0]
key := mockKey(q.Name, q.Qtype)
if ex, ok := m.responses[key]; ok {
if ex.err != nil {
return nil, 0, ex.err
}
resp := ex.resp.Copy()
resp.Id = msg.Id
return resp, time.Millisecond, nil
}
// Default: SERVFAIL
resp := new(dns.Msg)
resp.SetReply(msg)
resp.Rcode = dns.RcodeServerFailure
return resp, time.Millisecond, nil
}
func (m *mockDNSClient) on(
name string,
qtype uint16,
resp *dns.Msg,
) {
m.responses[mockKey(name, qtype)] = &mockExchange{resp: resp}
}
// ----------------------------------------------------------------
// Helper functions to build DNS responses
// ----------------------------------------------------------------
func makeNSResponse(
qname string,
nsNames []string,
glue map[string]string,
) *dns.Msg {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(qname), dns.TypeNS)
msg.Authoritative = true
for _, ns := range nsNames {
msg.Answer = append(msg.Answer, &dns.NS{
Hdr: dns.RR_Header{
Name: dns.Fqdn(qname),
Rrtype: dns.TypeNS,
Class: dns.ClassINET,
Ttl: 3600,
},
Ns: dns.Fqdn(ns),
})
}
for name, ip := range glue {
msg.Extra = append(msg.Extra, &dns.A{
Hdr: dns.RR_Header{
Name: dns.Fqdn(name),
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 3600,
},
A: net.ParseIP(ip),
})
}
return msg
}
func makeAResponse(qname string, ips ...string) *dns.Msg {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(qname), dns.TypeA)
msg.Authoritative = true
for _, ip := range ips {
msg.Answer = append(msg.Answer, &dns.A{
Hdr: dns.RR_Header{
Name: dns.Fqdn(qname),
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 300,
},
A: net.ParseIP(ip),
})
}
return msg
}
func makeAAAAResponse(qname string, ips ...string) *dns.Msg {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(qname), dns.TypeAAAA)
msg.Authoritative = true
for _, ip := range ips {
msg.Answer = append(msg.Answer, &dns.AAAA{
Hdr: dns.RR_Header{
Name: dns.Fqdn(qname),
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
Ttl: 300,
},
AAAA: net.ParseIP(ip),
})
}
return msg
}
func makeMXResponse(
qname string,
mxRecords map[uint16]string,
) *dns.Msg {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(qname), dns.TypeMX)
msg.Authoritative = true
for pref, mx := range mxRecords {
msg.Answer = append(msg.Answer, &dns.MX{
Hdr: dns.RR_Header{
Name: dns.Fqdn(qname),
Rrtype: dns.TypeMX,
Class: dns.ClassINET,
Ttl: 300,
},
Preference: pref,
Mx: dns.Fqdn(mx),
})
}
return msg
}
func makeTXTResponse(qname string, txts ...string) *dns.Msg {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(qname), dns.TypeTXT)
msg.Authoritative = true
for _, txt := range txts {
msg.Answer = append(msg.Answer, &dns.TXT{
Hdr: dns.RR_Header{
Name: dns.Fqdn(qname),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: 300,
},
Txt: []string{txt},
})
}
return msg
}
func makeNXDomainResponse(
qname string,
qtype uint16,
) *dns.Msg {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(qname), qtype)
msg.Rcode = dns.RcodeNameError
return msg
}
func makeEmptyResponse(
qname string,
qtype uint16,
) *dns.Msg {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(qname), qtype)
msg.Authoritative = true
return msg
}
// ----------------------------------------------------------------
// Setup helpers
// ----------------------------------------------------------------
// setupMockResolver creates a mock client pre-loaded with a
// realistic DNS delegation chain for example.com and google.com.
func setupGoogleMock(mock *mockDNSClient) {
// google.com. NS (authoritative answer)
googleDelegation := makeNSResponse(
"google.com.",
[]string{
"ns1.google.com", "ns2.google.com",
"ns3.google.com", "ns4.google.com",
},
map[string]string{
"ns1.google.com": "216.239.32.10",
"ns2.google.com": "216.239.34.10",
"ns3.google.com": "216.239.36.10",
"ns4.google.com": "216.239.38.10",
},
)
mock.on("google.com.", dns.TypeNS, googleDelegation)
// NS IP resolution
mock.on("ns1.google.com.", dns.TypeA,
makeAResponse("ns1.google.com", "216.239.32.10"))
mock.on("ns2.google.com.", dns.TypeA,
makeAResponse("ns2.google.com", "216.239.34.10"))
mock.on("ns3.google.com.", dns.TypeA,
makeAResponse("ns3.google.com", "216.239.36.10"))
mock.on("ns4.google.com.", dns.TypeA,
makeAResponse("ns4.google.com", "216.239.38.10"))
// google.com records
mock.on("google.com.", dns.TypeA,
makeAResponse("google.com", "142.250.80.46"))
mock.on("google.com.", dns.TypeAAAA,
makeAAAAResponse("google.com", "2607:f8b0:4004:800::200e"))
mock.on("google.com.", dns.TypeMX,
makeMXResponse("google.com", map[uint16]string{
10: "smtp.google.com", 20: "smtp2.google.com",
}))
mock.on("google.com.", dns.TypeTXT,
makeTXTResponse("google.com",
"v=spf1 include:_spf.google.com ~all"))
mock.on("google.com.", dns.TypeSRV,
makeEmptyResponse("google.com.", dns.TypeSRV))
mock.on("google.com.", dns.TypeCAA,
makeEmptyResponse("google.com.", dns.TypeCAA))
// www.google.com records
mock.on("www.google.com.", dns.TypeA,
makeAResponse("www.google.com", "142.250.80.46"))
for _, qt := range []uint16{
dns.TypeAAAA, dns.TypeCNAME, dns.TypeMX,
dns.TypeTXT, dns.TypeSRV, dns.TypeCAA, dns.TypeNS,
} {
mock.on("www.google.com.", qt,
makeEmptyResponse("www.google.com.", qt))
}
// NXDOMAIN for nonexistent subdomain
nxName := "this-surely-does-not-exist-xyz.google.com."
for _, qt := range []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeCNAME,
dns.TypeMX, dns.TypeTXT, dns.TypeSRV,
dns.TypeCAA, dns.TypeNS,
} {
mock.on(nxName, qt, makeNXDomainResponse(nxName, qt))
}
}
func setupCloudflareMock(mock *mockDNSClient) {
cfNS := makeNSResponse(
"cloudflare.com.",
[]string{"ns3.cloudflare.com", "ns7.cloudflare.com"},
map[string]string{
"ns3.cloudflare.com": "162.159.0.33",
"ns7.cloudflare.com": "162.159.36.1",
},
)
mock.on("cloudflare.com.", dns.TypeNS, cfNS)
mock.on("ns3.cloudflare.com.", dns.TypeA,
makeAResponse("ns3.cloudflare.com", "162.159.0.33"))
mock.on("ns7.cloudflare.com.", dns.TypeA,
makeAResponse("ns7.cloudflare.com", "162.159.36.1"))
mock.on("cloudflare.com.", dns.TypeA,
makeAResponse("cloudflare.com",
"104.16.132.229", "104.16.133.229"))
mock.on("cloudflare.com.", dns.TypeAAAA,
makeAAAAResponse("cloudflare.com",
"2606:4700::6810:84e5", "2606:4700::6810:85e5"))
mock.on("cloudflare.com.", dns.TypeCNAME,
makeEmptyResponse("cloudflare.com.", dns.TypeCNAME))
mock.on("cloudflare.com.", dns.TypeMX,
makeEmptyResponse("cloudflare.com.", dns.TypeMX))
mock.on("cloudflare.com.", dns.TypeTXT,
makeTXTResponse("cloudflare.com",
"v=spf1 include:_spf.google.com ~all"))
mock.on("cloudflare.com.", dns.TypeSRV,
makeEmptyResponse("cloudflare.com.", dns.TypeSRV))
mock.on("cloudflare.com.", dns.TypeCAA,
makeEmptyResponse("cloudflare.com.", dns.TypeCAA))
}
func setupMockResolver(t *testing.T) *resolver.Resolver {
t.Helper()
mock := newMockClient()
// Root -> com. NS (authoritative answer style since mock
// cannot distinguish server addresses)
mock.on("com.", dns.TypeNS, makeNSResponse(
"com.",
[]string{"a.gtld-servers.net", "b.gtld-servers.net"},
map[string]string{
"a.gtld-servers.net": "192.5.6.30",
"b.gtld-servers.net": "192.33.14.30",
},
))
setupGoogleMock(mock)
setupCloudflareMock(mock)
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
return resolver.NewFromLoggerWithClient(log, mock)
}
func testContext(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithTimeout(
context.Background(), 10*time.Second,
)
t.Cleanup(cancel)
return ctx
}
// ----------------------------------------------------------------
// FindAuthoritativeNameservers tests
// ----------------------------------------------------------------
func TestFindAuthoritativeNameservers_ValidDomain(
t *testing.T,
) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
hasGoogleNS := false
for _, ns := range nameservers {
if strings.Contains(ns, "google") {
hasGoogleNS = true
break
}
}
assert.True(t, hasGoogleNS,
"expected google nameservers, got: %v", nameservers,
)
}
func TestFindAuthoritativeNameservers_Subdomain(
t *testing.T,
) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "www.google.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
}
func TestFindAuthoritativeNameservers_ReturnsSorted(
t *testing.T,
) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
assert.True(
t,
sort.StringsAreSorted(nameservers),
"nameservers should be sorted, got: %v", nameservers,
)
}
func TestFindAuthoritativeNameservers_Deterministic(
t *testing.T,
) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
first, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
second, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
assert.Equal(t, first, second)
}
func TestFindAuthoritativeNameservers_TrailingDot(
t *testing.T,
) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
ns1, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
ns2, err := r.FindAuthoritativeNameservers(
ctx, "google.com.",
)
require.NoError(t, err)
assert.Equal(t, ns1, ns2)
}
func TestFindAuthoritativeNameservers_CloudflareDomain(
t *testing.T,
) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "cloudflare.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
for _, ns := range nameservers {
assert.True(t, strings.HasSuffix(ns, "."),
"NS should be FQDN with trailing dot: %s", ns,
)
}
}
// ----------------------------------------------------------------
// QueryNameserver tests
// ----------------------------------------------------------------
func TestQueryNameserver_BasicA(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
resp, err := r.QueryNameserver(
ctx, "ns1.google.com.", "www.google.com",
)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, resolver.StatusOK, resp.Status)
assert.Equal(t, "ns1.google.com.", resp.Nameserver)
hasRecords := len(resp.Records["A"]) > 0 ||
len(resp.Records["CNAME"]) > 0
assert.True(t, hasRecords,
"expected A or CNAME records for www.google.com",
)
}
func TestQueryNameserver_AAAA(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
resp, err := r.QueryNameserver(
ctx, "ns3.cloudflare.com.", "cloudflare.com",
)
require.NoError(t, err)
aaaaRecords := resp.Records["AAAA"]
require.NotEmpty(t, aaaaRecords,
"cloudflare.com should have AAAA records",
)
for _, ip := range aaaaRecords {
parsed := net.ParseIP(ip)
require.NotNil(t, parsed,
"should be valid IP: %s", ip,
)
}
}
func TestQueryNameserver_MX(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
resp, err := r.QueryNameserver(
ctx, "ns1.google.com.", "google.com",
)
require.NoError(t, err)
mxRecords := resp.Records["MX"]
require.NotEmpty(t, mxRecords,
"google.com should have MX records",
)
}
func TestQueryNameserver_TXT(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
resp, err := r.QueryNameserver(
ctx, "ns1.google.com.", "google.com",
)
require.NoError(t, err)
txtRecords := resp.Records["TXT"]
require.NotEmpty(t, txtRecords,
"google.com should have TXT records",
)
hasSPF := false
for _, txt := range txtRecords {
if strings.Contains(txt, "v=spf1") {
hasSPF = true
break
}
}
assert.True(t, hasSPF,
"google.com should have SPF TXT record",
)
}
func TestQueryNameserver_NXDomain(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
resp, err := r.QueryNameserver(
ctx, "ns1.google.com.",
"this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
assert.Equal(t, resolver.StatusNXDomain, resp.Status)
}
func TestQueryNameserver_RecordsSorted(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
resp, err := r.QueryNameserver(
ctx, "ns1.google.com.", "google.com",
)
require.NoError(t, err)
for recordType, values := range resp.Records {
assert.True(
t,
sort.StringsAreSorted(values),
"%s records should be sorted", recordType,
)
}
}
func TestQueryNameserver_ResponseIncludesNameserver(
t *testing.T,
) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
ns := "ns3.cloudflare.com."
resp, err := r.QueryNameserver(
ctx, ns, "cloudflare.com",
)
require.NoError(t, err)
assert.Equal(t, ns, resp.Nameserver)
}
func TestQueryNameserver_EmptyRecordsOnNXDomain(
t *testing.T,
) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
resp, err := r.QueryNameserver(
ctx, "ns1.google.com.",
"this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
totalRecords := 0
for _, values := range resp.Records {
totalRecords += len(values)
}
assert.Zero(t, totalRecords)
}
func TestQueryNameserver_TrailingDotHandling(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
resp1, err := r.QueryNameserver(
ctx, "ns1.google.com.", "google.com",
)
require.NoError(t, err)
resp2, err := r.QueryNameserver(
ctx, "ns1.google.com.", "google.com.",
)
require.NoError(t, err)
assert.Equal(t, resp1.Status, resp2.Status)
}
// ----------------------------------------------------------------
// QueryAllNameservers tests
// ----------------------------------------------------------------
func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
results, err := r.QueryAllNameservers(
ctx, "google.com",
)
require.NoError(t, err)
require.NotEmpty(t, results)
assert.GreaterOrEqual(t, len(results), 2)
for ns, resp := range results {
assert.Equal(t, ns, resp.Nameserver)
}
}
func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
results, err := r.QueryAllNameservers(
ctx, "google.com",
)
require.NoError(t, err)
for ns, resp := range results {
assert.Equal(
t, resolver.StatusOK, resp.Status,
"NS %s should return OK", ns,
)
}
}
func TestQueryAllNameservers_NXDomainFromAllNS(
t *testing.T,
) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
results, err := r.QueryAllNameservers(
ctx,
"this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
for ns, resp := range results {
assert.Equal(
t, resolver.StatusNXDomain, resp.Status,
"NS %s should return nxdomain", ns,
)
}
}
// ----------------------------------------------------------------
// LookupNS tests
// ----------------------------------------------------------------
func TestLookupNS_ValidDomain(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
nameservers, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
require.NotEmpty(t, nameservers)
for _, ns := range nameservers {
assert.True(t, strings.HasSuffix(ns, "."),
"NS should have trailing dot: %s", ns,
)
}
}
func TestLookupNS_Sorted(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
nameservers, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
assert.True(t, sort.StringsAreSorted(nameservers))
}
func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
fromLookup, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
fromFind, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
assert.Equal(t, fromFind, fromLookup)
}
// ----------------------------------------------------------------
// ResolveIPAddresses tests
// ----------------------------------------------------------------
func TestResolveIPAddresses_ReturnsIPs(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err)
require.NotEmpty(t, ips)
for _, ip := range ips {
parsed := net.ParseIP(ip)
assert.NotNil(t, parsed,
"should be valid IP: %s", ip,
)
}
}
func TestResolveIPAddresses_Deduplicated(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err)
seen := make(map[string]bool)
for _, ip := range ips {
assert.False(t, seen[ip], "duplicate IP: %s", ip)
seen[ip] = true
}
}
func TestResolveIPAddresses_Sorted(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err)
assert.True(t, sort.StringsAreSorted(ips))
}
func TestResolveIPAddresses_NXDomainReturnsEmpty(
t *testing.T,
) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(
ctx,
"this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
assert.Empty(t, ips)
}
func TestResolveIPAddresses_CloudflareDomain(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "cloudflare.com")
require.NoError(t, err)
require.NotEmpty(t, ips)
}
// ----------------------------------------------------------------
// Context cancellation tests
// ----------------------------------------------------------------
func TestFindAuthoritativeNameservers_ContextCanceled(
t *testing.T,
) {
t.Parallel()
r := setupMockResolver(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := r.FindAuthoritativeNameservers(ctx, "google.com")
assert.Error(t, err)
}
func TestQueryNameserver_ContextCanceled(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := r.QueryNameserver(
ctx, "ns1.google.com.", "google.com",
)
assert.Error(t, err)
}
func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := r.QueryAllNameservers(ctx, "google.com")
assert.Error(t, err)
}
func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
t.Parallel()
r := setupMockResolver(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := r.ResolveIPAddresses(ctx, "google.com")
assert.Error(t, err)
}