1 Commits

Author SHA1 Message Date
3959aedb6a Remove DNS mocking from tests; use live DNS everywhere
All checks were successful
check / check (push) Successful in 50s
DNS is never mocked in this repository: tests exercise live DNS,
and robustness comes from handling real-world DNS behavior with
tolerant assertions and sensible timeouts, not from mocks.

watcher: drop mockResolver and wire the real iterative resolver
into the tests, querying stable public names (example.com,
www.example.com). Change detection is exercised by seeding the
state store with a synthetic previous observation that live DNS
cannot match (reserved .invalid nameserver names and RFC 5737
documentation addresses); DNS stays live in every run. The port
checker, TLS checker, and notifier remain test doubles since they
are not DNS, keeping notification and state assertions
deterministic against whatever addresses live DNS returns.

resolver: drop the timeoutClient fake DNSClient and the
NewFromLoggerWithClient mock constructor. The timeout test is
replaced by a live query against an RFC 5737 documentation
address where no nameserver can exist, asserting a classified
non-OK response with no records.

TESTING.md: extend the live-DNS policy to every package and
remove the carve-out that permitted DNS mocks in packages that
consume the resolver.

TODO.md: update stale references to hermetic mocked-DNS work to
reflect the no-mocking policy and the current state of
feature/resolver.

Intentionally dropped coverage: the exact StatusTimeout
classification (previously forced by the fake client) is no
longer asserted, because a genuinely unreachable server may fail
fast instead of timing out depending on the network path; the
live test tolerantly accepts any failure classification.
2026-08-07 20:43:09 +00:00
12 changed files with 514 additions and 630 deletions

View File

@@ -4,8 +4,8 @@ FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4
RUN apk add --no-cache git make gcc musl-dev binutils-gold
# golangci-lint v2.12.2, 2026-08-07
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5
# golangci-lint v2.10.1
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee
# goimports v0.42.0
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0

View File

@@ -2,8 +2,10 @@
## DNS Resolution Tests
All resolver tests **MUST** use live queries against real DNS servers.
No mocking of the DNS client layer is permitted.
All tests that involve DNS resolution — in every package, including
consumers of the resolver such as the watcher — **MUST** use live
queries against real DNS servers. No mocking, faking, or stubbing of
DNS at any layer is permitted.
### Rationale
@@ -12,6 +14,8 @@ the full delegation chain. Mocked responses cannot faithfully represent
the variety of real-world DNS behavior (truncation, referrals, glue
records, DNSSEC, varied response times, EDNS, etc.). Testing against
real servers ensures the resolver works correctly in production.
Robustness comes from handling real-world DNS behavior with tolerant
assertions and sensible timeouts, not from mocks.
### Constraints
@@ -24,11 +28,14 @@ real servers ensures the resolver works correctly in production.
- Flaky failures from transient network issues are acceptable and
should be investigated as potential resolver bugs, not papered over
with mocks or skip flags
- Watcher change-detection tests seed a synthetic *previous state*
and compare it against fresh live lookups; the DNS side is never
faked
### What NOT to do
- **Do not mock `DNSClient`** for resolver tests (the mock constructor
exists for unit-testing other packages that consume the resolver)
- **Do not mock `DNSClient`**, the watcher's `DNSResolver` interface,
or any other DNS abstraction — in any package, for any reason
- **Do not add `-short` flags** to skip slow tests
- **Do not increase `-timeout`** to hide hanging queries
- **Do not modify linter configuration** to suppress findings

27
TODO.md
View File

