check / check (push) Successful in 1m5s
LookupAllRecords now returns each nameserver's response, so the watcher saves its status: ok when it answered, NXDOMAIN and no records included, and error with the reason when it timed out, answered SERVFAIL or REFUSED, or could not be reached. A nameserver that starts failing sends NS Failure and one that answers again sends NS Recovery. A failing nameserver is left out of the record change and inconsistency comparisons. The resolver used to report REFUSED and network errors as an answer with no records; they are now errors. A lookup cut short by its context now returns an error instead of a failure of the nameserver it was querying. Model: opus-5-5
186 lines
4.7 KiB
Go
186 lines
4.7 KiB
Go
package watcher_test
|
|
|
|
import (
|
|
"slices"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/dnswatcher/internal/state"
|
|
"sneak.berlin/go/dnswatcher/internal/watcher"
|
|
)
|
|
|
|
const (
|
|
host = "www.example.net"
|
|
nsA = "a.ns.example.net."
|
|
nsB = "b.ns.example.net."
|
|
nsC = "c.ns.example.net."
|
|
ip1 = "192.0.2.1"
|
|
ip2 = "192.0.2.2"
|
|
ip3 = "192.0.2.3"
|
|
)
|
|
|
|
// hostnameState builds the state a check with these records leaves behind.
|
|
func hostnameState(
|
|
records map[string]map[string][]string,
|
|
) *state.HostnameState {
|
|
hs := &state.HostnameState{
|
|
RecordsByNameserver: make(map[string]*state.NameserverRecordState),
|
|
}
|
|
|
|
for ns, recs := range records {
|
|
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
|
|
Records: recs,
|
|
Status: "ok",
|
|
}
|
|
}
|
|
|
|
return hs
|
|
}
|
|
|
|
func TestNewlyDisagreeingPairs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}}
|
|
agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}}
|
|
disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}}
|
|
alert := [][2]string{{nsA, nsB}}
|
|
|
|
// b already disagrees with a and c; then c changes, so a and c,
|
|
// which agreed, now differ.
|
|
bDiffers := map[string]map[string][]string{
|
|
nsA: {"A": {ip1}}, nsB: {"A": {ip2}}, nsC: {"A": {ip1}},
|
|
}
|
|
cChanges := map[string]map[string][]string{
|
|
nsA: {"A": {ip1}}, nsB: {"A": {ip2}}, nsC: {"A": {ip3}},
|
|
}
|
|
|
|
// Each case starts from the state loaded at startup and runs the
|
|
// checks in order; want[i] is what check i alerts for.
|
|
tests := []struct {
|
|
name string
|
|
loaded map[string]map[string][]string
|
|
checks []map[string]map[string][]string
|
|
want [][][2]string
|
|
}{
|
|
{
|
|
name: "disagreement persisting across checks alerts once",
|
|
loaded: agree,
|
|
checks: []map[string]map[string][]string{disagree, disagree, disagree},
|
|
want: [][][2]string{alert, nil, nil},
|
|
},
|
|
{
|
|
name: "disagreement starting on a later check alerts on it",
|
|
loaded: agree,
|
|
checks: []map[string]map[string][]string{agree, agree, disagree},
|
|
want: [][][2]string{nil, nil, alert},
|
|
},
|
|
{
|
|
name: "disagreement in the loaded state does not alert",
|
|
loaded: disagree,
|
|
checks: []map[string]map[string][]string{disagree, disagree},
|
|
want: [][][2]string{nil, nil},
|
|
},
|
|
{
|
|
name: "nameserver new on the first check and disagreeing alerts once",
|
|
loaded: onlyA,
|
|
checks: []map[string]map[string][]string{disagree, disagree},
|
|
want: [][][2]string{alert, nil},
|
|
},
|
|
{
|
|
name: "disagreement after agreeing again alerts again",
|
|
loaded: agree,
|
|
checks: []map[string]map[string][]string{disagree, agree, disagree},
|
|
want: [][][2]string{alert, nil, alert},
|
|
},
|
|
{
|
|
name: "new disagreement while another nameserver differs alerts",
|
|
loaded: bDiffers,
|
|
checks: []map[string]map[string][]string{cChanges, cChanges},
|
|
want: [][][2]string{{{nsA, nsC}}, nil},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prev := hostnameState(tt.loaded)
|
|
|
|
for i, records := range tt.checks {
|
|
current := hostnameState(records)
|
|
|
|
got := watcher.NewlyDisagreeingPairs(prev, current)
|
|
if !slices.Equal(got, tt.want[i]) {
|
|
t.Errorf(
|
|
"check %d: alerted for %v, want %v",
|
|
i, got, tt.want[i],
|
|
)
|
|
}
|
|
|
|
prev = current
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInconsistencyAlert(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}}
|
|
agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}}
|
|
disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}}
|
|
|
|
// Each case starts from the state loaded at startup and then sees
|
|
// the nameservers disagree on three checks in a row.
|
|
tests := []struct {
|
|
name string
|
|
loaded map[string]map[string][]string
|
|
want int
|
|
}{
|
|
{
|
|
name: "disagreement lasting several checks alerts once",
|
|
loaded: agree,
|
|
want: 1,
|
|
},
|
|
{
|
|
name: "disagreement in the loaded state does not alert",
|
|
loaded: disagree,
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "nameserver new on the first check and disagreeing alerts once",
|
|
loaded: onlyA,
|
|
want: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The hostname change detection uses only the notifier.
|
|
notifier := &mockNotifier{}
|
|
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
|
|
|
|
prev := hostnameState(tt.loaded)
|
|
|
|
for range 3 {
|
|
current := hostnameState(disagree)
|
|
w.DetectHostnameChanges(t.Context(), host, prev, current)
|
|
prev = current
|
|
}
|
|
|
|
got := 0
|
|
|
|
for _, n := range notifier.getNotifications() {
|
|
if n.Title == "Inconsistency: "+host {
|
|
got++
|
|
}
|
|
}
|
|
|
|
if got != tt.want {
|
|
t.Errorf("sent %d inconsistency alerts, want %d", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|