Files
dnswatcher/internal/resolver/iterative_test.go
T
sneak 2d9910cb14
check / check (push) Canceled after 0s
resolver, watcher: a record type whose query fails keeps its previous records (closes #231)
The resolver lists in FailedTypes each record type whose query to a
nameserver got no usable reply (none after two tries, an error reply, a
referral, or a truncated reply whose TCP retry failed), and logs it with
the reason. A nameserver that answered no type has failed, as before.
The watcher saves such a type in failedTypes, keeping the previous
check's records, leaves it out of the comparison with other nameservers
on that check, and compares the kept records with the next answer. With
nothing to keep, it is also in unknownTypes and not compared until it
answers. Change messages leave out what was not compared. A nameserver
whose A, AAAA or CNAME query failed is no answer when following a CNAME
or resolving addresses.

Model: opus-5-5
2026-10-02 06:55:24 +00:00

286 lines
7.0 KiB
Go

package resolver_test
import (
"math/rand/v2"
"slices"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/resolver"
)
// TestCollectIPs_OneAnswerIsEnough checks that one nameserver answering
// NXDOMAIN says the name has no addresses, though the other timed out.
func TestCollectIPs_OneAnswerIsEnough(t *testing.T) {
t.Parallel()
ips, _, err := resolver.CollectIPs(
map[string]*resolver.NameserverResponse{
"ns1.example.": {Status: resolver.StatusTimeout},
"ns2.example.": {Status: resolver.StatusNXDomain},
},
)
require.NoError(t, err)
assert.Empty(t, ips)
}
// TestCollectIPs_FailedIsNoAnswer checks that nameservers that all have
// status error, from a refusal, a server failure, a network error or a
// referral, are no answer rather than a name with no addresses.
func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
t.Parallel()
ips, _, err := resolver.CollectIPs(
map[string]*resolver.NameserverResponse{
"ns1.example.": {Status: resolver.StatusError},
"ns2.example.": {Status: resolver.StatusError},
},
)
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
assert.Empty(t, ips)
}
// TestCollectIPs_FailedTypeIsNoAnswer checks that a nameserver whose
// query for one of the types failed is no answer: its addresses are
// only part of them.
func TestCollectIPs_FailedTypeIsNoAnswer(t *testing.T) {
t.Parallel()
ips, _, err := resolver.CollectIPs(
map[string]*resolver.NameserverResponse{
nsExample1: {
Records: map[string][]string{"A": {"192.0.2.1"}},
FailedTypes: []string{"AAAA"},
Status: resolver.StatusOK,
},
},
)
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
assert.Empty(t, ips)
}
const (
// exampleCom is the zone most cases of TestUsableReply and
// TestNSSetFrom are about, and wwwExampleCom a name in it.
exampleCom = "example.com."
wwwExampleCom = "www.example.com."
// exampleNS is the server the NS records nsRecord builds name.
exampleNS = "ns1.example.net."
)
// nsRecord builds an NS record that names a server of zone.
func nsRecord(zone string) *dns.NS {
return &dns.NS{
Hdr: dns.RR_Header{
Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET,
},
Ns: exampleNS,
}
}
// referralTo builds a reply that refers the query to the servers of
// zone.
func referralTo(zone string) *dns.Msg {
msg := new(dns.Msg)
msg.Ns = []dns.RR{nsRecord(zone)}
return msg
}
// TestUsableReply checks which replies from one of a zone's servers are
// used. A reply that is not usable moves the query on to the zone's
// next server.
func TestUsableReply(t *testing.T) {
t.Parallel()
servfail := new(dns.Msg)
servfail.Rcode = dns.RcodeServerFailure
answer := new(dns.Msg)
answer.Authoritative = true
answer.Answer = []dns.RR{nsRecord(exampleCom)}
nxdomain := new(dns.Msg)
nxdomain.Authoritative = true
nxdomain.Rcode = dns.RcodeNameError
tests := []struct {
name string
resp *dns.Msg
zone string
query string
want bool
}{
{
name: "SERVFAIL", resp: servfail,
zone: exampleCom, query: exampleCom, want: false,
},
{
name: "answer", resp: answer,
zone: exampleCom, query: exampleCom, want: true,
},
{
name: "NXDOMAIN", resp: nxdomain,
zone: ".", query: exampleCom, want: true,
},
{
name: "root refers to com", resp: referralTo("com."),
zone: ".", query: exampleCom, want: true,
},
{
name: "com refers to example.com", resp: referralTo(exampleCom),
zone: "com.", query: wwwExampleCom, want: true,
},
{
name: "referral back to the zone", resp: referralTo(exampleCom),
zone: exampleCom, query: exampleCom, want: false,
},
{
name: "referral up to the root", resp: referralTo("."),
zone: exampleCom, query: exampleCom, want: false,
},
{
name: "referral sideways", resp: referralTo("net."),
zone: ".", query: exampleCom, want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want,
resolver.UsableReply(tt.resp, tt.zone, tt.query),
)
})
}
}
// TestNSSetFrom checks which NS set a reply gives for a domain; a set
// that is not empty ends the walk. The referral to example.com that
// com's servers all send alike gives its delegation, so the set is the
// same whichever of them answered, and example.com's own servers, which
// can disagree, are not asked.
func TestNSSetFrom(t *testing.T) {
t.Parallel()
answer := new(dns.Msg)
answer.Authoritative = true
answer.Answer = []dns.RR{nsRecord(exampleCom)}
tests := []struct {
name string
resp *dns.Msg
domain string
want []string
}{
{
name: "com refers to example.com", resp: referralTo(exampleCom),
domain: exampleCom, want: []string{exampleNS},
},
{
name: "com refers on, for www.example.com",
resp: referralTo(exampleCom), domain: wwwExampleCom,
want: nil,
},
{
name: "answer from a server that holds example.com",
resp: answer, domain: exampleCom,
want: []string{exampleNS},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.ElementsMatch(t, tt.want,
resolver.NSSetFrom(tt.resp, tt.domain),
)
})
}
}
func TestExtractRecordValue_LetterCase(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rr dns.RR
want string
}{
{
name: "MX target lower-cased",
rr: &dns.MX{Preference: 1, Mx: "ASPMX.L.GOOGLE.COM."},
want: "1 aspmx.l.google.com.",
},
{
name: "NS target lower-cased",
rr: &dns.NS{Ns: "x.ns.joker.COM."},
want: "x.ns.joker.com.",
},
{
name: "CNAME target lower-cased",
rr: &dns.CNAME{Target: "WWW.Example.Com."},
want: "www.example.com.",
},
{
name: "SRV target lower-cased",
rr: &dns.SRV{
Priority: 10, Weight: 5, Port: 443,
Target: "SIP.Example.Com.",
},
want: "10 5 443 sip.example.com.",
},
{
name: "TXT value keeps its case",
rr: &dns.TXT{Txt: []string{"Verify=AbC123"}},
want: "Verify=AbC123",
},
{
name: "CAA value keeps its case",
rr: &dns.CAA{Flag: 0, Tag: "issue", Value: "LetsEncrypt.org"},
want: `0 issue "LetsEncrypt.org"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, resolver.ExtractRecordValue(tt.rr))
})
}
}
// TestShuffled shuffles the root servers with many seeds. Every order
// must hold each root server once, so each is tried before a
// resolution fails; each root server must come first for some seed, so
// no one root server gets every first query; and the list passed in
// must be left as it was.
func TestShuffled(t *testing.T) {
t.Parallel()
const seeds = 1000
roots := resolver.RootServerList()
before := slices.Clone(roots)
first := make(map[string]bool)
for seed := range uint64(seeds) {
rng := rand.New(rand.NewPCG(seed, 0)) //nolint:gosec // seeded on purpose
order := resolver.Shuffled(roots, rng.Shuffle)
assert.ElementsMatch(t, roots, order)
first[order[0]] = true
}
assert.Len(t, first, len(roots))
assert.Equal(t, before, roots)
}