Files
dnswatcher/internal/watcher/watcher_test.go
T
clawbot 62dec447e3
check / check (push) Successful in 1m37s
test: bound watcher live DNS and serialise packages after rebase
Rebasing this branch onto next surfaced two failures that the branch
did not have in isolation. Both come from the change this PR makes:
the watcher package now queries live DNS, and it is the second package
to do so.

Burst fan-out in the watcher package. All 13 watcher tests are
parallel and each runs a full iterative resolution, so they hit the
same root servers in the same instant and get rate-limited. This is
exactly the pathology internal/resolver/livedns_test.go was written to
prevent (9cb2c2b); that gate is package-scoped and cannot reach into
this package's test binary, so liveWatcherGate is its counterpart
here, acquired in newTestWatcher and released when the test ends.

Cross-package oversubscription. Go runs package binaries in parallel,
so with both live-DNS packages in flight their gates sum rather than
hold. The excess is rate-limited and the resolver's 8s per-attempt
deadlines expire, failing ResolveIPAddresses tests this branch never
touched. script/test now passes -p 1 so each gate is authoritative
while its package runs. Serialising is also net faster here, because
the retries it removes cost more than the lost parallelism: resolver
16-22s (was 30-36s under contention), watcher 12-13s (was 27-37s),
whole suite 39-46s against the 60s cap and the 90s -timeout backstop.

TestQueryNameserverIP_UnreachableServer is dropped rather than fixed.
It asserted that a query to an RFC 5737 documentation address comes
back non-OK with no records, which does not hold in the build
environment: that network transparently intercepts all UDP/53 traffic
regardless of destination and answers it locally, so the query returns
StatusOK with 9 real records for example.com. Verified directly with
dig @192.0.2.1 inside the build network. The mock DNSClient that used
to force this classification is what this PR removes, and a live
substitute would only be testing the sandbox's network behaviour, so
the coverage gap is recorded in a comment where the test was.

Verified: three consecutive `docker build .` runs green, after three
consecutive failures without these changes.
2026-09-03 17:01:08 +00:00

870 lines
19 KiB
Go

