Files
dnswatcher/internal/watcher/watcher_test.go
T
clawbot 6b0fdc477a
check / check (push) Failing after 13s
test: restore transport-failure coverage with loopback nameservers
The DNS-mock removal deleted TestQueryNameserverIP_Timeout and left a
comment in its place, so the resolver's StatusTimeout / StatusError
classification branch went untested. The stated obstacle was that a
query to a black-holed RFC 5737 address comes back StatusOK, because
the build environment transparently intercepts UDP/53 and answers it
locally. That is a property of that environment, not of the resolver,
and it only rules out choosing a remote address.

internal/resolver/transport_test.go binds real nameservers on
127.0.0.1 instead and aims the query at them: one silent on A queries
and answering every other type (StatusTimeout), one answering SERVFAIL
(StatusError), and one address with nothing listening, which is
refused rather than dropped and so classifies as NoData. This is not a
mock — no DNSClient is substituted. The resolver dials a real socket,
writes a real query with the real miekg/dns client, and applies its
real deadline and real classification logic to what comes back.
Substituting the client is what TESTING.md bans; choosing the server
is not, and the resolver is aimed at a caller-chosen nameserver in
production too.

queryDNS now dials a nameserver address that already carries a port as
written, defaulting to 53 only for a bare address. That is what makes
a nameserver on any other port reachable, on loopback or otherwise.

Silence on one record type rather than all eight keeps the timeout
test to two query timeouts (4s) instead of sixteen (32s), and it is
asserted: the test fails if it ever costs more than 8s.

The watcher's assertStatePopulated and TestDomainPortAndTLSChecks
asserted only that hostname, port and certificate state were
non-empty, plus non-zero checker call counts. Neither was vacuous, but
neither would have caught the watcher resolving the wrong addresses.
The port and TLS test doubles now record their arguments, and both
tests assert that the state keys and the arguments the checkers were
actually called with match the addresses live DNS returned — exactly
those, no more and no fewer. Verified by mutation: making the watcher
drop all but one resolved address fails both tests, and it passed both
of them before.

TESTING.md records why a loopback nameserver is not a mock, so the new
tests are not mistaken for a violation of the rule they respect.
2026-09-03 22:58:01 +00:00

1070 lines
25 KiB
Go

