test: rework live-DNS quorum unit — tolerate silence, never a wrong answer
All checks were successful
check / check (push) Successful in 1m23s

Rework of the unit at #93
(commit 9cb2c2b), against the review at
#136 (comment).

Review of 9cb2c2b found the quorum assertions could not fail on a
class of wrong answer. Each test banned exactly one bad status —
_AllReturnOK banned only nxdomain, _NXDomainFromAllNS banned only ok
— so resolver.StatusNoData passed both. nodata is a wrong answer, not
silence, and answeredCount counted it as answered, so it did not even
trigger a retry; with a quorum of 3 of 4 a single wrong nameserver
slid through undetected. That is assertion-loosening beyond what the
quorum change requires.

Tolerance is now a closed allowlist rather than a blocklist of one
status. unsanctionedStatuses() reports every per-nameserver result
whose status the caller did not explicitly sanction: ok/timeout/error
for the all-OK test, nxdomain/timeout/error for the NXDOMAIN test.
Silence (timeout, error) is the only thing quorum exists to tolerate;
any other status, including one added to the resolver later, fails by
name. answeredCount is likewise an allowlist of ok/nxdomain/nodata, so
an unknown status counts as silence and can only cause a retry and
then a loud failure, never a quiet pass.

Two harness tests cover the regression directly: three OK plus one
nodata (quorum satisfied, no nxdomain present — the input that used
to pass) is now reported as unsanctioned, and an unknown status is
neither counted as answered nor tolerated.

Verified by re-running the reviewer's probe: queryEachNS patched to
force one of google.com's four nameservers to return StatusNoData
turns both tests red, naming the offending nameserver and status —

    --- FAIL: TestQueryAllNameservers_AllReturnOK (1.12s)
        Should be empty, but was [ns1.google.com.=nodata]
        every nameserver must answer OK or not answer at all:
        ns1.google.com.=nodata ns2.google.com.=ok ns3.google.com.=ok
        ns4.google.com.=ok
    --- FAIL: TestQueryAllNameservers_NXDomainFromAllNS (1.34s)
        Should be empty, but was [ns1.google.com.=nodata]
        every nameserver must report NXDOMAIN or not answer at all:
        ns1.google.com.=nodata ns2.google.com.=nxdomain
        ns3.google.com.=nxdomain ns4.google.com.=nxdomain

— and green with the probe reverted. Also fixes the review's nit: the
per-attempt deadline assertion had no lower bound, so it passed for a
deadline far shorter than intended.

No production code changed; DNS is still never mocked.
This commit is contained in:
2026-08-10 13:33:26 +00:00
parent 9cb2c2b7e0
commit 87bce43f8d
4 changed files with 227 additions and 25 deletions

View File

@@ -30,7 +30,12 @@ confirm make check still passes.
`internal/resolver/livedns_test.go` adds a package-wide concurrency `internal/resolver/livedns_test.go` adds a package-wide concurrency
gate (so parallel tests stop bursting at the first root server), gate (so parallel tests stop bursting at the first root server),
retry with exponential backoff on transport failures only, and retry with exponential backoff on transport failures only, and
quorum instead of unanimity for multi-nameserver assertions. The quorum instead of unanimity for multi-nameserver assertions. Quorum
tolerates silence only: every per-nameserver status must be in a
closed allowlist (`ok`/`timeout`/`error`, or
`nxdomain`/`timeout`/`error`), so a wrong answer from a minority —
`nodata` today, any status added later — fails the test instead of
sliding through under the majority. The
`make test` cap moved to the new org-wide 60s hard cap / 20s target `make test` cap moved to the new org-wide 60s hard cap / 20s target
with a 90s `-timeout` backstop; `REPO_POLICIES.md` re-vendored with a 90s `-timeout` backstop; `REPO_POLICIES.md` re-vendored
byte-identical from `sneak/prompts`. No mocks, no `-short`, no build byte-identical from `sneak/prompts`. No mocks, no `-short`, no build

View File

