package resolver_test import ( "context" "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" ) // ---------------------------------------------------------------- // Test helpers // ---------------------------------------------------------------- func newTestResolver(t *testing.T) *resolver.Resolver { t.Helper() log := slog.New(slog.NewTextHandler( os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}, )) return resolver.NewFromLogger(log) } // findOneNSForDomain picks one authoritative nameserver to aim a // test at. Live-DNS retry, concurrency and quorum handling live in // livedns_test.go. func findOneNSForDomain( t *testing.T, r *resolver.Resolver, domain string, ) string { t.Helper() return liveFindAuthoritative(t, r, domain)[0] } // ---------------------------------------------------------------- // FindAuthoritativeNameservers tests // ---------------------------------------------------------------- func TestFindAuthoritativeNameservers_ValidDomain( t *testing.T, ) { t.Parallel() r := newTestResolver(t) nameservers := liveFindAuthoritative(t, r, "google.com") 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 := newTestResolver(t) nameservers := liveFindAuthoritative(t, r, "www.google.com") assert.NotEmpty(t, nameservers) } func TestFindAuthoritativeNameservers_ReturnsSorted( t *testing.T, ) { t.Parallel() r := newTestResolver(t) nameservers := liveFindAuthoritative(t, r, "google.com") assert.True( t, sort.StringsAreSorted(nameservers), "nameservers should be sorted, got: %v", nameservers, ) } func TestFindAuthoritativeNameservers_Deterministic( t *testing.T, ) { t.Parallel() r := newTestResolver(t) first := liveFindAuthoritative(t, r, "google.com") second := liveFindAuthoritative(t, r, "google.com") assert.Equal(t, first, second) } func TestFindAuthoritativeNameservers_TrailingDot( t *testing.T, ) { t.Parallel() r := newTestResolver(t) ns1 := liveFindAuthoritative(t, r, "google.com") ns2 := liveFindAuthoritative(t, r, "google.com.") assert.Equal(t, ns1, ns2) } func TestFindAuthoritativeNameservers_CloudflareDomain( t *testing.T, ) { t.Parallel() r := newTestResolver(t) nameservers := liveFindAuthoritative(t, r, "cloudflare.com") 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 := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver(t, r, ns, "www.google.com") require.NotNil(t, resp) assert.Equal(t, resolver.StatusOK, resp.Status) assert.Equal(t, ns, 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 := newTestResolver(t) ns := findOneNSForDomain(t, r, "cloudflare.com") resp := liveQueryNameserver(t, r, ns, "cloudflare.com") 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 := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver(t, r, ns, "google.com") mxRecords := resp.Records["MX"] require.NotEmpty(t, mxRecords, "google.com should have MX records", ) } func TestQueryNameserver_TXT(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver(t, r, ns, "google.com") 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 := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver( t, r, ns, "this-surely-does-not-exist-xyz.google.com", ) assert.Equal(t, resolver.StatusNXDomain, resp.Status) } func TestQueryNameserver_RecordsSorted(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver(t, r, ns, "google.com") 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 := newTestResolver(t) ns := findOneNSForDomain(t, r, "cloudflare.com") resp := liveQueryNameserver(t, r, ns, "cloudflare.com") assert.Equal(t, ns, resp.Nameserver) } func TestQueryNameserver_EmptyRecordsOnNXDomain( t *testing.T, ) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp := liveQueryNameserver( t, r, ns, "this-surely-does-not-exist-xyz.google.com", ) totalRecords := 0 for _, values := range resp.Records { totalRecords += len(values) } assert.Zero(t, totalRecords) } func TestQueryNameserver_TrailingDotHandling(t *testing.T) { t.Parallel() r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") resp1 := liveQueryNameserver(t, r, ns, "google.com") resp2 := liveQueryNameserver(t, r, ns, "google.com.") assert.Equal(t, resp1.Status, resp2.Status) } // ---------------------------------------------------------------- // QueryAllNameservers tests // ---------------------------------------------------------------- func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) { t.Parallel() r := newTestResolver(t) results := liveQueryAllNameservers(t, r, "google.com") assert.GreaterOrEqual(t, len(results), minNameservers) for ns, resp := range results { assert.Equal(t, ns, resp.Nameserver) } } func TestQueryAllNameservers_AllReturnOK(t *testing.T) { t.Parallel() r := newTestResolver(t) results := liveQueryAllNameservers(t, r, "google.com") // A quorum, not unanimity: one authoritative server being // slow or rate-limiting us is a property of the live // internet, not a resolver defect. assert.GreaterOrEqual( t, countStatus(results, resolver.StatusOK), liveQuorum(len(results)), "a quorum of nameservers should answer OK: %s", describeStatuses(results), ) // Any nameserver claiming google.com does not exist is a // real failure and is never tolerated. assert.Zero( t, countStatus(results, resolver.StatusNXDomain), "no nameserver should report NXDOMAIN: %s", describeStatuses(results), ) } func TestQueryAllNameservers_NXDomainFromAllNS( t *testing.T, ) { t.Parallel() r := newTestResolver(t) results := liveQueryAllNameservers( t, r, "this-surely-does-not-exist-xyz.google.com", ) assert.GreaterOrEqual( t, countStatus(results, resolver.StatusNXDomain), liveQuorum(len(results)), "a quorum of nameservers should report NXDOMAIN: %s", describeStatuses(results), ) // Silence is tolerated; a positive answer for a name that // does not exist is not. assert.Zero( t, countStatus(results, resolver.StatusOK), "no nameserver should answer OK: %s", describeStatuses(results), ) } // ---------------------------------------------------------------- // LookupNS tests // ---------------------------------------------------------------- func TestLookupNS_ValidDomain(t *testing.T) { t.Parallel() r := newTestResolver(t) nameservers := liveLookupNS(t, r, "google.com") 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 := newTestResolver(t) nameservers := liveLookupNS(t, r, "google.com") assert.True(t, sort.StringsAreSorted(nameservers)) } func TestLookupNS_MatchesFindAuthoritative(t *testing.T) { t.Parallel() r := newTestResolver(t) fromLookup := liveLookupNS(t, r, "google.com") fromFind := liveFindAuthoritative(t, r, "google.com") assert.Equal(t, fromFind, fromLookup) } // ---------------------------------------------------------------- // ResolveIPAddresses tests // ---------------------------------------------------------------- func TestResolveIPAddresses_ReturnsIPs(t *testing.T) { t.Parallel() r := newTestResolver(t) ips := liveResolveIPs(t, r, "google.com") 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 := newTestResolver(t) ips := liveResolveIPs(t, r, "google.com") 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 := newTestResolver(t) ips := liveResolveIPs(t, r, "google.com") assert.True(t, sort.StringsAreSorted(ips)) } func TestResolveIPAddresses_NXDomainReturnsEmpty( t *testing.T, ) { t.Parallel() r := newTestResolver(t) ips := liveResolveIPsAllowingEmpty( t, r, "this-surely-does-not-exist-xyz.google.com", ) assert.Empty(t, ips) } func TestResolveIPAddresses_CloudflareDomain(t *testing.T) { t.Parallel() r := newTestResolver(t) ips := liveResolveIPs(t, r, "cloudflare.com") assert.NotEmpty(t, ips) } // ---------------------------------------------------------------- // Context cancellation tests // ---------------------------------------------------------------- func TestFindAuthoritativeNameservers_ContextCanceled( t *testing.T, ) { t.Parallel() r := newTestResolver(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 := newTestResolver(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 := newTestResolver(t) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := r.QueryAllNameservers(ctx, "google.com") assert.Error(t, err) } // ---------------------------------------------------------------- // Timeout tests // ---------------------------------------------------------------- func TestQueryNameserverIP_Timeout(t *testing.T) { t.Parallel() log := slog.New(slog.NewTextHandler( os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}, )) r := resolver.NewFromLoggerWithClient( log, &timeoutClient{}, ) ctx, cancel := context.WithTimeout( context.Background(), 10*time.Second, ) t.Cleanup(cancel) // Query any IP — the client always returns a timeout error. resp, err := r.QueryNameserverIP( ctx, "unreachable.test.", "192.0.2.1", "example.com", ) require.NoError(t, err) assert.Equal(t, resolver.StatusTimeout, resp.Status) assert.NotEmpty(t, resp.Error) } // timeoutClient simulates DNS timeout errors for testing. type timeoutClient struct{} func (c *timeoutClient) ExchangeContext( _ context.Context, _ *dns.Msg, _ string, ) (*dns.Msg, time.Duration, error) { return nil, 0, &net.OpError{ Op: "read", Net: "udp", Err: &timeoutError{}, } } type timeoutError struct{} func (e *timeoutError) Error() string { return "i/o timeout" } func (e *timeoutError) Timeout() bool { return true } func (e *timeoutError) Temporary() bool { return true } func TestResolveIPAddresses_ContextCanceled(t *testing.T) { t.Parallel() r := newTestResolver(t) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := r.ResolveIPAddresses(ctx, "google.com") assert.Error(t, err) }