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) }