package watcher_test
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"slices"
"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
// monitoredTestPorts and tlsCheckPort mirror the ports the
// watcher checks. They are duplicated here deliberately: a
// test that imported the production values would agree with
// the watcher no matter which ports it drifted to.
tlsCheckPort = 443
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 ---
// portCall records one CheckPort invocation. The arguments matter,
// not just the count: a watcher that dutifully checks ports on the
// wrong addresses produces exactly the same call count as one that
// resolved correctly, so the tests assert what was checked.
type portCall struct {
address string
port int
}
type mockPortChecker struct {
mu sync.Mutex
openAll bool
err error
calls int
seen []portCall
}
func (m *mockPortChecker) CheckPort(
_ context.Context,
address string,
port int,
) (*portcheck.PortResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls++
m.seen = append(m.seen, portCall{address: address, port: port})
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
}
// checkedKeys returns the distinct "address:port" pairs the checker
// was asked about, sorted, in the same form as the state store's
// port keys so the two can be compared directly.
func (m *mockPortChecker) checkedKeys() []string {
m.mu.Lock()
defer m.mu.Unlock()
keys := make([]string, 0, len(m.seen))
for _, c := range m.seen {
keys = append(
keys, fmt.Sprintf("%s:%d", c.address, c.port),
)
}
return sortedSet(keys)
}
// tlsCall records one CheckCertificate invocation, for the same
// reason portCall records CheckPort's arguments.
type tlsCall struct {
address string
hostname string
}
type mockTLSChecker struct {
mu sync.Mutex
cert *tlscheck.CertificateInfo
err error
calls int
seen []tlsCall
}
func (m *mockTLSChecker) CheckCertificate(
_ context.Context,
address string,
hostname string,
) (*tlscheck.CertificateInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls++
m.seen = append(
m.seen, tlsCall{address: address, hostname: hostname},
)
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
}
// checkedKeys returns the distinct certificate keys the checker was
// asked about, sorted, in the state store's "address:port:hostname"
// form so the two can be compared directly.
func (m *mockTLSChecker) checkedKeys() []string {
m.mu.Lock()
defer m.mu.Unlock()
keys := make([]string, 0, len(m.seen))
for _, c := range m.seen {
keys = append(keys, fmt.Sprintf(
"%s:%d:%s", c.address, tlsCheckPort, c.hostname,
))
}
return sortedSet(keys)
}
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
}
// monitoredTestPorts is the set of ports the watcher is expected to
// check on every resolved address.
func monitoredTestPorts() []int {
return []int{80, tlsCheckPort}
}
// sortedSet deduplicates and sorts, so that call records and state
// keys can be compared as sets.
func sortedSet(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
if seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
sort.Strings(out)
return out
}
// watchedNames is every name the config puts under observation.
// Domains get hostname state too, for their port and TLS checks.
func watchedNames(cfg *config.Config) []string {
names := make([]string, 0, len(cfg.Hostnames)+len(cfg.Domains))
names = append(names, cfg.Hostnames...)
names = append(names, cfg.Domains...)
return sortedSet(names)
}
// stateHostnames is the set of names the run actually recorded.
func stateHostnames(snap state.Snapshot) []string {
names := make([]string, 0, len(snap.Hostnames))
for name := range snap.Hostnames {
names = append(names, name)
}
return sortedSet(names)
}
// expectedPortKeys is every "address:port" the watcher should have
// touched: the monitored ports on every address live DNS resolved
// for every watched name, and nothing else.
func expectedPortKeys(
snap state.Snapshot,
names []string,
) []string {
var keys []string
for _, name := range names {
for _, ip := range liveIPs(snap, name) {
for _, port := range monitoredTestPorts() {
keys = append(
keys, fmt.Sprintf("%s:%d", ip, port),
)
}
}
}
return sortedSet(keys)
}
// expectedCertKeys is every "address:port:hostname" the watcher
// should have a certificate for, given that every port reports open.
func expectedCertKeys(
snap state.Snapshot,
names []string,
) []string {
var keys []string
for _, name := range names {
for _, ip := range liveIPs(snap, name) {
keys = append(keys, fmt.Sprintf(
"%s:%d:%s", ip, tlsCheckPort, name,
))
}
}
return sortedSet(keys)
}
// stateKeys sorts a state map's keys for set comparison.
func stateKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return sortedSet(keys)
}
// assertSameSet fails with both sides named when they differ.
func assertSameSet(t *testing.T, what string, want, got []string) {
t.Helper()
if !slices.Equal(want, got) {
t.Errorf(
"%s: expected %v, got %v", what, want, got,
)
}
}
// --- 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),
)
}
// Hostname state covers both explicit hostnames and domains
// (domains also get hostname state for port/TLS checks), and
// covers exactly those: a name in state that nothing asked
// for, or a configured name missing from it, is a bug.
names := watchedNames(deps.config)
assertSameSet(
t, "hostnames in state", names, stateHostnames(snap),
)
// Every watched name must have resolved to something, or the
// key comparisons below would pass vacuously on empty sets.
for _, name := range names {
if len(liveIPs(snap, name)) == 0 {
t.Errorf("no addresses resolved for %s", name)
}
}
// The addresses the port checker was aimed at must be the
// addresses live DNS returned. Counting calls would not
// notice the watcher checking the wrong hosts.
assertSameSet(
t, "ports checked",
expectedPortKeys(snap, names),
deps.portChecker.checkedKeys(),
)
}
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()
names := watchedNames(cfg)
ips := liveIPs(snap, testDomain)
if len(ips) == 0 {
t.Fatal("live DNS resolved no addresses for " + testDomain)
}
// Port and certificate state must be keyed by the addresses
// live DNS actually returned — every one of them, and no
// others. Asserting only that the maps are non-empty would
// hold just as well if the watcher had resolved the wrong
// name or dropped all but one of its addresses.
wantPorts := expectedPortKeys(snap, names)
assertSameSet(t, "port state", wantPorts, stateKeys(snap.Ports))
assertSameSet(
t, "ports checked", wantPorts,
deps.portChecker.checkedKeys(),
)
wantCerts := expectedCertKeys(snap, names)
assertSameSet(
t, "certificate state", wantCerts,
stateKeys(snap.Certificates),
)
assertSameSet(
t, "certificates checked", wantCerts,
deps.tlsChecker.checkedKeys(),
)
}
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)
}