@@ -16,6 +16,16 @@ import (
// perform no DNS resolution of any kind, so they neither mock DNS // perform no DNS resolution of any kind, so they neither mock DNS
// nor depend on it. // nor depend on it.
// Names for the synthetic status maps below. Nothing is ever queried
// at them: they are map keys handed to the package's pure counting
// helpers, not a stand-in for a nameserver.
const (
nsExample1 = "ns1.example."
nsExample2 = "ns2.example."
nsExample3 = "ns3.example."
nsExample4 = "ns4.example."
)
func TestLiveQuorumIsStrictMajority(t *testing.T) { func TestLiveQuorumIsStrictMajority(t *testing.T) {
t.Parallel() t.Parallel()
@@ -41,20 +51,20 @@ func TestStatusCountingIgnoresSilentNameservers(t *testing.T) {
t.Parallel() t.Parallel()
results := map[string]*resolver.NameserverResponse{ results := map[string]*resolver.NameserverResponse{
"ns1.example.": { nsExample1: {
Nameserver: "ns1.example.", Nameserver: nsExample1,
Status: resolver.StatusOK, Status: resolver.StatusOK,
}, },
"ns2.example.": { nsExample2: {
Nameserver: "ns2.example.", Nameserver: nsExample2,
Status: resolver.StatusOK, Status: resolver.StatusOK,
}, },
"ns3.example.": { nsExample3: {
Nameserver: "ns3.example.", Nameserver: nsExample3,
Status: resolver.StatusTimeout, Status: resolver.StatusTimeout,
}, },
"ns4.example.": { nsExample4: {
Nameserver: "ns4.example.", Nameserver: nsExample4,
Status: resolver.StatusError, Status: resolver.StatusError,
}, },
} }
@@ -106,14 +116,126 @@ func TestRetryLiveGivesEachAttemptADeadline(t *testing.T) {
retryLive(t, "deadline", func(ctx context.Context) error { retryLive(t, "deadline", func(ctx context.Context) error {
deadline, ok := ctx.Deadline() deadline, ok := ctx.Deadline()
assert.True(t, ok, "attempt should carry a deadline") assert.True(t, ok, "attempt should carry a deadline")
assert.LessOrEqual(
t, time.Until(deadline), liveAttemptTimeout, remaining := time.Until(deadline)
)
assert.LessOrEqual(t, remaining, liveAttemptTimeout)
// Lower bound too: without one this passes for a
// deadline far shorter than intended, which would
// silently turn every live attempt into an instant
// timeout.
assert.Greater(t, remaining, liveAttemptTimeout/2)
return nil return nil
}) })
} }
// TestUnsanctionedStatusesRejectsWrongAnswers is the regression test
// for the defect this allowlist exists to prevent: a minority of
// nameservers answering WRONGLY while quorum keeps the suite green.
// nodata is the case that motivated it — it is a wrong answer, not
// silence, and it was previously banned by neither test.
func TestUnsanctionedStatusesRejectsWrongAnswers(t *testing.T) {
t.Parallel()
// Four nameservers, three OK and one answering nodata: a
// quorum of three is satisfied and no NXDOMAIN is present, so
// the old blocklist assertions both passed on this input.
results := map[string]*resolver.NameserverResponse{
nsExample1: {
Nameserver: nsExample1,
Status: resolver.StatusOK,
},
nsExample2: {
Nameserver: nsExample2,
Status: resolver.StatusOK,
},
nsExample3: {
Nameserver: nsExample3,
Status: resolver.StatusOK,
},
nsExample4: {
Nameserver: nsExample4,
Status: resolver.StatusNoData,
},
}
assert.GreaterOrEqual(
t,
countStatus(results, resolver.StatusOK),
liveQuorum(len(results)),
)
assert.Zero(t, countStatus(results, resolver.StatusNXDomain))
// nodata is an ANSWER, so it never triggers a retry: nothing
// but the allowlist stands between it and a false green.
assert.Equal(t, len(results), answeredCount(results))
assert.Equal(
t,
[]string{nsExample4 + "=nodata"},
unsanctionedStatuses(
results,
resolver.StatusOK,
resolver.StatusTimeout,
resolver.StatusError,
),
"nodata must be reported as an unsanctioned status",
)
}
func TestUnsanctionedStatusesToleratesSilenceOnly(t *testing.T) {
t.Parallel()
results := map[string]*resolver.NameserverResponse{
nsExample1: {
Nameserver: nsExample1,
Status: resolver.StatusNXDomain,
},
nsExample2: {
Nameserver: nsExample2,
Status: resolver.StatusTimeout,
},
nsExample3: {
Nameserver: nsExample3,
Status: resolver.StatusError,
},
}
allowed := []string{
resolver.StatusNXDomain,
resolver.StatusTimeout,
resolver.StatusError,
}
assert.Empty(
t,
unsanctionedStatuses(results, allowed...),
"timeout and error are non-answers and are tolerated",
)
// The same silent nameservers do not count towards a quorum.
assert.Equal(t, 1, answeredCount(results))
// An unknown status is treated as silence by answeredCount —
// so it retries and fails loudly — and is unsanctioned by the
// allowlist rather than quietly permitted.
const laterStatus = "some-status-added-later"
results[nsExample4] = &resolver.NameserverResponse{
Nameserver: nsExample4,
Status: laterStatus,
}
assert.Equal(t, 1, answeredCount(results))
assert.Equal(
t,
[]string{nsExample4 + "=" + laterStatus},
unsanctionedStatuses(results, allowed...),
)
}
func TestRunLiveBoundsConcurrency(t *testing.T) { func TestRunLiveBoundsConcurrency(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"slices"
"sort" "sort"
"strings" "strings"
"testing" "testing"
@@ -44,6 +45,15 @@ import (
// nameservers, a strict majority answering as expected is // nameservers, a strict majority answering as expected is
// enough; a server that fails to answer is tolerated, while a // enough; a server that fails to answer is tolerated, while a
// server that answers *wrongly* still fails the test. // server that answers *wrongly* still fails the test.
//
// The tolerance in (3) 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.
const ( const (
// liveAttempts is how many times a live DNS operation is // liveAttempts is how many times a live DNS operation is
@@ -172,14 +182,62 @@ func countStatus(
return 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 // answeredCount counts the nameservers that produced an answer of
// any kind, as opposed to failing or timing out. // any kind, as opposed to failing or timing out.
func answeredCount( func answeredCount(
results map[string]*resolver.NameserverResponse, results map[string]*resolver.NameserverResponse,
) int { ) int {
return len(results) - answers := liveAnswerStatuses()
countStatus(results, resolver.StatusError) -
countStatus(results, resolver.StatusTimeout) 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 // describeStatuses renders per-nameserver statuses for use in

View File

@@ -324,12 +324,21 @@ func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
describeStatuses(results), describeStatuses(results),
) )
// Any nameserver claiming google.com does not exist is a // Quorum tolerates SILENCE only. Every individual result must
// real failure and is never tolerated. // be either the expected answer or a non-answer: ok, timeout
assert.Zero( // or error, and nothing else. Stated as a closed allowlist so
// that a wrong answer no one thought to ban — nxdomain and
// nodata today, any status added later — fails here rather
// than sliding through under the quorum.
assert.Empty(
t, t,
countStatus(results, resolver.StatusNXDomain), unsanctionedStatuses(
"no nameserver should report NXDOMAIN: %s", results,
resolver.StatusOK,
resolver.StatusTimeout,
resolver.StatusError,
),
"every nameserver must answer OK or not answer at all: %s",
describeStatuses(results), describeStatuses(results),
) )
} }
@@ -352,12 +361,20 @@ func TestQueryAllNameservers_NXDomainFromAllNS(
describeStatuses(results), describeStatuses(results),
) )
// Silence is tolerated; a positive answer for a name that // Silence is tolerated; any actual answer other than NXDOMAIN
// does not exist is not. // is not. Closed allowlist for the same reason as above: a
assert.Zero( // server answering `ok` or `nodata` for a name that must not
// exist is a wrong answer, not a slow one.
assert.Empty(
t, t,
countStatus(results, resolver.StatusOK), unsanctionedStatuses(
"no nameserver should answer OK: %s", results,
resolver.StatusNXDomain,
resolver.StatusTimeout,
resolver.StatusError,
),
"every nameserver must report NXDOMAIN or not answer "+
"at all: %s",
describeStatuses(results), describeStatuses(results),
) )
} }