test: restore transport-failure coverage with loopback nameservers
check / check (push) Failing after 1m58s
check / check (push) Failing after 1m58s
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.
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -53,6 +54,12 @@ const (
|
||||
longLife = 90 * 24 * time.Hour
|
||||
shortLife = 3 * 24 * time.Hour
|
||||
|
||||
// monitoredTestPorts and tlsCheckPort mirror the ports the
|
||||
// watcher checks. They are duplicated here deliberately: a
|
||||
// test that imported the production values would agree with
|
||||
// the watcher no matter which ports it drifted to.
|
||||
tlsCheckPort = 443
|
||||
|
||||
pollInterval = 100 * time.Millisecond
|
||||
scanTimeout = 25 * time.Second
|
||||
notifyGrace = 500 * time.Millisecond
|
||||
@@ -64,22 +71,31 @@ var errNotFound = errors.New("not found")
|
||||
|
||||
// --- Test doubles for non-DNS collaborators ---
|
||||
|
||||
// portCall records one CheckPort invocation. The arguments matter,
|
||||
// not just the count: a watcher that dutifully checks ports on the
|
||||
// wrong addresses produces exactly the same call count as one that
|
||||
// resolved correctly, so the tests assert what was checked.
|
||||
type portCall struct {
|
||||
address string
|
||||
port int
|
||||
}
|
||||
|
||||
type mockPortChecker struct {
|
||||
mu sync.Mutex
|
||||
openAll bool
|
||||
err error
|
||||
calls int
|
||||
seen []portCall
|
||||
}
|
||||
|
||||
func (m *mockPortChecker) CheckPort(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
_ int,
|
||||
address string,
|
||||
port int,
|
||||
) (*portcheck.PortResult, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.calls++
|
||||
m.seen = append(m.seen, portCall{address: address, port: port})
|
||||
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
@@ -95,29 +111,48 @@ func (m *mockPortChecker) setOpenAll(open bool) {
|
||||
m.openAll = open
|
||||
}
|
||||
|
||||
func (m *mockPortChecker) callCount() int {
|
||||
// checkedKeys returns the distinct "address:port" pairs the checker
|
||||
// was asked about, sorted, in the same form as the state store's
|
||||
// port keys so the two can be compared directly.
|
||||
func (m *mockPortChecker) checkedKeys() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
return m.calls
|
||||
keys := make([]string, 0, len(m.seen))
|
||||
for _, c := range m.seen {
|
||||
keys = append(
|
||||
keys, fmt.Sprintf("%s:%d", c.address, c.port),
|
||||
)
|
||||
}
|
||||
|
||||
return sortedSet(keys)
|
||||
}
|
||||
|
||||
// tlsCall records one CheckCertificate invocation, for the same
|
||||
// reason portCall records CheckPort's arguments.
|
||||
type tlsCall struct {
|
||||
address string
|
||||
hostname string
|
||||
}
|
||||
|
||||
type mockTLSChecker struct {
|
||||
mu sync.Mutex
|
||||
cert *tlscheck.CertificateInfo
|
||||
err error
|
||||
calls int
|
||||
mu sync.Mutex
|
||||
cert *tlscheck.CertificateInfo
|
||||
err error
|
||||
seen []tlsCall
|
||||
}
|
||||
|
||||
func (m *mockTLSChecker) CheckCertificate(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
address string,
|
||||
hostname string,
|
||||
) (*tlscheck.CertificateInfo, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.calls++
|
||||
m.seen = append(
|
||||
m.seen, tlsCall{address: address, hostname: hostname},
|
||||
)
|
||||
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
@@ -139,11 +174,21 @@ func (m *mockTLSChecker) setCert(cert *tlscheck.CertificateInfo) {
|
||||
m.cert = cert
|
||||
}
|
||||
|
||||
func (m *mockTLSChecker) callCount() int {
|
||||
// checkedKeys returns the distinct certificate keys the checker was
|
||||
// asked about, sorted, in the state store's "address:port:hostname"
|
||||
// form so the two can be compared directly.
|
||||
func (m *mockTLSChecker) checkedKeys() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
return m.calls
|
||||
keys := make([]string, 0, len(m.seen))
|
||||
for _, c := range m.seen {
|
||||
keys = append(keys, fmt.Sprintf(
|
||||
"%s:%d:%s", c.address, tlsCheckPort, c.hostname,
|
||||
))
|
||||
}
|
||||
|
||||
return sortedSet(keys)
|
||||
}
|
||||
|
||||
type notification struct {
|
||||
@@ -367,6 +412,116 @@ func liveIPs(snap state.Snapshot, hostname string) []string {
|
||||
return ips
|
||||
}
|
||||
|
||||
// monitoredTestPorts is the set of ports the watcher is expected to
|
||||
// check on every resolved address.
|
||||
func monitoredTestPorts() []int {
|
||||
return []int{80, tlsCheckPort}
|
||||
}
|
||||
|
||||
// sortedSet deduplicates and sorts, so that call records and state
|
||||
// keys can be compared as sets.
|
||||
func sortedSet(values []string) []string {
|
||||
seen := make(map[string]bool, len(values))
|
||||
|
||||
out := make([]string, 0, len(values))
|
||||
|
||||
for _, v := range values {
|
||||
if seen[v] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[v] = true
|
||||
|
||||
out = append(out, v)
|
||||
}
|
||||
|
||||
sort.Strings(out)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// watchedNames is every name the config puts under observation.
|
||||
// Domains get hostname state too, for their port and TLS checks.
|
||||
func watchedNames(cfg *config.Config) []string {
|
||||
names := make([]string, 0, len(cfg.Hostnames)+len(cfg.Domains))
|
||||
names = append(names, cfg.Hostnames...)
|
||||
names = append(names, cfg.Domains...)
|
||||
|
||||
return sortedSet(names)
|
||||
}
|
||||
|
||||
// stateHostnames is the set of names the run actually recorded.
|
||||
func stateHostnames(snap state.Snapshot) []string {
|
||||
names := make([]string, 0, len(snap.Hostnames))
|
||||
for name := range snap.Hostnames {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
return sortedSet(names)
|
||||
}
|
||||
|
||||
// expectedPortKeys is every "address:port" the watcher should have
|
||||
// touched: the monitored ports on every address live DNS resolved
|
||||
// for every watched name, and nothing else.
|
||||
func expectedPortKeys(
|
||||
snap state.Snapshot,
|
||||
names []string,
|
||||
) []string {
|
||||
var keys []string
|
||||
|
||||
for _, name := range names {
|
||||
for _, ip := range liveIPs(snap, name) {
|
||||
for _, port := range monitoredTestPorts() {
|
||||
keys = append(
|
||||
keys, fmt.Sprintf("%s:%d", ip, port),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortedSet(keys)
|
||||
}
|
||||
|
||||
// expectedCertKeys is every "address:port:hostname" the watcher
|
||||
// should have a certificate for, given that every port reports open.
|
||||
func expectedCertKeys(
|
||||
snap state.Snapshot,
|
||||
names []string,
|
||||
) []string {
|
||||
var keys []string
|
||||
|
||||
for _, name := range names {
|
||||
for _, ip := range liveIPs(snap, name) {
|
||||
keys = append(keys, fmt.Sprintf(
|
||||
"%s:%d:%s", ip, tlsCheckPort, name,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return sortedSet(keys)
|
||||
}
|
||||
|
||||
// stateKeys sorts a state map's keys for set comparison.
|
||||
func stateKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
return sortedSet(keys)
|
||||
}
|
||||
|
||||
// assertSameSet fails with both sides named when they differ.
|
||||
func assertSameSet(t *testing.T, what string, want, got []string) {
|
||||
t.Helper()
|
||||
|
||||
if !slices.Equal(want, got) {
|
||||
t.Errorf(
|
||||
"%s: expected %v, got %v", what, want, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestFirstRunBaseline(t *testing.T) {
|
||||
@@ -415,14 +570,31 @@ func assertStatePopulated(
|
||||
)
|
||||
}
|
||||
|
||||
// Hostnames includes both explicit hostnames and domains
|
||||
// (domains now also get hostname state for port/TLS checks).
|
||||
if len(snap.Hostnames) < 1 {
|
||||
t.Errorf(
|
||||
"expected at least 1 hostname in state, got %d",
|
||||
len(snap.Hostnames),
|
||||
)
|
||||
// Hostname state covers both explicit hostnames and domains
|
||||
// (domains also get hostname state for port/TLS checks), and
|
||||
// covers exactly those: a name in state that nothing asked
|
||||
// for, or a configured name missing from it, is a bug.
|
||||
names := watchedNames(deps.config)
|
||||
assertSameSet(
|
||||
t, "hostnames in state", names, stateHostnames(snap),
|
||||
)
|
||||
|
||||
// Every watched name must have resolved to something, or the
|
||||
// key comparisons below would pass vacuously on empty sets.
|
||||
for _, name := range names {
|
||||
if len(liveIPs(snap, name)) == 0 {
|
||||
t.Errorf("no addresses resolved for %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The addresses the port checker was aimed at must be the
|
||||
// addresses live DNS returned. Counting calls would not
|
||||
// notice the watcher checking the wrong hosts.
|
||||
assertSameSet(
|
||||
t, "ports checked",
|
||||
expectedPortKeys(snap, names),
|
||||
deps.portChecker.checkedKeys(),
|
||||
)
|
||||
}
|
||||
|
||||
func TestDomainPortAndTLSChecks(t *testing.T) {
|
||||
@@ -438,24 +610,34 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
|
||||
|
||||
snap := deps.state.GetSnapshot()
|
||||
|
||||
// The domain resolved via live DNS should have port state
|
||||
// populated for its real addresses.
|
||||
if len(snap.Ports) == 0 {
|
||||
t.Error("expected port state for domain, got none")
|
||||
names := watchedNames(cfg)
|
||||
|
||||
ips := liveIPs(snap, testDomain)
|
||||
if len(ips) == 0 {
|
||||
t.Fatal("live DNS resolved no addresses for " + testDomain)
|
||||
}
|
||||
|
||||
// Domain should have certificate state populated.
|
||||
if len(snap.Certificates) == 0 {
|
||||
t.Error("expected certificate state for domain, got none")
|
||||
}
|
||||
// Port and certificate state must be keyed by the addresses
|
||||
// live DNS actually returned — every one of them, and no
|
||||
// others. Asserting only that the maps are non-empty would
|
||||
// hold just as well if the watcher had resolved the wrong
|
||||
// name or dropped all but one of its addresses.
|
||||
wantPorts := expectedPortKeys(snap, names)
|
||||
assertSameSet(t, "port state", wantPorts, stateKeys(snap.Ports))
|
||||
assertSameSet(
|
||||
t, "ports checked", wantPorts,
|
||||
deps.portChecker.checkedKeys(),
|
||||
)
|
||||
|
||||
if deps.portChecker.callCount() == 0 {
|
||||
t.Error("expected port checker to be called for domain")
|
||||
}
|
||||
|
||||
if deps.tlsChecker.callCount() == 0 {
|
||||
t.Error("expected TLS checker to be called for domain")
|
||||
}
|
||||
wantCerts := expectedCertKeys(snap, names)
|
||||
assertSameSet(
|
||||
t, "certificate state", wantCerts,
|
||||
stateKeys(snap.Certificates),
|
||||
)
|
||||
assertSameSet(
|
||||
t, "certificates checked", wantCerts,
|
||||
deps.tlsChecker.checkedKeys(),
|
||||
)
|
||||
}
|
||||
|
||||
func TestNSChangeDetection(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user