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) } 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)) }) } } // TestCollectAnswerRecords_CNAMEOnce collects the answers a nameserver // gives for a name with a CNAME, one for each record type a check asks // for. Each answer holds the CNAME, which must be stored once. func TestCollectAnswerRecords_CNAMEOnce(t *testing.T) { t.Parallel() cname := &dns.CNAME{ Hdr: dns.RR_Header{ Name: "git.eeqj.de.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, }, Target: "fsn1app1.datavi.be.", } resp := &resolver.NameserverResponse{Records: map[string][]string{}} for _, qtype := range []uint16{ dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, dns.TypeMX, dns.TypeTXT, dns.TypeSRV, dns.TypeCAA, dns.TypeNS, } { msg := new(dns.Msg) msg.SetQuestion("git.eeqj.de.", qtype) msg.Answer = []dns.RR{cname} resolver.CollectAnswerRecords(msg, resp) } assert.Equal(t, map[string][]string{"CNAME": {"fsn1app1.datavi.be."}}, resp.Records, ) } // 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) }