Files
dnswatcher/internal/resolver/transport_test.go
T
clawbot 6b0fdc477a
check / check (push) Failing after 13s
test: restore transport-failure coverage with loopback nameservers
The DNS-mock removal deleted TestQueryNameserverIP_Timeout and left a
comment in its place, so the resolver's StatusTimeout / StatusError
classification branch went untested. The stated obstacle was that a
query to a black-holed RFC 5737 address comes back StatusOK, because
the build environment transparently intercepts UDP/53 and answers it
locally. That is a property of that environment, not of the resolver,
and it only rules out choosing a remote address.

internal/resolver/transport_test.go binds real nameservers on
127.0.0.1 instead and aims the query at them: one silent on A queries
and answering every other type (StatusTimeout), one answering SERVFAIL
(StatusError), and one address with nothing listening, which is
refused rather than dropped and so classifies as NoData. This is not a
mock — no DNSClient is substituted. The resolver dials a real socket,
writes a real query with the real miekg/dns client, and applies its
real deadline and real classification logic to what comes back.
Substituting the client is what TESTING.md bans; choosing the server
is not, and the resolver is aimed at a caller-chosen nameserver in
production too.

queryDNS now dials a nameserver address that already carries a port as
written, defaulting to 53 only for a bare address. That is what makes
a nameserver on any other port reachable, on loopback or otherwise.

Silence on one record type rather than all eight keeps the timeout
test to two query timeouts (4s) instead of sixteen (32s), and it is
asserted: the test fails if it ever costs more than 8s.

The watcher's assertStatePopulated and TestDomainPortAndTLSChecks
asserted only that hostname, port and certificate state were
non-empty, plus non-zero checker call counts. Neither was vacuous, but
neither would have caught the watcher resolving the wrong addresses.
The port and TLS test doubles now record their arguments, and both
tests assert that the state keys and the arguments the checkers were
actually called with match the addresses live DNS returned — exactly
those, no more and no fewer. Verified by mutation: making the watcher
drop all but one resolved address fails both tests, and it passed both
of them before.

TESTING.md records why a loopback nameserver is not a mock, so the new
tests are not mistaken for a violation of the rule they respect.
2026-09-03 22:58:01 +00:00

254 lines
7.0 KiB
Go

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)
}