All checks were successful
check / check (push) Successful in 5s
When set to a truthy value, sends a startup status notification to all configured notification channels after the first full scan completes on application startup. The notification is clearly an all-ok/success message showing the number of monitored domains, hostnames, ports, and certificates. Changes: - Added `SendTestNotification` config field reading `DNSWATCHER_SEND_TEST_NOTIFICATION` - Added `maybeSendTestNotification()` in watcher, called after initial `RunOnce` in `Run` - Added 3 watcher tests (enabled via Run, enabled via RunOnce alone, disabled) - Added config tests for the new field - Updated README: env var table, example .env, Docker example Closes #84 Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: #85 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
905 lines
20 KiB
Go
905 lines
20 KiB
Go
package watcher_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/dnswatcher/internal/config"
|
|
"sneak.berlin/go/dnswatcher/internal/portcheck"
|
|
"sneak.berlin/go/dnswatcher/internal/state"
|
|
"sneak.berlin/go/dnswatcher/internal/tlscheck"
|
|
"sneak.berlin/go/dnswatcher/internal/watcher"
|
|
)
|
|
|
|
// errNotFound is returned when mock data is missing.
|
|
var errNotFound = errors.New("not found")
|
|
|
|
// --- Mock implementations ---
|
|
|
|
type mockResolver struct {
|
|
mu sync.Mutex
|
|
nsRecords map[string][]string
|
|
allRecords map[string]map[string]map[string][]string
|
|
ipAddresses map[string][]string
|
|
lookupNSErr error
|
|
allRecordsErr error
|
|
resolveIPErr error
|
|
lookupNSCalls int
|
|
allRecordCalls int
|
|
}
|
|
|
|
func (m *mockResolver) LookupNS(
|
|
_ context.Context,
|
|
domain string,
|
|
) ([]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.lookupNSCalls++
|
|
|
|
if m.lookupNSErr != nil {
|
|
return nil, m.lookupNSErr
|
|
}
|
|
|
|
ns, ok := m.nsRecords[domain]
|
|
if !ok {
|
|
return nil, fmt.Errorf(
|
|
"%w: NS for %s", errNotFound, domain,
|
|
)
|
|
}
|
|
|
|
return ns, nil
|
|
}
|
|
|
|
func (m *mockResolver) LookupAllRecords(
|
|
_ context.Context,
|
|
hostname string,
|
|
) (map[string]map[string][]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.allRecordCalls++
|
|
|
|
if m.allRecordsErr != nil {
|
|
return nil, m.allRecordsErr
|
|
}
|
|
|
|
recs, ok := m.allRecords[hostname]
|
|
if !ok {
|
|
return nil, fmt.Errorf(
|
|
"%w: records for %s", errNotFound, hostname,
|
|
)
|
|
}
|
|
|
|
return recs, nil
|
|
}
|
|
|
|
func (m *mockResolver) ResolveIPAddresses(
|
|
_ context.Context,
|
|
hostname string,
|
|
) ([]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if m.resolveIPErr != nil {
|
|
return nil, m.resolveIPErr
|
|
}
|
|
|
|
ips, ok := m.ipAddresses[hostname]
|
|
if !ok {
|
|
return nil, fmt.Errorf(
|
|
"%w: IPs for %s", errNotFound, hostname,
|
|
)
|
|
}
|
|
|
|
return ips, nil
|
|
}
|
|
|
|
type mockPortChecker struct {
|
|
mu sync.Mutex
|
|
results map[string]bool
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (m *mockPortChecker) CheckPort(
|
|
_ context.Context,
|
|
address string,
|
|
port int,
|
|
) (*portcheck.PortResult, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.calls++
|
|
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
|
|
key := fmt.Sprintf("%s:%d", address, port)
|
|
open := m.results[key]
|
|
|
|
return &portcheck.PortResult{Open: open}, nil
|
|
}
|
|
|
|
type mockTLSChecker struct {
|
|
mu sync.Mutex
|
|
certs map[string]*tlscheck.CertificateInfo
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (m *mockTLSChecker) CheckCertificate(
|
|
_ context.Context,
|
|
ip string,
|
|
hostname string,
|
|
) (*tlscheck.CertificateInfo, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.calls++
|
|
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
|
|
key := fmt.Sprintf("%s:%s", ip, hostname)
|
|
cert, ok := m.certs[key]
|
|
|
|
if !ok {
|
|
return nil, fmt.Errorf(
|
|
"%w: cert for %s", errNotFound, key,
|
|
)
|
|
}
|
|
|
|
return cert, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// --- Helper to build a Watcher for testing ---
|
|
|
|
type testDeps struct {
|
|
resolver *mockResolver
|
|
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{
|
|
resolver: &mockResolver{
|
|
nsRecords: make(map[string][]string),
|
|
allRecords: make(map[string]map[string]map[string][]string),
|
|
ipAddresses: make(map[string][]string),
|
|
},
|
|
portChecker: &mockPortChecker{
|
|
results: make(map[string]bool),
|
|
},
|
|
tlsChecker: &mockTLSChecker{
|
|
certs: make(map[string]*tlscheck.CertificateInfo),
|
|
},
|
|
notifier: &mockNotifier{},
|
|
config: cfg,
|
|
}
|
|
|
|
deps.state = state.NewForTest()
|
|
|
|
w := watcher.NewForTest(
|
|
deps.config,
|
|
deps.state,
|
|
deps.resolver,
|
|
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 TestFirstRunBaseline(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := defaultTestConfig(t)
|
|
cfg.Domains = []string{"example.com"}
|
|
cfg.Hostnames = []string{"www.example.com"}
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
setupBaselineMocks(deps)
|
|
|
|
w.RunOnce(t.Context())
|
|
|
|
assertNoNotifications(t, deps)
|
|
assertStatePopulated(t, deps)
|
|
}
|
|
|
|
func setupBaselineMocks(deps *testDeps) {
|
|
deps.resolver.nsRecords["example.com"] = []string{
|
|
"ns1.example.com.",
|
|
"ns2.example.com.",
|
|
}
|
|
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"93.184.216.34"}},
|
|
"ns2.example.com.": {"A": {"93.184.216.34"}},
|
|
}
|
|
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"93.184.216.34"}},
|
|
"ns2.example.com.": {"A": {"93.184.216.34"}},
|
|
}
|
|
deps.resolver.ipAddresses["www.example.com"] = []string{
|
|
"93.184.216.34",
|
|
}
|
|
deps.portChecker.results["93.184.216.34:80"] = true
|
|
deps.portChecker.results["93.184.216.34:443"] = true
|
|
deps.tlsChecker.certs["93.184.216.34:www.example.com"] = &tlscheck.CertificateInfo{
|
|
CommonName: "www.example.com",
|
|
Issuer: "DigiCert",
|
|
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
SubjectAlternativeNames: []string{
|
|
"www.example.com",
|
|
},
|
|
}
|
|
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
|
|
CommonName: "example.com",
|
|
Issuer: "DigiCert",
|
|
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
SubjectAlternativeNames: []string{
|
|
"example.com",
|
|
},
|
|
}
|
|
}
|
|
|
|
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",
|
|
len(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{"example.com"}
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
|
|
deps.resolver.nsRecords["example.com"] = []string{
|
|
"ns1.example.com.",
|
|
}
|
|
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"93.184.216.34"}},
|
|
}
|
|
deps.portChecker.results["93.184.216.34:80"] = true
|
|
deps.portChecker.results["93.184.216.34:443"] = true
|
|
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
|
|
CommonName: "example.com",
|
|
Issuer: "DigiCert",
|
|
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
SubjectAlternativeNames: []string{
|
|
"example.com",
|
|
},
|
|
}
|
|
|
|
w.RunOnce(t.Context())
|
|
|
|
snap := deps.state.GetSnapshot()
|
|
|
|
// Domain should have port state populated
|
|
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")
|
|
}
|
|
|
|
// Verify port checker was actually called
|
|
deps.portChecker.mu.Lock()
|
|
calls := deps.portChecker.calls
|
|
deps.portChecker.mu.Unlock()
|
|
|
|
if calls == 0 {
|
|
t.Error("expected port checker to be called for domain")
|
|
}
|
|
|
|
// Verify TLS checker was actually called
|
|
deps.tlsChecker.mu.Lock()
|
|
tlsCalls := deps.tlsChecker.calls
|
|
deps.tlsChecker.mu.Unlock()
|
|
|
|
if tlsCalls == 0 {
|
|
t.Error("expected TLS checker to be called for domain")
|
|
}
|
|
}
|
|
|
|
func TestNSChangeDetection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := defaultTestConfig(t)
|
|
cfg.Domains = []string{"example.com"}
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
|
|
deps.resolver.nsRecords["example.com"] = []string{
|
|
"ns1.example.com.",
|
|
"ns2.example.com.",
|
|
}
|
|
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
|
"ns2.example.com.": {"A": {"1.2.3.4"}},
|
|
}
|
|
deps.portChecker.results["1.2.3.4:80"] = false
|
|
deps.portChecker.results["1.2.3.4:443"] = false
|
|
|
|
ctx := t.Context()
|
|
w.RunOnce(ctx)
|
|
|
|
deps.resolver.mu.Lock()
|
|
deps.resolver.nsRecords["example.com"] = []string{
|
|
"ns1.example.com.",
|
|
"ns3.example.com.",
|
|
}
|
|
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
|
"ns3.example.com.": {"A": {"1.2.3.4"}},
|
|
}
|
|
deps.resolver.mu.Unlock()
|
|
|
|
w.RunOnce(ctx)
|
|
|
|
notifications := deps.notifier.getNotifications()
|
|
if len(notifications) == 0 {
|
|
t.Error("expected notification for NS change")
|
|
}
|
|
|
|
found := false
|
|
|
|
for _, n := range notifications {
|
|
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{"www.example.com"}
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
|
|
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"93.184.216.34"}},
|
|
}
|
|
deps.resolver.ipAddresses["www.example.com"] = []string{
|
|
"93.184.216.34",
|
|
}
|
|
deps.portChecker.results["93.184.216.34:80"] = false
|
|
deps.portChecker.results["93.184.216.34:443"] = false
|
|
|
|
ctx := t.Context()
|
|
w.RunOnce(ctx)
|
|
|
|
deps.resolver.mu.Lock()
|
|
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"93.184.216.35"}},
|
|
}
|
|
deps.resolver.ipAddresses["www.example.com"] = []string{
|
|
"93.184.216.35",
|
|
}
|
|
deps.resolver.mu.Unlock()
|
|
|
|
deps.portChecker.mu.Lock()
|
|
deps.portChecker.results["93.184.216.35:80"] = false
|
|
deps.portChecker.results["93.184.216.35:443"] = false
|
|
deps.portChecker.mu.Unlock()
|
|
|
|
w.RunOnce(ctx)
|
|
|
|
notifications := deps.notifier.getNotifications()
|
|
if len(notifications) == 0 {
|
|
t.Error("expected notification for record change")
|
|
}
|
|
}
|
|
|
|
func TestPortStateChange(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := defaultTestConfig(t)
|
|
cfg.Hostnames = []string{"www.example.com"}
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
|
|
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
|
}
|
|
deps.resolver.ipAddresses["www.example.com"] = []string{
|
|
"1.2.3.4",
|
|
}
|
|
deps.portChecker.results["1.2.3.4:80"] = true
|
|
deps.portChecker.results["1.2.3.4:443"] = true
|
|
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
|
|
CommonName: "www.example.com",
|
|
Issuer: "DigiCert",
|
|
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
SubjectAlternativeNames: []string{
|
|
"www.example.com",
|
|
},
|
|
}
|
|
|
|
ctx := t.Context()
|
|
w.RunOnce(ctx)
|
|
|
|
deps.portChecker.mu.Lock()
|
|
deps.portChecker.results["1.2.3.4:443"] = false
|
|
deps.portChecker.mu.Unlock()
|
|
|
|
w.RunOnce(ctx)
|
|
|
|
notifications := deps.notifier.getNotifications()
|
|
if len(notifications) == 0 {
|
|
t.Error("expected notification for port state change")
|
|
}
|
|
}
|
|
|
|
func TestTLSExpiryWarning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := defaultTestConfig(t)
|
|
cfg.Hostnames = []string{"www.example.com"}
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
|
|
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
|
}
|
|
deps.resolver.ipAddresses["www.example.com"] = []string{
|
|
"1.2.3.4",
|
|
}
|
|
deps.portChecker.results["1.2.3.4:80"] = true
|
|
deps.portChecker.results["1.2.3.4:443"] = true
|
|
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
|
|
CommonName: "www.example.com",
|
|
Issuer: "DigiCert",
|
|
NotAfter: time.Now().Add(3 * 24 * time.Hour),
|
|
SubjectAlternativeNames: []string{
|
|
"www.example.com",
|
|
},
|
|
}
|
|
|
|
ctx := t.Context()
|
|
|
|
// First run = baseline
|
|
w.RunOnce(ctx)
|
|
|
|
// Second run should warn about expiry
|
|
w.RunOnce(ctx)
|
|
|
|
notifications := deps.notifier.getNotifications()
|
|
|
|
found := false
|
|
|
|
for _, n := range notifications {
|
|
if n.Priority == "warning" {
|
|
found = true
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Errorf(
|
|
"expected expiry warning, got: %v",
|
|
notifications,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestTLSExpiryWarningDedup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := defaultTestConfig(t)
|
|
cfg.Hostnames = []string{"www.example.com"}
|
|
cfg.TLSInterval = 24 * time.Hour
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
|
|
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
|
}
|
|
deps.resolver.ipAddresses["www.example.com"] = []string{
|
|
"1.2.3.4",
|
|
}
|
|
deps.portChecker.results["1.2.3.4:80"] = true
|
|
deps.portChecker.results["1.2.3.4:443"] = true
|
|
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
|
|
CommonName: "www.example.com",
|
|
Issuer: "DigiCert",
|
|
NotAfter: time.Now().Add(3 * 24 * time.Hour),
|
|
SubjectAlternativeNames: []string{
|
|
"www.example.com",
|
|
},
|
|
}
|
|
|
|
ctx := t.Context()
|
|
|
|
// First run = baseline, no notifications
|
|
w.RunOnce(ctx)
|
|
|
|
// Second run should fire one expiry warning
|
|
w.RunOnce(ctx)
|
|
|
|
// Third run should NOT fire another warning (dedup)
|
|
w.RunOnce(ctx)
|
|
|
|
notifications := deps.notifier.getNotifications()
|
|
|
|
expiryCount := 0
|
|
|
|
for _, n := range notifications {
|
|
if n.Title == "TLS Expiry Warning: www.example.com" {
|
|
expiryCount++
|
|
}
|
|
}
|
|
|
|
if expiryCount != 1 {
|
|
t.Errorf(
|
|
"expected exactly 1 expiry warning (dedup), got %d",
|
|
expiryCount,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestGracefulShutdown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := defaultTestConfig(t)
|
|
cfg.Domains = []string{"example.com"}
|
|
cfg.DNSInterval = 100 * time.Millisecond
|
|
cfg.TLSInterval = 100 * time.Millisecond
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
|
|
deps.resolver.nsRecords["example.com"] = []string{
|
|
"ns1.example.com.",
|
|
}
|
|
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
|
}
|
|
deps.portChecker.results["1.2.3.4:80"] = false
|
|
deps.portChecker.results["1.2.3.4:443"] = false
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
w.Run(ctx)
|
|
close(done)
|
|
}()
|
|
|
|
time.Sleep(250 * time.Millisecond)
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
// Shut down cleanly
|
|
case <-time.After(5 * time.Second):
|
|
t.Error("watcher did not shut down within timeout")
|
|
}
|
|
}
|
|
|
|
func setupHostnameIP(
|
|
deps *testDeps,
|
|
hostname, ip string,
|
|
) {
|
|
deps.resolver.allRecords[hostname] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {ip}},
|
|
}
|
|
deps.portChecker.results[ip+":80"] = true
|
|
deps.portChecker.results[ip+":443"] = true
|
|
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
|
|
CommonName: hostname,
|
|
Issuer: "DigiCert",
|
|
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
SubjectAlternativeNames: []string{hostname},
|
|
}
|
|
}
|
|
|
|
func updateHostnameIP(deps *testDeps, hostname, ip string) {
|
|
deps.resolver.mu.Lock()
|
|
deps.resolver.allRecords[hostname] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {ip}},
|
|
}
|
|
deps.resolver.mu.Unlock()
|
|
|
|
deps.portChecker.mu.Lock()
|
|
deps.portChecker.results[ip+":80"] = true
|
|
deps.portChecker.results[ip+":443"] = true
|
|
deps.portChecker.mu.Unlock()
|
|
|
|
deps.tlsChecker.mu.Lock()
|
|
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
|
|
CommonName: hostname,
|
|
Issuer: "DigiCert",
|
|
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
SubjectAlternativeNames: []string{hostname},
|
|
}
|
|
deps.tlsChecker.mu.Unlock()
|
|
}
|
|
|
|
func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := defaultTestConfig(t)
|
|
cfg.Hostnames = []string{"www.example.com"}
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
|
|
setupHostnameIP(deps, "www.example.com", "10.0.0.1")
|
|
|
|
ctx := t.Context()
|
|
w.RunOnce(ctx)
|
|
|
|
snap := deps.state.GetSnapshot()
|
|
if _, ok := snap.Ports["10.0.0.1:80"]; !ok {
|
|
t.Fatal("expected port state for 10.0.0.1:80")
|
|
}
|
|
|
|
// DNS changes to a new IP; port and TLS must pick it up.
|
|
updateHostnameIP(deps, "www.example.com", "10.0.0.2")
|
|
|
|
w.RunOnce(ctx)
|
|
|
|
snap = deps.state.GetSnapshot()
|
|
|
|
if _, ok := snap.Ports["10.0.0.2:80"]; !ok {
|
|
t.Error("port check used stale DNS: missing 10.0.0.2:80")
|
|
}
|
|
|
|
certKey := "10.0.0.2:443:www.example.com"
|
|
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.Domains = []string{"example.com"}
|
|
cfg.Hostnames = []string{"www.example.com"}
|
|
cfg.SendTestNotification = true
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
setupBaselineMocks(deps)
|
|
|
|
w.RunOnce(t.Context())
|
|
|
|
// RunOnce does not send the test notification — it is
|
|
// sent by Run after RunOnce completes. Call the exported
|
|
// RunOnce then check that no test notification was sent
|
|
// (only Run triggers it). We test the full path via Run.
|
|
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.Domains = []string{"example.com"}
|
|
cfg.Hostnames = []string{"www.example.com"}
|
|
cfg.SendTestNotification = true
|
|
cfg.DNSInterval = 24 * time.Hour
|
|
cfg.TLSInterval = 24 * time.Hour
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
setupBaselineMocks(deps)
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
w.Run(ctx)
|
|
close(done)
|
|
}()
|
|
|
|
// Wait for the initial scan and test notification.
|
|
time.Sleep(500 * time.Millisecond)
|
|
cancel()
|
|
|
|
<-done
|
|
|
|
notifications := deps.notifier.getNotifications()
|
|
|
|
found := false
|
|
|
|
for _, n := range notifications {
|
|
if n.Priority == "success" &&
|
|
n.Title == "✅ dnswatcher startup complete" {
|
|
found = true
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Errorf(
|
|
"expected startup test notification, got: %v",
|
|
notifications,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSendTestNotification_Disabled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := defaultTestConfig(t)
|
|
cfg.Domains = []string{"example.com"}
|
|
cfg.Hostnames = []string{"www.example.com"}
|
|
cfg.SendTestNotification = false
|
|
cfg.DNSInterval = 24 * time.Hour
|
|
cfg.TLSInterval = 24 * time.Hour
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
setupBaselineMocks(deps)
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
w.Run(ctx)
|
|
close(done)
|
|
}()
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
cancel()
|
|
|
|
<-done
|
|
|
|
notifications := deps.notifier.getNotifications()
|
|
|
|
for _, n := range notifications {
|
|
if n.Title == "✅ dnswatcher startup complete" {
|
|
t.Error(
|
|
"test notification should not be sent when disabled",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNSFailureAndRecovery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := defaultTestConfig(t)
|
|
cfg.Hostnames = []string{"www.example.com"}
|
|
|
|
w, deps := newTestWatcher(t, cfg)
|
|
|
|
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
|
"ns2.example.com.": {"A": {"1.2.3.4"}},
|
|
}
|
|
deps.resolver.ipAddresses["www.example.com"] = []string{
|
|
"1.2.3.4",
|
|
}
|
|
deps.portChecker.results["1.2.3.4:80"] = false
|
|
deps.portChecker.results["1.2.3.4:443"] = false
|
|
|
|
ctx := t.Context()
|
|
|
|
w.RunOnce(ctx)
|
|
|
|
deps.resolver.mu.Lock()
|
|
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
|
|
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
|
}
|
|
deps.resolver.mu.Unlock()
|
|
|
|
w.RunOnce(ctx)
|
|
|
|
notifications := deps.notifier.getNotifications()
|
|
if len(notifications) == 0 {
|
|
t.Error("expected notification for NS disappearance")
|
|
}
|
|
}
|