@@ -14,7 +14,10 @@ pre-1.0. No git tags. Core resolver work in flight on feature/resolver
(dirty: internal/resolver/resolver_test.go). Local checkout has diverged
from origin: origin/main is 8 commits ahead (watcher orchestrator,
unified TARGETS) and origin/feature/resolver already contains the full
iterative resolver implementation with hermetic mocked tests.
iterative resolver implementation. DNS mocking is banned in this repo
(see `TESTING.md`): all tests use live DNS only. The hermetic mocked
tests previously noted on `feature/resolver` are gone from its current
tip, which carries a live-DNS suite against `*.dns.sneak.cloud`.
# Next Step
@@ -25,13 +28,15 @@ confirm make check still passes.
# Completed Steps
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
in `Dockerfile` and `script/bootstrap`); fixed the resulting
`goconst` findings. `.golangci.yml` unchanged (canonical)
- 2026-08-07: DNS mocking removed from the entire test suite; watcher
tests now drive the real iterative resolver against live DNS and
`TESTING.md` bans DNS mocks in every package (`remove-dns-mocking`
branch)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-02-20: iterative DNS resolver implemented; tests made hermetic
with mocked DNS (origin/feature/resolver, unmerged)
with mocked DNS (origin/feature/resolver, unmerged; superseded — DNS
mocking is banned, see `TESTING.md`)
- 2026-02-20: CI actions and go install refs pinned to commit SHAs;
Gitea Actions workflow for make check (origin/ci/make-check, unmerged)
- 2026-02-20: watcher monitoring orchestrator merged to main (#8)
@@ -58,8 +63,9 @@ Branch reconciliation:
- Sync local checkout with origin: local main is 8 commits behind
origin/main; local feature/resolver has diverged from
origin/feature/resolver, which already implements the resolver
- Merge in-flight branches to main once green: feature/resolver,
ci/make-check, feature/portcheck-implementation,
- Merge in-flight branches to main once green: feature/resolver
(confirm its tests remain live-DNS — DNS mocking is banned, see
`TESTING.md`), ci/make-check, feature/portcheck-implementation,
feature/tlscheck-implementation
Resolver (plan from untracked TODO.md; largely implemented on
@@ -143,6 +149,7 @@ Infrastructure notes (from untracked TODO.md):
- Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de
remote intentionally; do not "fix" it
- Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix
- Resolver tests originally used live DNS against *.dns.sneak.cloud
(required records documented in the test file header); origin now has
mocked hermetic tests, keep them hermetic
- Resolver tests originally used live DNS against `*.dns.sneak.cloud`
(required records documented in the test file header); `main` now
tests against live public DNS. DNS mocking is banned (see
`TESTING.md`); never reintroduce hermetic mocked DNS tests

View File

@@ -25,18 +25,6 @@ const (
colorDefault = "#6c757d"
)
// Priority and fixture values shared across tests.
const (
prioError = "error"
prioWarning = "warning"
prioInfo = "info"
prioSuccess = "success"
prioUnknown = "unknown"
ntfyUrgent = "urgent"
ntfyDefault = "default"
testHost = "example.com"
)
// errSimulated is a static error for transport failures.
var errSimulated = errors.New("simulated transport failure")
@@ -113,13 +101,13 @@ func TestNtfyPriority(t *testing.T) {
input string
want string
}{
{prioError, ntfyUrgent},
{prioWarning, "high"},
{prioSuccess, ntfyDefault},
{prioInfo, "low"},
{"", ntfyDefault},
{prioUnknown, ntfyDefault},
{"critical", ntfyDefault},
{"error", "urgent"},
{"warning", "high"},
{"success", "default"},
{"info", "low"},
{"", "default"},
{"unknown", "default"},
{"critical", "default"},
}
for _, tc := range cases {
@@ -146,12 +134,12 @@ func TestSlackColor(t *testing.T) {
input string
want string
}{
{prioError, colorError},
{prioWarning, colorWarning},
{prioSuccess, colorSuccess},
{prioInfo, colorInfo},
{"error", colorError},
{"warning", colorWarning},
{"success", colorSuccess},
{"info", colorInfo},
{"", colorDefault},
{prioUnknown, colorDefault},
{"unknown", colorDefault},
{"critical", colorDefault},
}
@@ -177,7 +165,7 @@ func TestNewRequest(t *testing.T) {
target := &url.URL{
Scheme: "https",
Host: testHost,
Host: "example.com",
Path: "/webhook",
}
body := bytes.NewBufferString("hello")
@@ -199,9 +187,9 @@ func TestNewRequest(t *testing.T) {
)
}
if req.Host != testHost {
if req.Host != "example.com" {
t.Errorf(
"Host = %q, want %q", req.Host, testHost,
"Host = %q, want %q", req.Host, "example.com",
)
}
@@ -229,7 +217,7 @@ func TestNewRequestPreservesContext(t *testing.T) {
ctxKey("k"),
"v",
)
target := &url.URL{Scheme: "https", Host: testHost}
target := &url.URL{Scheme: "https", Host: "example.com"}
req := notify.NewRequestForTest(
ctx, http.MethodGet, target, http.NoBody,
@@ -301,10 +289,10 @@ func TestSendNtfyHeaders(t *testing.T) {
)
}
if captured.priority != ntfyUrgent {
if captured.priority != "urgent" {
t.Errorf(
"Priority header = %q, want %q",
captured.priority, ntfyUrgent,
captured.priority, "urgent",
)
}
@@ -323,10 +311,10 @@ func TestSendNtfyAllPriorities(t *testing.T) {
input string
want string
}{
{prioError, ntfyUrgent},
{prioWarning, "high"},
{prioSuccess, ntfyDefault},
{prioInfo, "low"},
{"error", "urgent"},
{"warning", "high"},
{"success", "default"},
{"info", "low"},
}
for _, tc := range priorities {
@@ -562,11 +550,11 @@ func TestSendSlackAllColors(t *testing.T) {
priority string
want string
}{
{prioError, colorError},
{prioWarning, colorWarning},
{prioSuccess, colorSuccess},
{prioInfo, colorInfo},
{prioUnknown, colorDefault},
{"error", colorError},
{"warning", colorWarning},
{"success", colorSuccess},
{"info", colorInfo},
{"unknown", colorDefault},
}
for _, tc := range colors {

View File

@@ -29,14 +29,14 @@ func TestAlertHistoryAddAndRecent(t *testing.T) {
Timestamp: now.Add(-2 * time.Minute),
Title: "first",
Message: "msg1",
Priority: prioInfo,
Priority: "info",
})
h.Add(notify.AlertEntry{
Timestamp: now.Add(-1 * time.Minute),
Title: "second",
Message: "msg2",
Priority: prioWarning,
Priority: "warning",
})
entries := h.Recent()

View File

@@ -7,8 +7,8 @@ import (
"github.com/miekg/dns"
)
// DNSClient abstracts DNS wire-protocol exchanges so the resolver
// can be tested without hitting real nameservers.
// DNSClient abstracts DNS wire-protocol exchanges over a single
// transport, letting the resolver switch between UDP and TCP.
type DNSClient interface {
ExchangeContext(
ctx context.Context,

View File

@@ -67,17 +67,4 @@ func NewFromLogger(log *slog.Logger) *Resolver {
}
}
// NewFromLoggerWithClient creates a Resolver with a custom DNS
// client, useful for testing with mock DNS responses.
func NewFromLoggerWithClient(
log *slog.Logger,
client DNSClient,
) *Resolver {
return &Resolver{
log: log,
client: client,
tcp: client,
}
}
// Method implementations are in iterative.go.

View File

@@ -10,7 +10,6 @@ import (
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -624,58 +623,41 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
}
// ----------------------------------------------------------------
// Timeout tests
// Unreachable nameserver tests
// ----------------------------------------------------------------
func TestQueryNameserverIP_Timeout(t *testing.T) {
func TestQueryNameserverIP_UnreachableServer(t *testing.T) {
t.Parallel()
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
r := resolver.NewFromLoggerWithClient(
log, &timeoutClient{},
)
r := newTestResolver(t)
ctx, cancel := context.WithTimeout(
context.Background(), 10*time.Second,
)
t.Cleanup(cancel)
// Query any IP — the client always returns a timeout error.
// 192.0.2.1 is an RFC 5737 documentation address: no
// nameserver can exist there. Depending on the network
// path the queries either time out (silent drop) or fail
// fast (ICMP unreachable), so accept any non-OK status;
// the resolver must return a classified response with no
// records rather than an error or a hang.
resp, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1",
"example.com",
)
require.NoError(t, err)
assert.Equal(t, resolver.StatusTimeout, resp.Status)
assert.NotEmpty(t, resp.Error)
}
assert.NotEqual(t, resolver.StatusOK, resp.Status)
// timeoutClient simulates DNS timeout errors for testing.
type timeoutClient struct{}
func (c *timeoutClient) ExchangeContext(
_ context.Context,
_ *dns.Msg,
_ string,
) (*dns.Msg, time.Duration, error) {
return nil, 0, &net.OpError{
Op: "read",
Net: "udp",
Err: &timeoutError{},
totalRecords := 0
for _, values := range resp.Records {
totalRecords += len(values)
}
assert.Zero(t, totalRecords)
}
type timeoutError struct{}
func (e *timeoutError) Error() string { return "i/o timeout" }
func (e *timeoutError) Timeout() bool { return true }
func (e *timeoutError) Temporary() bool { return true }
func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
t.Parallel()

View File

@@ -13,16 +13,6 @@ import (
const testHostname = "www.example.com"
// Shared fixture values used across tests.
const (
testNS1 = "ns1.example.com."
testNS2 = "ns2.example.com."
testAltNS1 = "ns1.test.com."
testIPv4 = "93.184.216.34"
testIP = "1.2.3.4"
statusError = "error"
)
// populateState fills a State with representative test data across all categories.
func populateState(t *testing.T, s *state.State) {
t.Helper()
@@ -30,7 +20,7 @@ func populateState(t *testing.T, s *state.State) {
now := time.Now().UTC().Truncate(time.Second)
s.SetDomainState("example.com", &state.DomainState{
Nameservers: []string{testNS1, testNS2},
Nameservers: []string{"ns1.example.com.", "ns2.example.com."},
LastChecked: now,
})
@@ -41,17 +31,17 @@ func populateState(t *testing.T, s *state.State) {
s.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
testNS1: {
"ns1.example.com.": {
Records: map[string][]string{
"A": {testIPv4},
"A": {"93.184.216.34"},
"AAAA": {"2606:2800:220:1:248:1893:25c8:1946"},
},
Status: "ok",
LastChecked: now,
},
testNS2: {
"ns2.example.com.": {
Records: map[string][]string{
"A": {testIPv4},
"A": {"93.184.216.34"},
},
Status: "ok",
LastChecked: now,
@@ -162,13 +152,13 @@ func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
func verifyNS1Records(t *testing.T, hn *state.HostnameState) {
t.Helper()
ns1, ok := hn.RecordsByNameserver[testNS1]
ns1, ok := hn.RecordsByNameserver["ns1.example.com."]
if !ok {
t.Fatal("missing nameserver ns1.example.com.")
}
aRecords := ns1.Records["A"]
if len(aRecords) != 1 || aRecords[0] != testIPv4 {
if len(aRecords) != 1 || aRecords[0] != "93.184.216.34" {
t.Errorf("ns1 A records: got %v", aRecords)
}
@@ -663,7 +653,7 @@ func TestDomainState_GetSet(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
ds := &state.DomainState{
Nameservers: []string{testAltNS1},
Nameservers: []string{"ns1.test.com."},
LastChecked: now,
}
@@ -674,7 +664,7 @@ func TestDomainState_GetSet(t *testing.T) {
t.Fatal("expected true for existing domain")
}
if len(got.Nameservers) != 1 || got.Nameservers[0] != testAltNS1 {
if len(got.Nameservers) != 1 || got.Nameservers[0] != "ns1.test.com." {
t.Errorf("nameservers: got %v", got.Nameservers)
}
@@ -684,7 +674,7 @@ func TestDomainState_GetSet(t *testing.T) {
// Overwrite.
ds2 := &state.DomainState{
Nameservers: []string{testAltNS1, "ns2.test.com."},
Nameservers: []string{"ns1.test.com.", "ns2.test.com."},
LastChecked: now.Add(time.Hour),
}
@@ -714,8 +704,8 @@ func TestHostnameState_GetSet(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
hs := &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
testNS1: {
Records: map[string][]string{"A": {testIP}},
"ns1.example.com.": {
Records: map[string][]string{"A": {"1.2.3.4"}},
Status: "ok",
LastChecked: now,
},
@@ -730,7 +720,7 @@ func TestHostnameState_GetSet(t *testing.T) {
t.Fatal("expected true for existing hostname")
}
nsState, ok := got.RecordsByNameserver[testNS1]
nsState, ok := got.RecordsByNameserver["ns1.example.com."]
if !ok {
t.Fatal("missing nameserver entry")
}
@@ -740,7 +730,7 @@ func TestHostnameState_GetSet(t *testing.T) {
}
aRecords := nsState.Records["A"]
if len(aRecords) != 1 || aRecords[0] != testIP {
if len(aRecords) != 1 || aRecords[0] != "1.2.3.4" {
t.Errorf("A records: got %v", aRecords)
}
}
@@ -879,7 +869,7 @@ func TestCertificateState_ErrorField(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
cs := &state.CertificateState{
Status: statusError,
Status: "error",
Error: "connection refused",
LastChecked: now,
}
@@ -903,8 +893,8 @@ func TestCertificateState_ErrorField(t *testing.T) {
t.Fatal("missing certificate after load")
}
if got.Status != statusError {
t.Errorf("status: got %q, want %q", got.Status, statusError)
if got.Status != "error" {
t.Errorf("status: got %q, want %q", got.Status, "error")
}
if got.Error != "connection refused" {
@@ -922,9 +912,9 @@ func TestHostnameState_ErrorField(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
hs := &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
testNS1: {
"ns1.example.com.": {
Records: nil,
Status: statusError,
Status: "error",
Error: "SERVFAIL",
LastChecked: now,
},
@@ -951,9 +941,9 @@ func TestHostnameState_ErrorField(t *testing.T) {
t.Fatal("missing hostname after load")
}
nsState := got.RecordsByNameserver[testNS1]
if nsState.Status != statusError {
t.Errorf("status: got %q, want %q", nsState.Status, statusError)
nsState := got.RecordsByNameserver["ns1.example.com."]
if nsState.Status != "error" {
t.Errorf("status: got %q, want %q", nsState.Status, "error")
}
if nsState.Error != "SERVFAIL" {
@@ -1095,7 +1085,7 @@ func runConcurrentOps(s *state.State, key string, now time.Time) {
s.SetHostnameState(key+".example.com", &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
"ns1.test.": {
Records: map[string][]string{"A": {testIP}},
Records: map[string][]string{"A": {"1.2.3.4"}},
Status: "ok",
LastChecked: now,
},

View File

@@ -26,9 +26,6 @@ const tlsPort = 443
// hoursPerDay converts days to hours for duration calculations.
const hoursPerDay = 24
// statusError is the status value recorded for failed checks.
const statusError = "error"
// Params contains dependencies for Watcher.
type Params struct {
fx.In
@@ -424,7 +421,7 @@ func (w *Watcher) detectNSDisappearances(
for ns := range current {
prevNS, ok := prev.RecordsByNameserver[ns]
if !ok || prevNS.Status != statusError {
if !ok || prevNS.Status != "error" {
continue
}
@@ -724,7 +721,7 @@ func (w *Watcher) handleTLSError(
w.state.SetCertificateState(
certKey, &state.CertificateState{
Status: statusError,
Status: "error",
Error: err.Error(),
LastChecked: now,
},
@@ -763,7 +760,7 @@ func (w *Watcher) detectTLSChanges(
prev *state.CertificateState,
cert *tlscheck.CertificateInfo,
) {
if prev.Status == statusError {
if prev.Status == "error" {
msg := fmt.Sprintf(
"Host: %s\nIP: %s\nTLS recovered",
hostname, ip,

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,9 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-08-07 (same pins as the Dockerfile)
# golangci-lint v2.12.2
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# Pinned versions, 2026-07-07 (same pins as the Dockerfile)
# golangci-lint v2.10.1
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee"
# goimports v0.42.0
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"