check / check (push) Successful in 1m21s
The watcher tests used a stand-in resolver and the resolver timeout test a stand-in DNS client, against the rule that DNS is never mocked. Watcher tests that look something up in DNS now run the real resolver against live servers, each attempt on a new watcher. A DNS change is tested by saving values live DNS never returns (names under .invalid, 192.0.2.1) in the state a check starts from, or by marking a real nameserver failed. The timeout test queries 192.0.2.1, where nothing answers. The live-DNS retry and concurrency limit moved from the resolver tests to internal/livedns, so both packages share them. NewFromLoggerWithClient had no other use and is gone. TESTING.md now states the README's rule. Model: opus-5-5
386 lines
8.6 KiB
Go
386 lines
8.6 KiB
Go
package resolver_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/dnswatcher/internal/livedns"
|
|
"sneak.berlin/go/dnswatcher/internal/resolver"
|
|
)
|
|
|
|
// ----------------------------------------------------------------
|
|
// Live DNS test support
|
|
// ----------------------------------------------------------------
|
|
//
|
|
// Tests that look something up in DNS query live DNS servers, never a
|
|
// stand-in; logic that works on record data may be tested on that
|
|
// data with no lookup (see TESTING.md). Each live operation below goes
|
|
// through livedns.Retry, which bounds how many resolutions are in
|
|
// flight at once and retries an operation that got no answer (see
|
|
// package livedns).
|
|
//
|
|
// Where an assertion spans several independent nameservers, a quorum
|
|
// is enough: a strict majority answering as expected. A server that
|
|
// fails to answer is tolerated, while a server that answers *wrongly*
|
|
// still fails the test.
|
|
//
|
|
// That tolerance is expressed as an ALLOWLIST of sanctioned statuses,
|
|
// never as a blocklist of known-bad ones. A blocklist bans the one
|
|
// wrong answer its author thought of and silently admits every other
|
|
// status, including any added to the resolver later; an allowlist
|
|
// fails on anything nobody explicitly sanctioned. Silence (timeout,
|
|
// error) is the only thing quorum exists to tolerate. A *wrong
|
|
// answer* — nxdomain for a name that exists, ok for one that does
|
|
// not, nodata for either — is never tolerated at any count.
|
|
|
|
// minNameservers is the smallest nameserver count a well-run zone is
|
|
// expected to publish.
|
|
const minNameservers = 2
|
|
|
|
// errLiveNoQuorum reports that too few of a domain's nameservers
|
|
// answered for a quorum assertion to be made.
|
|
var errLiveNoQuorum = errors.New("no nameserver quorum")
|
|
|
|
// liveQuorum is how many of total nameservers must agree for a
|
|
// multi-nameserver assertion to hold: a strict majority.
|
|
func liveQuorum(total int) int {
|
|
if total < 1 {
|
|
return 1
|
|
}
|
|
|
|
return total/2 + 1
|
|
}
|
|
|
|
// countStatus counts the responses carrying the given status.
|
|
func countStatus(
|
|
results map[string]*resolver.NameserverResponse,
|
|
status string,
|
|
) int {
|
|
n := 0
|
|
|
|
for _, resp := range results {
|
|
if resp.Status == status {
|
|
n++
|
|
}
|
|
}
|
|
|
|
return n
|
|
}
|
|
|
|
// liveAnswerStatuses is the closed set of statuses that count as a
|
|
// nameserver having ANSWERED at all, whether or not the test agrees
|
|
// with the answer. It is deliberately an allowlist: a status added
|
|
// to the resolver later is treated as silence, so it can only ever
|
|
// cause a retry and then a loud failure, never a quiet pass.
|
|
func liveAnswerStatuses() []string {
|
|
return []string{
|
|
resolver.StatusOK,
|
|
resolver.StatusNXDomain,
|
|
resolver.StatusNoData,
|
|
}
|
|
}
|
|
|
|
// answeredCount counts the nameservers that produced an answer of
|
|
// any kind, as opposed to failing or timing out.
|
|
func answeredCount(
|
|
results map[string]*resolver.NameserverResponse,
|
|
) int {
|
|
answers := liveAnswerStatuses()
|
|
|
|
n := 0
|
|
|
|
for _, resp := range results {
|
|
if slices.Contains(answers, resp.Status) {
|
|
n++
|
|
}
|
|
}
|
|
|
|
return n
|
|
}
|
|
|
|
// unsanctionedStatuses returns "nameserver=status" for every result
|
|
// whose status the caller did not explicitly sanction, sorted for a
|
|
// stable failure message. Callers pass the full closed set they will
|
|
// accept — the expected answer plus whichever non-answers (timeout,
|
|
// error) quorum is allowed to tolerate — so that any status outside
|
|
// it fails the test by name.
|
|
func unsanctionedStatuses(
|
|
results map[string]*resolver.NameserverResponse,
|
|
allowed ...string,
|
|
) []string {
|
|
offenders := make([]string, 0, len(results))
|
|
|
|
for ns, resp := range results {
|
|
if slices.Contains(allowed, resp.Status) {
|
|
continue
|
|
}
|
|
|
|
offenders = append(
|
|
offenders, fmt.Sprintf("%s=%s", ns, resp.Status),
|
|
)
|
|
}
|
|
|
|
sort.Strings(offenders)
|
|
|
|
return offenders
|
|
}
|
|
|
|
// describeStatuses renders per-nameserver statuses for use in
|
|
// assertion failure messages.
|
|
func describeStatuses(
|
|
results map[string]*resolver.NameserverResponse,
|
|
) string {
|
|
parts := make([]string, 0, len(results))
|
|
for ns, resp := range results {
|
|
parts = append(
|
|
parts, fmt.Sprintf("%s=%s", ns, resp.Status),
|
|
)
|
|
}
|
|
|
|
sort.Strings(parts)
|
|
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Live operation wrappers
|
|
// ----------------------------------------------------------------
|
|
|
|
// liveFindAuthoritative resolves a domain's authoritative
|
|
// nameservers, retrying until the delegation chain can be walked.
|
|
func liveFindAuthoritative(
|
|
t *testing.T,
|
|
r *resolver.Resolver,
|
|
domain string,
|
|
) []string {
|
|
t.Helper()
|
|
|
|
var out []string
|
|
|
|
livedns.Retry(
|
|
t,
|
|
"FindAuthoritativeNameservers("+domain+")",
|
|
func(ctx context.Context) error {
|
|
ns, err := r.FindAuthoritativeNameservers(ctx, domain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(ns) == 0 {
|
|
return fmt.Errorf(
|
|
"%w: %s has no nameservers",
|
|
livedns.ErrNoAnswer, domain,
|
|
)
|
|
}
|
|
|
|
out = ns
|
|
|
|
return nil
|
|
},
|
|
)
|
|
|
|
return out
|
|
}
|
|
|
|
// liveLookupNS is liveFindAuthoritative through the LookupNS entry
|
|
// point, so that both entry points stay independently exercised.
|
|
func liveLookupNS(
|
|
t *testing.T,
|
|
r *resolver.Resolver,
|
|
domain string,
|
|
) []string {
|
|
t.Helper()
|
|
|
|
var out []string
|
|
|
|
livedns.Retry(
|
|
t,
|
|
"LookupNS("+domain+")",
|
|
func(ctx context.Context) error {
|
|
ns, err := r.LookupNS(ctx, domain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(ns) == 0 {
|
|
return fmt.Errorf(
|
|
"%w: %s has no nameservers",
|
|
livedns.ErrNoAnswer, domain,
|
|
)
|
|
}
|
|
|
|
out = ns
|
|
|
|
return nil
|
|
},
|
|
)
|
|
|
|
return out
|
|
}
|
|
|
|
// liveQueryNameserver queries one nameserver, retrying while that
|
|
// nameserver fails to answer. NXDOMAIN and NODATA are answers and
|
|
// are returned to the caller to assert on.
|
|
func liveQueryNameserver(
|
|
t *testing.T,
|
|
r *resolver.Resolver,
|
|
nameserver string,
|
|
hostname string,
|
|
) *resolver.NameserverResponse {
|
|
t.Helper()
|
|
|
|
what := fmt.Sprintf(
|
|
"QueryNameserver(%s, %s)", nameserver, hostname,
|
|
)
|
|
|
|
var out *resolver.NameserverResponse
|
|
|
|
livedns.Retry(
|
|
t,
|
|
what,
|
|
func(ctx context.Context) error {
|
|
resp, err := r.QueryNameserver(
|
|
ctx, nameserver, hostname,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if resp.Status == resolver.StatusTimeout ||
|
|
resp.Status == resolver.StatusError {
|
|
return fmt.Errorf(
|
|
"%w: %s returned %s: %s",
|
|
livedns.ErrNoAnswer, nameserver,
|
|
resp.Status, resp.Error,
|
|
)
|
|
}
|
|
|
|
out = resp
|
|
|
|
return nil
|
|
},
|
|
)
|
|
|
|
return out
|
|
}
|
|
|
|
// liveQueryAllNameservers queries every authoritative nameserver
|
|
// for a hostname, retrying until a quorum of them has answered.
|
|
// Individual nameservers that stay silent are left in the result
|
|
// for the caller to account for.
|
|
func liveQueryAllNameservers(
|
|
t *testing.T,
|
|
r *resolver.Resolver,
|
|
hostname string,
|
|
) map[string]*resolver.NameserverResponse {
|
|
t.Helper()
|
|
|
|
var out map[string]*resolver.NameserverResponse
|
|
|
|
livedns.Retry(
|
|
t,
|
|
"QueryAllNameservers("+hostname+")",
|
|
func(ctx context.Context) error {
|
|
results, err := r.QueryAllNameservers(ctx, hostname)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return fmt.Errorf(
|
|
"%w: no nameservers queried for %s",
|
|
livedns.ErrNoAnswer, hostname,
|
|
)
|
|
}
|
|
|
|
answered := answeredCount(results)
|
|
if answered < liveQuorum(len(results)) {
|
|
return fmt.Errorf(
|
|
"%w: %d of %d answered: %s",
|
|
errLiveNoQuorum, answered,
|
|
len(results), describeStatuses(results),
|
|
)
|
|
}
|
|
|
|
out = results
|
|
|
|
return nil
|
|
},
|
|
)
|
|
|
|
return out
|
|
}
|
|
|
|
// liveResolveIPs resolves a hostname that is expected to have
|
|
// addresses, retrying until at least one is returned.
|
|
func liveResolveIPs(
|
|
t *testing.T,
|
|
r *resolver.Resolver,
|
|
hostname string,
|
|
) []string {
|
|
t.Helper()
|
|
|
|
var out []string
|
|
|
|
livedns.Retry(
|
|
t,
|
|
"ResolveIPAddresses("+hostname+")",
|
|
func(ctx context.Context) error {
|
|
ips, err := r.ResolveIPAddresses(ctx, hostname)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(ips) == 0 {
|
|
return fmt.Errorf(
|
|
"%w: no addresses for %s",
|
|
livedns.ErrNoAnswer, hostname,
|
|
)
|
|
}
|
|
|
|
out = ips
|
|
|
|
return nil
|
|
},
|
|
)
|
|
|
|
return out
|
|
}
|
|
|
|
// liveResolveIPsAllowingEmpty resolves a hostname that may legitimately
|
|
// have no addresses, so the empty result is returned rather than
|
|
// retried. Used for names that must not exist; the corresponding
|
|
// QueryAllNameservers test is what proves the nameservers actively
|
|
// said NXDOMAIN rather than merely staying silent.
|
|
func liveResolveIPsAllowingEmpty(
|
|
t *testing.T,
|
|
r *resolver.Resolver,
|
|
hostname string,
|
|
) []string {
|
|
t.Helper()
|
|
|
|
var out []string
|
|
|
|
livedns.Retry(
|
|
t,
|
|
"ResolveIPAddresses("+hostname+")",
|
|
func(ctx context.Context) error {
|
|
ips, err := r.ResolveIPAddresses(ctx, hostname)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
out = ips
|
|
|
|
return nil
|
|
},
|
|
)
|
|
|
|
return out
|
|
}
|