2 Commits

Author SHA1 Message Date
da3c831a26 Merge remote-tracking branch 'origin/main' into feature/resolver
All checks were successful
check / check (push) Successful in 46s
# Conflicts:
#	internal/resolver/errors.go
#	internal/resolver/resolver.go
#	internal/resolver/resolver_test.go
2026-07-23 08:37:54 +07:00
483bed68a1 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.
2026-07-07 03:06:49 +02:00

View File

@@ -1,3 +1,10 @@
// Package resolver_test verifies the iterative resolver against
// live DNS. Per TESTING.md, all resolver tests use real queries
// against real nameservers — no mocking of the DNS client layer.
// Tests target long-lived public infrastructure (root servers,
// gTLD servers, IANA, Google, Cloudflare, GitHub) to minimize
// fixture churn, plus dedicated fixture records in the
// sneak.cloud zone.
package resolver_test
import (
@@ -10,13 +17,36 @@ import (
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/resolver"
)
// ----------------------------------------------------------------
// Test fixtures
// ----------------------------------------------------------------
// Fixture hostnames in the sneak.cloud zone (hosted on
// Cloudflare). These records must exist in the sneak.cloud 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
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"
)
// ----------------------------------------------------------------
// Test helpers
// ----------------------------------------------------------------
@@ -108,6 +138,35 @@ func TestFindAuthoritativeNameservers_Subdomain(
require.NotEmpty(t, nameservers)
}
func TestFindAuthoritativeNameservers_TLD(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers,
"should find TLD nameservers",
)
hasGTLD := false
for _, ns := range nameservers {
if strings.Contains(ns, "gtld-servers.net") {
hasGTLD = true
break
}
}
assert.True(t, hasGTLD,
"expected gtld-servers for com, got: %v", nameservers,
)
}
func TestFindAuthoritativeNameservers_ReturnsSorted(
t *testing.T,
) {
@@ -191,6 +250,68 @@ func TestFindAuthoritativeNameservers_CloudflareDomain(
}
}
func TestFindAuthoritativeNameservers_AllNSRespondAuthoritatively(
t *testing.T,
) {
// Every NS returned by FindAuthoritativeNameservers must
// actually serve the zone. A lame NS would return REFUSED,
// SERVFAIL, or a non-authoritative answer.
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
for _, ns := range nameservers {
resp, queryErr := r.QueryNameserver(
ctx, ns, "www.google.com",
)
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,
)
hasRecords := len(resp.Records["A"]) > 0 ||
len(resp.Records["CNAME"]) > 0
assert.True(t, hasRecords,
"NS %s should return records for www.google.com",
ns,
)
}
}
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)
}
// ----------------------------------------------------------------
// QueryNameserver tests
// ----------------------------------------------------------------
@@ -218,6 +339,28 @@ func TestQueryNameserver_BasicA(t *testing.T) {
)
}
func TestQueryNameserver_MultipleA(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, testDomain)
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()
@@ -243,6 +386,44 @@ func TestQueryNameserver_AAAA(t *testing.T) {
}
}
func TestQueryNameserver_DualStack(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, testDomain)
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 := findOneNSForDomain(t, r, ctx, testDomain)
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()
@@ -390,6 +571,116 @@ func TestQueryNameserver_TrailingDotHandling(t *testing.T) {
assert.Equal(t, resp1.Status, resp2.Status)
}
func TestQueryNameserver_NSRecordsForZoneApex(
t *testing.T,
) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "cloudflare.com")
// Querying a zone apex should return NS records.
resp, err := r.QueryNameserver(
ctx, ns, "cloudflare.com",
)
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",
)
hasCloudflare := false
for _, nsRecord := range nsRecords {
if strings.Contains(nsRecord, "cloudflare") {
hasCloudflare = true
break
}
}
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 := findOneNSForDomain(t, r, ctx, "google.com")
// A leaf hostname (not a zone cut) has no NS records.
resp, err := r.QueryNameserver(
ctx, ns, "www.google.com",
)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Empty(t, resp.Records["NS"],
"leaf hostname should not have NS records, got: %v",
resp.Records["NS"],
)
}
func TestQueryNameserver_LameNameserver(t *testing.T) {
// Querying a nameserver that does not serve a zone should
// return a non-ok status, not silently return empty results
// with status "ok". a.iana-servers.net serves example.com,
// not google.com.
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
resp, err := r.QueryNameserver(
ctx, "a.iana-servers.net.", "www.google.com",
)
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,
)
}
// ----------------------------------------------------------------
// QueryNameserverIP tests
// ----------------------------------------------------------------
func TestQueryNameserverIP_BasicA(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
// a.iana-servers.net (199.43.135.53) is authoritative for
// example.com; query it directly by IP.
resp, err := r.QueryNameserverIP(
ctx, "a.iana-servers.net.", "199.43.135.53",
"example.com",
)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, resolver.StatusOK, resp.Status)
assert.Equal(t, "a.iana-servers.net.", resp.Nameserver)
assert.NotEmpty(t, resp.Records["A"],
"example.com should have A records",
)
}
// ----------------------------------------------------------------
// QueryAllNameservers tests
// ----------------------------------------------------------------
@@ -432,6 +723,39 @@ func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
}
}
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,
) {
@@ -526,6 +850,80 @@ func TestResolveIPAddresses_ReturnsIPs(t *testing.T) {
}
}
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)
// www.github.com is a long-standing CNAME to github.com;
// resolution must follow the chain to A records.
ips, err := r.ResolveIPAddresses(ctx, "www.github.com")
require.NoError(t, err)
require.NotEmpty(t, ips)
}
func TestResolveIPAddresses_Deduplicated(t *testing.T) {
t.Parallel()
@@ -623,59 +1021,6 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
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()