package watcher_test
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"sort"
"strings"
"sync"
"testing"
"time"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/tlscheck"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// These tests exercise the watcher against live DNS. DNS is never
// mocked in this repository (see TESTING.md): the watcher is wired
// to the real iterative resolver and queries stable, well-known
// names. Only the non-DNS collaborators (port checker, TLS
// checker, notifier) are test doubles so that notification and
// state behavior can be asserted deterministically.
//
// Change detection is exercised by seeding the state store with a
// synthetic previous observation that live DNS cannot match; DNS
// itself stays live in every run.
const (
// testDomain and testHostname are IANA-operated names
// reserved for documentation use; they are stable and
// resolve worldwide.
testDomain = "example.com"
testHostname = "www.example.com"
// fakeNS uses the RFC 2606 reserved .invalid TLD, so it can
// never collide with a nameserver name in live DNS results.
fakeNS = "ns-baseline.invalid."
// fakeIP is an RFC 5737 documentation address that live DNS
// can never return for a real hostname.
fakeIP = "203.0.113.1"
startupTitle = "✅ dnswatcher startup complete"
// longLife is a certificate lifetime far outside the expiry
// warning window; shortLife is inside it.
longLife = 90 * 24 * time.Hour
shortLife = 3 * 24 * time.Hour
pollInterval = 100 * time.Millisecond
scanTimeout = 25 * time.Second
notifyGrace = 500 * time.Millisecond
shutdownWait = 5 * time.Second
)
// errNotFound is returned when a test double has no data.
var errNotFound = errors.New("not found")
// --- Test doubles for non-DNS collaborators ---
type mockPortChecker struct {
mu sync.Mutex
openAll bool
err error
calls int
}
func (m *mockPortChecker) CheckPort(
_ context.Context,
_ string,
_ int,
) (*portcheck.PortResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls++
if m.err != nil {
return nil, m.err
}
return &portcheck.PortResult{Open: m.openAll}, nil
}
func (m *mockPortChecker) setOpenAll(open bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.openAll = open
}
func (m *mockPortChecker) callCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.calls
}
type mockTLSChecker struct {
mu sync.Mutex
cert *tlscheck.CertificateInfo
err error
calls int
}
func (m *mockTLSChecker) CheckCertificate(
_ context.Context,
_ string,
hostname string,
) (*tlscheck.CertificateInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls++
if m.err != nil {
return nil, m.err
}
if m.cert == nil {
return nil, fmt.Errorf(
"%w: cert for %s", errNotFound, hostname,
)
}
return m.cert, nil
}
func (m *mockTLSChecker) setCert(cert *tlscheck.CertificateInfo) {
m.mu.Lock()
defer m.mu.Unlock()
m.cert = cert
}
func (m *mockTLSChecker) callCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.calls
}
type notification struct {
Title string
Message string
Priority string
}
type mockNotifier struct {
mu sync.Mutex
notifications []notification
}
func (m *mockNotifier) SendNotification(
_ context.Context,
title, message, priority string,
) {
m.mu.Lock()
defer m.mu.Unlock()
m.notifications = append(m.notifications, notification{
Title: title,
Message: message,
Priority: priority,
})
}
func (m *mockNotifier) getNotifications() []notification {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]notification, len(m.notifications))
copy(result, m.notifications)
return result
}
// --- Helpers to build a Watcher for testing ---
type testDeps struct {
portChecker *mockPortChecker
tlsChecker *mockTLSChecker
notifier *mockNotifier
state *state.State
config *config.Config
}
// liveWatcherConcurrency caps how many of this package's tests may
// be driving live DNS at once. Every test here runs a full iterative
// resolution, and the package is entirely parallel, so without a
// bound all of them burst against the same root servers in the same
// instant and get rate-limited — the fan-out pathology that
// internal/resolver/livedns_test.go exists to prevent. That gate is
// package-scoped and so cannot reach across into this package's test
// binary; this is its counterpart here.
const liveWatcherConcurrency = 3
// liveWatcherGate bounds concurrent live-DNS watcher tests. It has to
// be package scoped: the point is that every parallel test shares it.
//
//nolint:gochecknoglobals // package-wide live query rate limit
var liveWatcherGate = make(chan struct{}, liveWatcherConcurrency)
// acquireLiveSlot blocks until this test may run live DNS, releasing
// the slot when the test ends.
func acquireLiveSlot(t *testing.T) {
t.Helper()
liveWatcherGate <- struct{}{}
t.Cleanup(func() { <-liveWatcherGate })
}
func newTestWatcher(
t *testing.T,
cfg *config.Config,
) (*watcher.Watcher, *testDeps) {
t.Helper()
acquireLiveSlot(t)
deps := &testDeps{
portChecker: &mockPortChecker{},
tlsChecker: &mockTLSChecker{},
notifier: &mockNotifier{},
config: cfg,
}
deps.state = state.NewForTestWithDataDir(cfg.DataDir)
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
w := watcher.NewForTest(
deps.config,
deps.state,
resolver.NewFromLogger(log),
deps.portChecker,
deps.tlsChecker,
deps.notifier,
)
return w, deps
}
func defaultTestConfig(t *testing.T) *config.Config {
t.Helper()
return &config.Config{
DNSInterval: time.Hour,
TLSInterval: 12 * time.Hour,
TLSExpiryWarning: 7,
DataDir: t.TempDir(),
}
}
func testCert(
hostname string,
lifetime time.Duration,
) *tlscheck.CertificateInfo {
return &tlscheck.CertificateInfo{
CommonName: hostname,
Issuer: "Test CA",
NotAfter: time.Now().Add(lifetime),
SubjectAlternativeNames: []string{hostname},
}
}
// setupHealthyEndpoints makes every port report open and every TLS
// check return a long-lived certificate, regardless of which IPs
// live DNS resolves.
func setupHealthyEndpoints(deps *testDeps, hostname string) {
deps.portChecker.setOpenAll(true)
deps.tlsChecker.setCert(testCert(hostname, longLife))
}
// countTitled counts notifications whose title starts with prefix.
func countTitled(deps *testDeps, prefix string) int {
count := 0
for _, n := range deps.notifier.getNotifications() {
if strings.HasPrefix(n.Title, prefix) {
count++
}
}
return count
}
// waitFor polls cond until it returns true or timeout elapses.
func waitFor(timeout time.Duration, cond func() bool) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(pollInterval)
}
return cond()
}
// startWatcher runs w.Run in a goroutine and returns a stop
// function that cancels it and waits for a clean shutdown.
func startWatcher(
t *testing.T,
w *watcher.Watcher,
) func() {
t.Helper()
ctx, cancel := context.WithCancel(t.Context())
done := make(chan struct{})
go func() {
w.Run(ctx)
close(done)
}()
return func() {
cancel()
select {
case <-done:
// Shut down cleanly.
case <-time.After(shutdownWait):
t.Error("watcher did not shut down within timeout")
}
}
}
// liveIPs extracts all A/AAAA addresses recorded for hostname in
// the snapshot, i.e. what live DNS actually resolved.
func liveIPs(snap state.Snapshot, hostname string) []string {
hs, ok := snap.Hostnames[hostname]
if !ok {
return nil
}
seen := make(map[string]bool)
var ips []string
for _, nsState := range hs.RecordsByNameserver {
for _, recType := range []string{"A", "AAAA"} {
for _, ip := range nsState.Records[recType] {
if !seen[ip] {
seen[ip] = true
ips = append(ips, ip)
}
}
}
}
sort.Strings(ips)
return ips
}
// --- Tests ---
func TestFirstRunBaseline(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHostname}
w, deps := newTestWatcher(t, cfg)
setupHealthyEndpoints(deps, testHostname)
w.RunOnce(t.Context())
assertNoNotifications(t, deps)
assertStatePopulated(t, deps)
}
func assertNoNotifications(
t *testing.T,
deps *testDeps,
) {
t.Helper()
notifications := deps.notifier.getNotifications()
if len(notifications) != 0 {
t.Errorf(
"expected 0 notifications on first run, got %d: %v",
len(notifications), notifications,
)
}
}
func assertStatePopulated(
t *testing.T,
deps *testDeps,
) {
t.Helper()
snap := deps.state.GetSnapshot()
if len(snap.Domains) != 1 {
t.Errorf(
"expected 1 domain in state, got %d",
len(snap.Domains),
)
}
// 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),
)
}
}
func TestDomainPortAndTLSChecks(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
w, deps := newTestWatcher(t, cfg)
setupHealthyEndpoints(deps, testDomain)
w.RunOnce(t.Context())
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")
}
// Domain should have certificate state populated.
if len(snap.Certificates) == 0 {
t.Error("expected certificate state for domain, got none")
}
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")
}
}
func TestNSChangeDetection(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
w, deps := newTestWatcher(t, cfg)
ctx := t.Context()
w.RunOnce(ctx)
// Replace the recorded NS baseline with a synthetic value
// that live DNS cannot return; the next live run must
// detect the difference.
deps.state.SetDomainState(testDomain, &state.DomainState{
Nameservers: []string{fakeNS},
LastChecked: time.Now().UTC(),
})
w.RunOnce(ctx)
if countTitled(deps, "NS Change: ") == 0 {
t.Error("expected notification for NS change")
}
found := false
for _, n := range deps.notifier.getNotifications() {
if n.Priority == "warning" {
found = true
}
}
if !found {
t.Error("expected warning-priority NS change notification")
}
}
func TestRecordChangeDetection(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHostname}
w, deps := newTestWatcher(t, cfg)
ctx := t.Context()
w.RunOnce(ctx)
hs, ok := deps.state.GetHostnameState(testHostname)
if !ok || len(hs.RecordsByNameserver) == 0 {
t.Fatal("expected hostname state after first live run")
}
// Rewrite every nameserver's recorded answer to a
// documentation address; the next live run must observe
// different records and notify.
tampered := &state.HostnameState{
RecordsByNameserver: make(
map[string]*state.NameserverRecordState,
),
LastChecked: hs.LastChecked,
}
for ns := range hs.RecordsByNameserver {
tampered.RecordsByNameserver[ns] = &state.NameserverRecordState{
Records: map[string][]string{"A": {fakeIP}},
Status: "ok",
LastChecked: time.Now().UTC(),
}
}
deps.state.SetHostnameState(testHostname, tampered)
w.RunOnce(ctx)
if countTitled(deps, "Record Change: ") == 0 {
t.Error("expected notification for record change")
}
}
func TestPortStateChange(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHostname}
w, deps := newTestWatcher(t, cfg)
setupHealthyEndpoints(deps, testHostname)
ctx := t.Context()
w.RunOnce(ctx)
// All live-resolved addresses flip from open to closed.
deps.portChecker.setOpenAll(false)
w.RunOnce(ctx)
if countTitled(deps, "Port Change: ") == 0 {
t.Error("expected notification for port state change")
}
}
func TestTLSExpiryWarning(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHostname}
w, deps := newTestWatcher(t, cfg)
deps.portChecker.setOpenAll(true)
deps.tlsChecker.setCert(testCert(testHostname, shortLife))
w.RunOnce(t.Context())
found := false
for _, n := range deps.notifier.getNotifications() {
if strings.HasPrefix(n.Title, "TLS Expiry Warning: ") &&
n.Priority == "warning" {
found = true
}
}
if !found {
t.Errorf(
"expected expiry warning, got: %v",
deps.notifier.getNotifications(),
)
}
}
func TestTLSExpiryWarningDedup(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHostname}
cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg)
deps.portChecker.setOpenAll(true)
deps.tlsChecker.setCert(testCert(testHostname, shortLife))
ctx := t.Context()
// First run fires one expiry warning per live-resolved IP.
w.RunOnce(ctx)
afterFirst := countTitled(deps, "TLS Expiry Warning: ")
if afterFirst == 0 {
t.Fatal("expected at least one expiry warning")
}
// A second run within the TLS interval must not re-notify.
w.RunOnce(ctx)
afterSecond := countTitled(deps, "TLS Expiry Warning: ")
if afterSecond != afterFirst {
t.Errorf(
"expected expiry warnings deduplicated at %d, got %d",
afterFirst, afterSecond,
)
}
}
func TestGracefulShutdown(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.DNSInterval = 100 * time.Millisecond
cfg.TLSInterval = 100 * time.Millisecond
w, _ := newTestWatcher(t, cfg)
stop := startWatcher(t, w)
// Cancel while live DNS work is likely still in flight; the
// watcher must still shut down promptly.
time.Sleep(250 * time.Millisecond)
stop()
}
func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHostname}
w, deps := newTestWatcher(t, cfg)
setupHealthyEndpoints(deps, testHostname)
// Seed stale state pointing at an address live DNS cannot
// return. A correct cycle refreshes DNS before the port and
// TLS phases, so the stale address must be dropped, not
// probed.
deps.state.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
fakeNS: {
Records: map[string][]string{"A": {fakeIP}},
Status: "ok",
LastChecked: time.Now().UTC(),
},
},
LastChecked: time.Now().UTC(),
})
deps.state.SetPortState(fakeIP+":80", &state.PortState{
Open: true,
Hostnames: []string{testHostname},
LastChecked: time.Now().UTC(),
})
w.RunOnce(t.Context())
snap := deps.state.GetSnapshot()
ips := liveIPs(snap, testHostname)
if len(ips) == 0 {
t.Fatal("expected live-resolved IPs in hostname state")
}
for _, ip := range ips {
if _, ok := snap.Ports[ip+":80"]; !ok {
t.Errorf(
"port check missed fresh DNS address %s", ip,
)
}
}
if _, ok := snap.Ports[fakeIP+":80"]; ok {
t.Error("port check used stale DNS: " + fakeIP + ":80")
}
certKey := ips[0] + ":443:" + testHostname
if _, ok := snap.Certificates[certKey]; !ok {
t.Error("TLS check used stale DNS: missing " + certKey)
}
}
func TestSendTestNotification_Enabled(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHostname}
cfg.SendTestNotification = true
w, deps := newTestWatcher(t, cfg)
setupHealthyEndpoints(deps, testHostname)
w.RunOnce(t.Context())
// RunOnce does not send the test notification — it is
// sent by Run after RunOnce completes.
notifications := deps.notifier.getNotifications()
if len(notifications) != 0 {
t.Errorf(
"RunOnce should not send test notification, got %d",
len(notifications),
)
}
}
func TestSendTestNotification_ViaRun(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHostname}
cfg.SendTestNotification = true
cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg)
setupHealthyEndpoints(deps, testHostname)
stop := startWatcher(t, w)
// The startup notification is sent after the initial live
// scan completes; wait for it rather than assuming a scan
// duration.
found := waitFor(scanTimeout, func() bool {
return countTitled(deps, startupTitle) > 0
})
stop()
if !found {
t.Errorf(
"expected startup test notification, got: %v",
deps.notifier.getNotifications(),
)
}
for _, n := range deps.notifier.getNotifications() {
if n.Title == startupTitle && n.Priority != "success" {
t.Errorf(
"expected success priority, got %q", n.Priority,
)
}
}
}
func TestSendTestNotification_Disabled(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHostname}
cfg.SendTestNotification = false
cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg)
setupHealthyEndpoints(deps, testHostname)
stop := startWatcher(t, w)
// Wait until the initial scan has completed (RunOnce saves
// state as its final step), then allow a grace period for
// any notification that would follow it.
scanned := waitFor(scanTimeout, func() bool {
return !deps.state.GetSnapshot().LastUpdated.IsZero()
})
time.Sleep(notifyGrace)
stop()
if !scanned {
t.Fatal("initial live scan did not complete in time")
}
if countTitled(deps, startupTitle) != 0 {
t.Error(
"test notification should not be sent when disabled",
)
}
}
func TestNSFailureAndRecovery(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHostname}
w, deps := newTestWatcher(t, cfg)
ctx := t.Context()
w.RunOnce(ctx)
tamperNSBaseline(t, deps)
w.RunOnce(ctx)
// fakeNS was recorded as healthy but is absent from live
// results: a disappearance.
if countTitled(deps, "NS Failure: ") == 0 {
t.Error("expected notification for NS disappearance")
}
// A real nameserver was recorded as failed but answers in
// live results: a recovery.
if countTitled(deps, "NS Recovery: ") == 0 {
t.Error("expected notification for NS recovery")
}
}
// tamperNSBaseline rewrites the recorded hostname state so that
// one real (live-observed) nameserver is marked as failed and a
// synthetic nameserver is recorded as healthy. The next live run
// must report the synthetic one as disappeared and the real one
// as recovered.
func tamperNSBaseline(t *testing.T, deps *testDeps) {
t.Helper()
hs, ok := deps.state.GetHostnameState(testHostname)
if !ok || len(hs.RecordsByNameserver) == 0 {
t.Fatal("expected hostname state after first live run")
}
tampered := &state.HostnameState{
RecordsByNameserver: make(
map[string]*state.NameserverRecordState,
),
LastChecked: hs.LastChecked,
}
realNS := make([]string, 0, len(hs.RecordsByNameserver))
for ns, rec := range hs.RecordsByNameserver {
tampered.RecordsByNameserver[ns] = rec
realNS = append(realNS, ns)
}
sort.Strings(realNS)
failed := realNS[0]
tampered.RecordsByNameserver[failed] = &state.NameserverRecordState{
Records: hs.RecordsByNameserver[failed].Records,
Status: "error",
Error: "synthetic baseline failure",
LastChecked: time.Now().UTC(),
}
tampered.RecordsByNameserver[fakeNS] = &state.NameserverRecordState{
Records: map[string][]string{"A": {fakeIP}},
Status: "ok",
LastChecked: time.Now().UTC(),
}
deps.state.SetHostnameState(testHostname, tampered)
}