Files
dnswatcher/internal/watcher/watcher_test.go
sneak a535ae864b
All checks were successful
check / check (push) Successful in 53s
Remove DNS mocking from tests; use live DNS everywhere
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 23:40:38 +00:00

842 lines
18 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
}
func newTestWatcher(
t *testing.T,
cfg *config.Config,
) (*watcher.Watcher, *testDeps) {
t.Helper()
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)
}