This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
package resolver_test
|
||||
|
||||
// Transport-failure classification tests.
|
||||
//
|
||||
// These are live tests, not mocks. Nothing here substitutes the
|
||||
// resolver's DNSClient: the resolver dials a real UDP socket, writes
|
||||
// a real DNS query with the real miekg/dns client, and applies its
|
||||
// real deadline and its real classification logic to what comes
|
||||
// back. The only thing under test control is which address the query
|
||||
// is sent to, and what — if anything — is listening there.
|
||||
//
|
||||
// That distinction is what the no-DNS-mocks rule in TESTING.md is
|
||||
// about. A fake DNSClient lets the code under test skip DNS entirely
|
||||
// and hands it a manufactured verdict; a nameserver bound on
|
||||
// loopback makes it speak DNS for real and earn one. Pointing a live
|
||||
// query at a nameserver of the test's choosing is no more a mock
|
||||
// than pointing it at a.root-servers.net.
|
||||
//
|
||||
// The public network cannot produce these outcomes on demand. A
|
||||
// black-holed address is not black-holed everywhere — build
|
||||
// environments that intercept UDP/53 answer it locally — so a test
|
||||
// built on one asserts on the network it happens to run on rather
|
||||
// than on the resolver. A loopback nameserver is deterministic
|
||||
// everywhere, and it is fast, because the test picks the deadline.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/resolver"
|
||||
)
|
||||
|
||||
const (
|
||||
// transportBudget is the wall time the timeout test must stay
|
||||
// under. The resolver asks a nameserver for eight record types
|
||||
// in turn and retries each one once, so a nameserver silent on
|
||||
// every type would cost sixteen query timeouts. The test's
|
||||
// nameserver is silent on exactly one type, which costs two,
|
||||
// and this budget fails loudly if that ever stops being true.
|
||||
transportBudget = 8 * time.Second
|
||||
|
||||
// transportDeadline is the caller deadline the tests run
|
||||
// under. It is generous on purpose: these tests are about the
|
||||
// resolver classifying a nameserver's behaviour, so the
|
||||
// caller's deadline must never be the thing that expires.
|
||||
transportDeadline = 30 * time.Second
|
||||
|
||||
// silentNS and failingNS are the nameserver names reported
|
||||
// back in NameserverResponse.Nameserver. They are .test names
|
||||
// (RFC 6761) and are never resolved: the tests address the
|
||||
// nameserver by its socket address.
|
||||
silentNS = "silent.ns.test."
|
||||
failingNS = "servfail.ns.test."
|
||||
|
||||
// transportHostname is the name queried. Nothing resolves it;
|
||||
// the point is entirely how the nameserver behaves.
|
||||
transportHostname = "example.com"
|
||||
)
|
||||
|
||||
// startNameserver binds a real UDP nameserver on loopback and serves
|
||||
// every datagram it receives with handle, which returns the reply to
|
||||
// send or nil to stay silent. It returns the "host:port" address to
|
||||
// aim a query at, and stops the server when the test ends.
|
||||
func startNameserver(
|
||||
t *testing.T,
|
||||
handle func(query *dns.Msg) *dns.Msg,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "binding loopback nameserver")
|
||||
|
||||
stopped := make(chan struct{})
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = conn.Close()
|
||||
<-stopped
|
||||
})
|
||||
|
||||
go serveNameserver(conn, handle, stopped)
|
||||
|
||||
return conn.LocalAddr().String()
|
||||
}
|
||||
|
||||
// serveNameserver reads queries until conn is closed, replying with
|
||||
// whatever handle produces.
|
||||
func serveNameserver(
|
||||
conn net.PacketConn,
|
||||
handle func(query *dns.Msg) *dns.Msg,
|
||||
stopped chan<- struct{},
|
||||
) {
|
||||
defer close(stopped)
|
||||
|
||||
buf := make([]byte, dns.MaxMsgSize)
|
||||
|
||||
for {
|
||||
n, from, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
query := new(dns.Msg)
|
||||
if query.Unpack(buf[:n]) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
reply := handle(query)
|
||||
if reply == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
wire, err := reply.Pack()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := conn.WriteTo(wire, from); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unservedAddr returns a loopback address with nothing listening on
|
||||
// it, by binding a port and releasing it again.
|
||||
func unservedAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "binding loopback port")
|
||||
|
||||
addr := conn.LocalAddr().String()
|
||||
require.NoError(t, conn.Close(), "releasing loopback port")
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
// TestQueryNameserverIP_Timeout covers the StatusTimeout branch: a
|
||||
// nameserver that takes the query and never answers.
|
||||
func TestQueryNameserverIP_Timeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A real nameserver that drops A queries and answers every
|
||||
// other type. Silence on one type is all the resolver needs to
|
||||
// classify the response as a timeout, and it keeps the test
|
||||
// two query timeouts long instead of sixteen.
|
||||
addr := startNameserver(t, func(query *dns.Msg) *dns.Msg {
|
||||
if len(query.Question) > 0 &&
|
||||
query.Question[0].Qtype == dns.TypeA {
|
||||
return nil
|
||||
}
|
||||
|
||||
reply := new(dns.Msg)
|
||||
reply.SetReply(query)
|
||||
|
||||
return reply
|
||||
})
|
||||
|
||||
r := newTestResolver(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(), transportDeadline,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
resp, err := r.QueryNameserverIP(
|
||||
ctx, silentNS, addr, transportHostname,
|
||||
)
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
|
||||
assert.Equal(t, resolver.StatusTimeout, resp.Status)
|
||||
assert.Equal(t, "all queries timed out", resp.Error)
|
||||
assert.Empty(t, resp.Records)
|
||||
assert.Equal(t, silentNS, resp.Nameserver)
|
||||
|
||||
assert.Less(
|
||||
t, elapsed, transportBudget,
|
||||
"one silent record type must cost one query's retries, "+
|
||||
"not every record type's",
|
||||
)
|
||||
}
|
||||
|
||||
// TestQueryNameserverIP_ServFail covers the StatusError branch: a
|
||||
// nameserver that answers, and answers SERVFAIL.
|
||||
func TestQueryNameserverIP_ServFail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
addr := startNameserver(t, func(query *dns.Msg) *dns.Msg {
|
||||
reply := new(dns.Msg)
|
||||
reply.SetRcode(query, dns.RcodeServerFailure)
|
||||
|
||||
return reply
|
||||
})
|
||||
|
||||
r := newTestResolver(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(), transportDeadline,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
resp, err := r.QueryNameserverIP(
|
||||
ctx, failingNS, addr, transportHostname,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
|
||||
assert.Equal(t, resolver.StatusError, resp.Status)
|
||||
assert.Equal(t, "server returned SERVFAIL", resp.Error)
|
||||
assert.Empty(t, resp.Records)
|
||||
assert.Equal(t, failingNS, resp.Nameserver)
|
||||
}
|
||||
|
||||
// TestQueryNameserverIP_NoListener pins the third transport outcome:
|
||||
// a refused datagram is not a timeout. The socket fails immediately
|
||||
// with ECONNREFUSED rather than going quiet, so isTimeout is false,
|
||||
// no failure flag is set, and the response classifies as NoData.
|
||||
// Asserting it here is what stops that path being mistaken for the
|
||||
// timeout path, in either direction.
|
||||
func TestQueryNameserverIP_NoListener(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
addr := unservedAddr(t)
|
||||
|
||||
r := newTestResolver(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(
|
||||
context.Background(), transportDeadline,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
resp, err := r.QueryNameserverIP(
|
||||
ctx, silentNS, addr, transportHostname,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
|
||||
assert.Equal(t, resolver.StatusNoData, resp.Status)
|
||||
assert.Empty(t, resp.Records)
|
||||
}
|
||||
Reference in New Issue
Block a user