check / check (push) Successful in 1m6s
For a name with a CNAME, a nameserver answers a query of any type with that CNAME, and the records of every answer were added, so the CNAME was stored once for each of the eight record types asked for. collectAnswerRecords now adds each value once per record type. A state file saved before this holds the repeated values. Load keeps each record value once, so the first check after upgrading sees no record change and notifies nothing for them. Model: opus-5-5
241 lines
6.2 KiB
Go
241 lines
6.2 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFirstCheckAfterRepeatedValuesLoaded saves a state file holding a
|
|
// hostname's CNAME once for every record type asked for, as checks did
|
|
// before each value was stored once, and two addresses each repeated,
|
|
// and loads it. A check that then finds each value once at each
|
|
// nameserver must notify nothing.
|
|
func TestFirstCheckAfterRepeatedValuesLoaded(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
cnameType = "CNAME"
|
|
cname = "c.example.net."
|
|
)
|
|
|
|
cfg := defaultTestConfig(t)
|
|
repeated := map[string][]string{
|
|
"A": {ip2, ip1, ip2, ip1},
|
|
cnameType: {cname, cname, cname, cname, cname, cname, cname, cname},
|
|
}
|
|
once := map[string][]string{"A": {ip1, ip2}, cnameType: {cname}}
|
|
|
|
saved := newTestDeps(t, cfg).state
|
|
saved.SetHostnameState(host, hostnameState(map[string]map[string][]string{
|
|
nsA: repeated, nsB: repeated,
|
|
}))
|
|
|
|
err := saved.Save()
|
|
if err != nil {
|
|
t.Fatalf("saving the state file: %v", err)
|
|
}
|
|
|
|
deps := newTestDeps(t, cfg)
|
|
|
|
err = deps.state.Load()
|
|
if err != nil {
|
|
t.Fatalf("loading the state file: %v", err)
|
|
}
|
|
|
|
prev, ok := deps.state.GetHostnameState(host)
|
|
if !ok {
|
|
t.Fatal("the state file has no state for " + host)
|
|
}
|
|
|
|
current := hostnameState(map[string]map[string][]string{
|
|
nsA: once, nsB: once,
|
|
})
|
|
|
|
// The hostname change detection uses only the notifier.
|
|
w := watcher.NewForTest(nil, nil, nil, nil, nil, deps.notifier)
|
|
w.DetectHostnameChanges(t.Context(), host, prev, current)
|
|
|
|
if got := deps.notifier.getNotifications(); len(got) != 0 {
|
|
t.Errorf("sent %v, want no notification", got)
|
|
}
|
|
}
|