package watcher_test import ( "context" "log/slog" "strings" "testing" "time" "sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/watcher" ) // answered is what a check saves for a nameserver that answered with // these records. func answered(records map[string][]string) *state.NameserverRecordState { return &state.NameserverRecordState{Records: records, Status: "ok"} } // failed is what a check saves for a nameserver that did not answer. func failed() *state.NameserverRecordState { return &state.NameserverRecordState{ Records: map[string][]string{}, Status: "error", Error: "all queries timed out", } } // saved builds the hostname state a check saves. func saved( byNameserver map[string]*state.NameserverRecordState, ) *state.HostnameState { return &state.HostnameState{RecordsByNameserver: byNameserver} } // alertCounts counts the hostname alerts sent, by kind. type alertCounts struct { failures, recoveries, recordChanges, inconsistencies int } // countAlerts runs the hostname change detection from the state loaded // at startup through each check in turn, and counts the alerts sent. func countAlerts( t *testing.T, loaded *state.HostnameState, checks []*state.HostnameState, ) alertCounts { t.Helper() // The hostname change detection uses only the notifier. notifier := &mockNotifier{} w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) prev := loaded for _, current := range checks { w.DetectHostnameChanges(t.Context(), host, prev, current) prev = current } var got alertCounts for _, n := range notifier.getNotifications() { kind, _, _ := strings.Cut(n.Title, ":") switch kind { case "NS Failure": got.failures++ case "NS Recovery": got.recoveries++ case "Record Change": got.recordChanges++ case "Inconsistency": got.inconsistencies++ } } return got } func TestNSFailureAndRecoveryAlerts(t *testing.T) { t.Parallel() records := map[string][]string{"A": {ip1}} bothAnswer := saved(map[string]*state.NameserverRecordState{ nsA: answered(records), nsB: answered(records), }) bFails := saved(map[string]*state.NameserverRecordState{ nsA: answered(records), nsB: failed(), }) onlyA := saved(map[string]*state.NameserverRecordState{ nsA: answered(records), }) bAnswersNoRecords := saved(map[string]*state.NameserverRecordState{ nsA: answered(records), nsB: answered(map[string][]string{}), }) bAnswersDifferently := saved(map[string]*state.NameserverRecordState{ nsA: answered(records), nsB: answered(map[string][]string{"A": {ip2}}), }) // Each case starts from the state loaded at startup and runs the // checks in order. tests := []struct { name string loaded *state.HostnameState checks []*state.HostnameState want alertCounts }{ { "failure lasting several checks alerts once", bothAnswer, []*state.HostnameState{bFails, bFails, bFails}, alertCounts{failures: 1}, }, { "recovery alerts once", bFails, []*state.HostnameState{bothAnswer, bothAnswer}, alertCounts{recoveries: 1}, }, { "failing again after recovering alerts again", bothAnswer, []*state.HostnameState{bFails, bothAnswer, bFails}, alertCounts{failures: 2, recoveries: 1}, }, { "nameserver failing when first seen does not alert", onlyA, []*state.HostnameState{bFails, bFails}, alertCounts{}, }, { "answer with no records is a record change, not a failure", bothAnswer, []*state.HostnameState{bAnswersNoRecords}, alertCounts{recordChanges: 1, inconsistencies: 1}, }, { "recovered nameserver that answers differently disagrees", bFails, []*state.HostnameState{bAnswersDifferently}, alertCounts{recoveries: 1, inconsistencies: 1}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := countAlerts(t, tt.loaded, tt.checks) if got != tt.want { t.Errorf("sent %+v, want %+v", got, tt.want) } }) } } func TestNSFailureAlertNamesNameserverAndReason(t *testing.T) { t.Parallel() records := map[string][]string{"A": {ip1}} notifier := &mockNotifier{} w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) w.DetectHostnameChanges( t.Context(), host, saved(map[string]*state.NameserverRecordState{nsA: answered(records)}), saved(map[string]*state.NameserverRecordState{nsA: failed()}), ) notifications := notifier.getNotifications() if len(notifications) != 1 { t.Fatalf("sent %v, want one NS Failure", notifications) } msg := notifications[0].Message if !strings.Contains(msg, nsA) || !strings.Contains(msg, failed().Error) { t.Errorf("message %q does not name %s and the reason", msg, nsA) } } // TestNameserverThatNeverAnswers asks a nameserver address where // nothing answers, 192.0.2.1, and checks what the watcher saves for it. // The deadline outlasts the resolver's first two-second try, as in the // resolver's timeout test. func TestNameserverThatNeverAnswers(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) t.Cleanup(cancel) res := resolver.NewFromLogger(slog.Default()) resp, err := res.QueryNameserverIP(ctx, nsA, "192.0.2.1", host) if err != nil { t.Fatal(err) } hs := watcher.BuildHostnameState( map[string]*resolver.NameserverResponse{nsA: resp}, time.Now(), ) got := hs.RecordsByNameserver[nsA] if got.Status != "error" || got.Error == "" { t.Errorf( "saved status %q, error %q; want status error with a reason", got.Status, got.Error, ) } }