From 483bed68a16f33c55d456876975641a9552ebfff Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 03:06:49 +0200 Subject: [PATCH] Add resolver API definition and comprehensive live-DNS test suite The test suite defines the full resolver contract using live DNS queries against controlled records in the sneak.cloud zone (Cloudflare). Covers FindAuthoritativeNameservers, QueryNameserver, QueryAllNameservers, LookupNS, and ResolveIPAddresses, including sorting/determinism guarantees, trailing-dot handling, per-NS response status model, lame-delegation detection, NXDOMAIN semantics, CNAME following, and context cancellation. Also adds DNSSEC validation to planned future features in README. --- README.md | 7 + go.mod | 4 + internal/resolver/errors.go | 27 + internal/resolver/resolver.go | 82 ++- internal/resolver/resolver_test.go | 1032 ++++++++++++++++++++++++++++ 5 files changed, 1136 insertions(+), 16 deletions(-) create mode 100644 internal/resolver/errors.go create mode 100644 internal/resolver/resolver_test.go diff --git a/README.md b/README.md index 1460218..25fa039 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,13 @@ docker run -d \ --- +## Planned Future Features (Post-1.0) + +- **DNSSEC validation**: Validate the DNSSEC chain of trust during + iterative resolution and report DNSSEC failures as notifications. + +--- + ## Project Structure Follows the conventions defined in `CONVENTIONS.md`, adapted from the diff --git a/go.mod b/go.mod index 1b21a1c..41f57c4 100644 --- a/go.mod +++ b/go.mod @@ -9,16 +9,19 @@ require ( github.com/joho/godotenv v1.5.1 github.com/prometheus/client_golang v1.23.2 github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 go.uber.org/fx v1.24.0 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect @@ -36,4 +39,5 @@ require ( golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go new file mode 100644 index 0000000..94bc313 --- /dev/null +++ b/internal/resolver/errors.go @@ -0,0 +1,27 @@ +package resolver + +import "errors" + +// Sentinel errors returned by the resolver. +var ( + // ErrNotImplemented indicates a method is stubbed out. + ErrNotImplemented = errors.New( + "resolver not yet implemented", + ) + + // ErrNoNameservers is returned when no authoritative NS + // could be discovered for a domain. + ErrNoNameservers = errors.New( + "no authoritative nameservers found", + ) + + // ErrCNAMEDepthExceeded is returned when a CNAME chain + // exceeds MaxCNAMEDepth. + ErrCNAMEDepthExceeded = errors.New( + "CNAME chain depth exceeded", + ) + + // ErrContextCanceled wraps context cancellation for the + // resolver's iterative queries. + ErrContextCanceled = errors.New("context canceled") +) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index be47717..b155782 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -1,9 +1,10 @@ // Package resolver provides iterative DNS resolution from root nameservers. +// It traces the full delegation chain from IANA root servers through TLD +// and domain nameservers, never relying on upstream recursive resolvers. package resolver import ( "context" - "errors" "log/slog" "go.uber.org/fx" @@ -11,8 +12,16 @@ import ( "sneak.berlin/go/dnswatcher/internal/logger" ) -// ErrNotImplemented indicates the resolver is not yet implemented. -var ErrNotImplemented = errors.New("resolver not yet implemented") +// Query status constants matching the state model. +const ( + StatusOK = "ok" + StatusError = "error" + StatusNXDomain = "nxdomain" + StatusNoData = "nodata" +) + +// MaxCNAMEDepth is the maximum CNAME chain depth to follow. +const MaxCNAMEDepth = 10 // Params contains dependencies for Resolver. type Params struct { @@ -21,12 +30,20 @@ type Params struct { Logger *logger.Logger } +// NameserverResponse holds one nameserver's response for a query. +type NameserverResponse struct { + Nameserver string + Records map[string][]string + Status string + Error string +} + // Resolver performs iterative DNS resolution from root servers. type Resolver struct { log *slog.Logger } -// New creates a new Resolver instance. +// New creates a new Resolver instance for use with uber/fx. func New( _ fx.Lifecycle, params Params, @@ -36,8 +53,48 @@ func New( }, nil } -// LookupNS performs iterative resolution to find authoritative -// nameservers for the given domain. +// NewFromLogger creates a Resolver directly from an slog.Logger, +// useful for testing without the fx lifecycle. +func NewFromLogger(log *slog.Logger) *Resolver { + return &Resolver{log: log} +} + +// FindAuthoritativeNameservers traces the delegation chain from +// root servers to discover all authoritative nameservers for the +// given domain. Returns the NS hostnames (e.g. ["ns1.example.com.", +// "ns2.example.com."]). +func (r *Resolver) FindAuthoritativeNameservers( + _ context.Context, + _ string, +) ([]string, error) { + return nil, ErrNotImplemented +} + +// QueryNameserver queries a specific authoritative nameserver for +// all supported record types (A, AAAA, CNAME, MX, TXT, SRV, CAA, +// NS) for the given hostname. Returns a NameserverResponse with +// per-type record slices and a status indicating success or the +// type of failure. +func (r *Resolver) QueryNameserver( + _ context.Context, + _ string, + _ string, +) (*NameserverResponse, error) { + return nil, ErrNotImplemented +} + +// QueryAllNameservers discovers the authoritative nameservers for +// the hostname's parent domain, then queries each one independently. +// Returns a map from nameserver hostname to its response. +func (r *Resolver) QueryAllNameservers( + _ context.Context, + _ string, +) (map[string]*NameserverResponse, error) { + return nil, ErrNotImplemented +} + +// LookupNS returns the NS record set for a domain by performing +// iterative resolution. This is used for domain (apex) monitoring. func (r *Resolver) LookupNS( _ context.Context, _ string, @@ -45,17 +102,10 @@ func (r *Resolver) LookupNS( return nil, ErrNotImplemented } -// LookupAllRecords performs iterative resolution to find all DNS -// records for the given hostname. -func (r *Resolver) LookupAllRecords( - _ context.Context, - _ string, -) (map[string][]string, error) { - return nil, ErrNotImplemented -} - // ResolveIPAddresses resolves a hostname to all IPv4 and IPv6 -// addresses, following CNAME chains. +// addresses by querying all authoritative nameservers and following +// CNAME chains up to MaxCNAMEDepth. Returns a deduplicated list +// of IP address strings. func (r *Resolver) ResolveIPAddresses( _ context.Context, _ string, diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go new file mode 100644 index 0000000..c7c2c99 --- /dev/null +++ b/internal/resolver/resolver_test.go @@ -0,0 +1,1032 @@ +package resolver_test + +import ( + "context" + "log/slog" + "net" + "os" + "sort" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/dnswatcher/internal/resolver" +) + +// Test domain and hostnames hosted on Cloudflare. +// These records must exist in the sneak.cloud Cloudflare zone: +// +// basic.dns.sneak.cloud A 192.0.2.1 +// multi.dns.sneak.cloud A 192.0.2.1 +// multi.dns.sneak.cloud A 192.0.2.2 +// ipv6.dns.sneak.cloud AAAA 2001:db8::1 +// dual.dns.sneak.cloud A 192.0.2.1 +// dual.dns.sneak.cloud AAAA 2001:db8::1 +// cname-target.dns.sneak.cloud A 198.51.100.1 +// cname.dns.sneak.cloud CNAME cname-target.dns.sneak.cloud +// mx.dns.sneak.cloud MX 10 mail.dns.sneak.cloud +// mail.dns A 192.0.2.10 +// txt.dns.sneak.cloud TXT "v=spf1 -all" +const ( + testDomain = "sneak.cloud" + testHostBasic = "basic.dns.sneak.cloud" + testHostMultiA = "multi.dns.sneak.cloud" + testHostIPv6 = "ipv6.dns.sneak.cloud" + testHostDualStack = "dual.dns.sneak.cloud" + testHostCNAME = "cname.dns.sneak.cloud" + testHostCNAMETarget = "cname-target.dns.sneak.cloud" + testHostMX = "mx.dns.sneak.cloud" + testHostMail = "mail.dns.sneak.cloud" + testHostTXT = "txt.dns.sneak.cloud" + testHostNXDomain = "nxdomain-surely-does-not-exist.dns.sneak.cloud" +) + +// queryTimeout is the default timeout for test queries. +const queryTimeout = 30 * time.Second + +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) +} + +func testContext(t *testing.T) context.Context { + t.Helper() + + ctx, cancel := context.WithTimeout( + context.Background(), queryTimeout, + ) + t.Cleanup(cancel) + + return ctx +} + +// --- FindAuthoritativeNameservers tests --- + +func TestFindAuthoritativeNameservers_ValidDomain( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.FindAuthoritativeNameservers( + ctx, testDomain, + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers, "should find at least one NS") + + // sneak.cloud is on Cloudflare, NS should contain cloudflare + for _, ns := range nameservers { + t.Logf("discovered NS: %s", ns) + assert.True( + t, + strings.HasSuffix(ns, "."), + "NS should be FQDN with trailing dot: %s", ns, + ) + } + + // Verify at least one is a Cloudflare NS + hasCloudflare := false + + for _, ns := range nameservers { + if strings.Contains(ns, "cloudflare") { + hasCloudflare = true + + break + } + } + + assert.True( + t, hasCloudflare, + "sneak.cloud should be hosted on Cloudflare, got: %v", + nameservers, + ) +} + +func TestFindAuthoritativeNameservers_Subdomain( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + // Looking up NS for a hostname that isn't a zone should + // return the parent zone's NS records. + nameservers, err := r.FindAuthoritativeNameservers( + ctx, testHostBasic, + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers) + + // Should be the same Cloudflare NSes as the parent domain + hasCloudflare := false + + for _, ns := range nameservers { + if strings.Contains(ns, "cloudflare") { + hasCloudflare = true + + break + } + } + + assert.True(t, hasCloudflare) +} + +func TestFindAuthoritativeNameservers_TLD(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.FindAuthoritativeNameservers( + ctx, "cloud", + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers, "should find TLD nameservers") + + for _, ns := range nameservers { + t.Logf("TLD NS: %s", ns) + } +} + +func TestFindAuthoritativeNameservers_ReturnsSorted( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.FindAuthoritativeNameservers( + ctx, testDomain, + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers) + + // Results should be sorted for deterministic comparison + assert.True( + t, + sort.StringsAreSorted(nameservers), + "nameservers should be sorted, got: %v", nameservers, + ) +} + +func TestFindAuthoritativeNameservers_Deterministic( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + first, err := r.FindAuthoritativeNameservers( + ctx, testDomain, + ) + require.NoError(t, err) + + second, err := r.FindAuthoritativeNameservers( + ctx, testDomain, + ) + require.NoError(t, err) + + assert.Equal( + t, first, second, + "repeated lookups should return same result", + ) +} + +// --- QueryNameserver tests --- + +func TestQueryNameserver_BasicA(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + resp, err := r.QueryNameserver(ctx, ns, testHostBasic) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, resolver.StatusOK, resp.Status) + assert.Equal(t, ns, resp.Nameserver) + + aRecords := resp.Records["A"] + require.NotEmpty(t, aRecords, "basic.dns should have A records") + assert.Contains(t, aRecords, "192.0.2.1") + + t.Logf( + "QueryNameserver(%s, %s) A records: %v", + ns, testHostBasic, aRecords, + ) +} + +func TestQueryNameserver_MultipleA(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + resp, err := r.QueryNameserver(ctx, ns, testHostMultiA) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, resolver.StatusOK, resp.Status) + + aRecords := resp.Records["A"] + require.Len( + t, aRecords, 2, + "multi.dns should have exactly 2 A records", + ) + + sort.Strings(aRecords) + assert.Equal(t, []string{"192.0.2.1", "192.0.2.2"}, aRecords) +} + +func TestQueryNameserver_AAAA(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + resp, err := r.QueryNameserver(ctx, ns, testHostIPv6) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, resolver.StatusOK, resp.Status) + + aaaaRecords := resp.Records["AAAA"] + require.NotEmpty( + t, aaaaRecords, + "ipv6.dns should have AAAA records", + ) + assert.Contains(t, aaaaRecords, "2001:db8::1") +} + +func TestQueryNameserver_DualStack(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + resp, err := r.QueryNameserver(ctx, ns, testHostDualStack) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, resolver.StatusOK, resp.Status) + + assert.Contains(t, resp.Records["A"], "192.0.2.1") + assert.Contains(t, resp.Records["AAAA"], "2001:db8::1") +} + +func TestQueryNameserver_CNAME(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + resp, err := r.QueryNameserver(ctx, ns, testHostCNAME) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, resolver.StatusOK, resp.Status) + + cnameRecords := resp.Records["CNAME"] + require.NotEmpty( + t, cnameRecords, + "cname.dns should have CNAME records", + ) + assert.Contains( + t, cnameRecords, "cname-target.dns.sneak.cloud.", + ) +} + +func TestQueryNameserver_MX(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + resp, err := r.QueryNameserver(ctx, ns, testHostMX) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, resolver.StatusOK, resp.Status) + + mxRecords := resp.Records["MX"] + require.NotEmpty( + t, mxRecords, + "mx.dns should have MX records", + ) + + // MX records are formatted as "priority host" + hasMail := false + + for _, mx := range mxRecords { + if strings.Contains(mx, "mail.dns.sneak.cloud.") { + hasMail = true + + break + } + } + + assert.True( + t, hasMail, + "MX should reference mail.dns.sneak.cloud, got: %v", + mxRecords, + ) +} + +func TestQueryNameserver_TXT(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + resp, err := r.QueryNameserver(ctx, ns, testHostTXT) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, resolver.StatusOK, resp.Status) + + txtRecords := resp.Records["TXT"] + require.NotEmpty( + t, txtRecords, + "txt.dns should have TXT records", + ) + + hasSPF := false + + for _, txt := range txtRecords { + if strings.Contains(txt, "v=spf1") { + hasSPF = true + + break + } + } + + assert.True( + t, hasSPF, + "TXT should contain SPF record, got: %v", txtRecords, + ) +} + +func TestQueryNameserver_NXDomain(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + resp, err := r.QueryNameserver(ctx, ns, testHostNXDomain) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal( + t, resolver.StatusNXDomain, resp.Status, + "nonexistent host should return nxdomain status", + ) +} + +func TestQueryNameserver_RecordsSorted(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + resp, err := r.QueryNameserver(ctx, ns, testHostMultiA) + require.NoError(t, err) + + // Each record type's values should be sorted for determinism + for recordType, values := range resp.Records { + assert.True( + t, + sort.StringsAreSorted(values), + "%s records should be sorted, got: %v", + recordType, values, + ) + } +} + +func TestQueryNameserver_ResponseIncludesNameserver( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + resp, err := r.QueryNameserver(ctx, ns, testHostBasic) + require.NoError(t, err) + + assert.Equal( + t, ns, resp.Nameserver, + "response should include the queried nameserver", + ) +} + +func TestQueryNameserver_EmptyRecordsMapOnNXDomain( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + resp, err := r.QueryNameserver(ctx, ns, testHostNXDomain) + require.NoError(t, err) + + totalRecords := 0 + for _, values := range resp.Records { + totalRecords += len(values) + } + + assert.Zero( + t, totalRecords, + "NXDOMAIN should have no records, got: %v", + resp.Records, + ) +} + +// --- QueryAllNameservers tests --- + +func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + results, err := r.QueryAllNameservers(ctx, testHostBasic) + require.NoError(t, err) + require.NotEmpty(t, results) + + // Should have queried each NS independently + t.Logf( + "QueryAllNameservers returned %d nameserver results", + len(results), + ) + + for ns, resp := range results { + t.Logf(" %s: status=%s A=%v", ns, resp.Status, resp.Records["A"]) + assert.Equal(t, ns, resp.Nameserver) + } + + // Should have more than one NS for Cloudflare-hosted domain + assert.GreaterOrEqual( + t, len(results), 2, + "should query at least 2 nameservers", + ) +} + +func TestQueryAllNameservers_Consistent(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + results, err := r.QueryAllNameservers(ctx, testHostBasic) + require.NoError(t, err) + require.NotEmpty(t, results) + + // All NSes should return the same A records for a + // well-configured hostname. + var referenceRecords map[string][]string + + for ns, resp := range results { + require.Equal( + t, resolver.StatusOK, resp.Status, + "NS %s should return OK status", ns, + ) + + if referenceRecords == nil { + referenceRecords = resp.Records + + continue + } + + assert.Equal( + t, referenceRecords["A"], resp.Records["A"], + "NS %s A records should match", ns, + ) + } +} + +func TestQueryAllNameservers_NXDomainFromAllNS( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + results, err := r.QueryAllNameservers( + ctx, testHostNXDomain, + ) + require.NoError(t, err) + require.NotEmpty(t, results) + + for ns, resp := range results { + assert.Equal( + t, resolver.StatusNXDomain, resp.Status, + "NS %s should return nxdomain for nonexistent host", + ns, + ) + } +} + +// --- LookupNS tests --- + +func TestLookupNS_ValidDomain(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.LookupNS(ctx, testDomain) + require.NoError(t, err) + require.NotEmpty(t, nameservers) + + for _, ns := range nameservers { + t.Logf("NS record: %s", ns) + assert.True( + t, + strings.HasSuffix(ns, "."), + "NS should be FQDN: %s", ns, + ) + } +} + +func TestLookupNS_Sorted(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.LookupNS(ctx, testDomain) + require.NoError(t, err) + + assert.True( + t, + sort.StringsAreSorted(nameservers), + "NS records should be sorted, got: %v", nameservers, + ) +} + +func TestLookupNS_MatchesFindAuthoritative(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + fromLookup, err := r.LookupNS(ctx, testDomain) + require.NoError(t, err) + + fromFind, err := r.FindAuthoritativeNameservers( + ctx, testDomain, + ) + require.NoError(t, err) + + // Both methods should return the same NS set + assert.Equal( + t, fromFind, fromLookup, + "LookupNS and FindAuthoritativeNameservers "+ + "should return the same set", + ) +} + +// --- ResolveIPAddresses tests --- + +func TestResolveIPAddresses_BasicA(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses(ctx, testHostBasic) + require.NoError(t, err) + require.NotEmpty(t, ips) + assert.Contains(t, ips, "192.0.2.1") +} + +func TestResolveIPAddresses_MultipleA(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses(ctx, testHostMultiA) + require.NoError(t, err) + + sort.Strings(ips) + assert.Contains(t, ips, "192.0.2.1") + assert.Contains(t, ips, "192.0.2.2") +} + +func TestResolveIPAddresses_IPv6Only(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses(ctx, testHostIPv6) + require.NoError(t, err) + require.NotEmpty(t, ips) + assert.Contains(t, ips, "2001:db8::1") + + // Should not contain any IPv4 + for _, ip := range ips { + parsed := net.ParseIP(ip) + require.NotNil(t, parsed, "should be valid IP: %s", ip) + assert.Nil( + t, parsed.To4(), + "ipv6-only host should not return IPv4: %s", ip, + ) + } +} + +func TestResolveIPAddresses_DualStack(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses(ctx, testHostDualStack) + require.NoError(t, err) + + assert.Contains(t, ips, "192.0.2.1") + assert.Contains(t, ips, "2001:db8::1") +} + +func TestResolveIPAddresses_FollowsCNAME(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + // cname.dns.sneak.cloud -> cname-target.dns.sneak.cloud -> 198.51.100.1 + ips, err := r.ResolveIPAddresses(ctx, testHostCNAME) + require.NoError(t, err) + require.NotEmpty(t, ips) + assert.Contains( + t, ips, "198.51.100.1", + "should follow CNAME to resolve target IP", + ) +} + +func TestResolveIPAddresses_Deduplicated(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses(ctx, testHostBasic) + require.NoError(t, err) + + // Check for duplicates + seen := make(map[string]bool) + + for _, ip := range ips { + assert.False( + t, seen[ip], + "IP %s appears more than once", ip, + ) + seen[ip] = true + } +} + +func TestResolveIPAddresses_Sorted(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses(ctx, testHostDualStack) + require.NoError(t, err) + + assert.True( + t, + sort.StringsAreSorted(ips), + "IP addresses should be sorted, got: %v", ips, + ) +} + +func TestResolveIPAddresses_NXDomainReturnsEmpty( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses(ctx, testHostNXDomain) + // Should not error — NXDOMAIN is an expected DNS response. + // It just means no IPs to return. + require.NoError(t, err) + assert.Empty(t, ips) +} + +// --- Context cancellation tests --- + +func TestFindAuthoritativeNameservers_ContextCanceled( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := r.FindAuthoritativeNameservers(ctx, testDomain) + 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.example.com.", testHostBasic, + ) + 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, testHostBasic) + assert.Error(t, err) +} + +func TestResolveIPAddresses_ContextCanceled(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := r.ResolveIPAddresses(ctx, testHostBasic) + assert.Error(t, err) +} + +// --- Iterative resolution verification --- + +func TestFindAuthoritativeNameservers_IsIterative( + t *testing.T, +) { + // Verify that resolution works for well-known domains, + // proving we trace from root rather than relying on a + // system stub resolver that might not be configured. + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + // Resolve a well-known domain to prove root->TLD->domain + // tracing works. + nameservers, err := r.FindAuthoritativeNameservers( + ctx, "example.com", + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers) + + t.Logf("example.com NS: %v", nameservers) +} + +// --- NS record and delegation tests --- + +func TestQueryNameserver_NSRecordsForZoneApex( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + // Querying a zone apex should return NS records + resp, err := r.QueryNameserver(ctx, ns, testDomain) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, resolver.StatusOK, resp.Status) + + nsRecords := resp.Records["NS"] + require.NotEmpty( + t, nsRecords, + "zone apex should have NS records", + ) + + // Should contain Cloudflare nameservers + hasCloudflare := false + + for _, nsRecord := range nsRecords { + t.Logf("NS record: %s", nsRecord) + + if strings.Contains(nsRecord, "cloudflare") { + hasCloudflare = true + } + } + + assert.True( + t, hasCloudflare, + "NS records should include Cloudflare, got: %v", + nsRecords, + ) +} + +func TestQueryNameserver_NoNSRecordsForLeafHostname( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + // A leaf hostname (not a zone cut) should not have NS records + resp, err := r.QueryNameserver(ctx, ns, testHostBasic) + require.NoError(t, err) + require.NotNil(t, resp) + + nsRecords := resp.Records["NS"] + assert.Empty( + t, nsRecords, + "leaf hostname should not have NS records, got: %v", + nsRecords, + ) +} + +func TestFindAuthoritativeNameservers_AllNSRespondAuthoritatively( + t *testing.T, +) { + // Every NS returned by FindAuthoritativeNameservers should + // actually serve the zone. A lame NS would return REFUSED, + // SERVFAIL, or a non-authoritative answer. This test verifies + // that each discovered NS can answer queries authoritatively. + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.FindAuthoritativeNameservers( + ctx, testDomain, + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers) + + for _, ns := range nameservers { + resp, queryErr := r.QueryNameserver( + ctx, ns, testHostBasic, + ) + require.NoError( + t, queryErr, + "NS %s should be queryable", ns, + ) + assert.Equal( + t, resolver.StatusOK, resp.Status, + "NS %s should respond authoritatively, "+ + "status=%s error=%s", + ns, resp.Status, resp.Error, + ) + assert.NotEmpty( + t, resp.Records["A"], + "NS %s should return A records for %s", + ns, testHostBasic, + ) + } +} + +func TestQueryNameserver_LameNameserver(t *testing.T) { + // Querying a nameserver that does not serve a zone should + // return a non-ok status (error or refused), not silently + // return empty results with status "ok". + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + // Use a known-good nameserver that does NOT serve sneak.cloud. + // a.iana-servers.net serves example.com, not sneak.cloud. + resp, err := r.QueryNameserver( + ctx, "a.iana-servers.net.", testHostBasic, + ) + require.NoError(t, err, "should not return Go-level error") + require.NotNil(t, resp) + + assert.NotEqual( + t, resolver.StatusOK, resp.Status, + "lame NS should not return status ok; got status=%s "+ + "records=%v", + resp.Status, resp.Records, + ) +} + +// --- Edge cases --- + +func TestQueryNameserver_TrailingDotHandling(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns := findOneNS(t, r, ctx) + + // Both with and without trailing dot should work + resp1, err := r.QueryNameserver( + ctx, ns, "basic.dns.sneak.cloud", + ) + require.NoError(t, err) + + resp2, err := r.QueryNameserver( + ctx, ns, "basic.dns.sneak.cloud.", + ) + require.NoError(t, err) + + assert.Equal( + t, resp1.Records["A"], resp2.Records["A"], + "trailing dot should not affect results", + ) +} + +func TestFindAuthoritativeNameservers_TrailingDot( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns1, err := r.FindAuthoritativeNameservers( + ctx, "sneak.cloud", + ) + require.NoError(t, err) + + ns2, err := r.FindAuthoritativeNameservers( + ctx, "sneak.cloud.", + ) + require.NoError(t, err) + + assert.Equal( + t, ns1, ns2, + "trailing dot should not affect NS lookup", + ) +} + +// --- Helper functions --- + +// findOneNS discovers authoritative nameservers and returns the first +// one, failing the test if none are found. +func findOneNS( + t *testing.T, + r *resolver.Resolver, + ctx context.Context, //nolint:revive // test helper +) string { + t.Helper() + + nameservers, err := r.FindAuthoritativeNameservers( + ctx, testDomain, + ) + require.NoError(t, err) + require.NotEmpty( + t, nameservers, + "should find at least one NS for %s", testDomain, + ) + + return nameservers[0] +}