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