checker-resolver-propagation/checker/rules_test.go

348 lines
12 KiB
Go

package checker
import (
"context"
"errors"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestRules_AllAreUniqueAndNamed(t *testing.T) {
seen := map[string]bool{}
for _, r := range Rules() {
if r.Name() == "" {
t.Errorf("rule with empty name: %T", r)
}
if r.Description() == "" {
t.Errorf("rule %s has empty description", r.Name())
}
if seen[r.Name()] {
t.Errorf("duplicate rule name: %s", r.Name())
}
seen[r.Name()] = true
}
if len(seen) < 10 {
t.Errorf("expected many rules, got %d", len(seen))
}
}
func TestLoadData_ObsError(t *testing.T) {
obs := &errObs{err: errors.New("boom")}
data, st := loadData(context.Background(), obs)
if data != nil {
t.Errorf("data should be nil on error")
}
if st == nil || st.Status != sdk.StatusError {
t.Errorf("want error state, got %+v", st)
}
}
// runRule is a tiny helper to evaluate a CheckRule with a payload.
func runRule(t *testing.T, r sdk.CheckRule, data *ResolverPropagationData, opts sdk.CheckerOptions) []sdk.CheckState {
t.Helper()
return r.Evaluate(context.Background(), newFakeObs(data), opts)
}
func TestResolverSelectionRule(t *testing.T) {
// Empty resolver map → crit.
st := runRule(t, &resolverSelectionRule{}, &ResolverPropagationData{Zone: "ex."}, nil)
if len(st) != 1 || st[0].Status != sdk.StatusCrit || st[0].Code != CodeNoResolvers {
t.Errorf("empty: %+v", st)
}
// Non-empty → ok.
data := &ResolverPropagationData{Resolvers: map[string]*ResolverView{"a": {ID: "a"}}}
st = runRule(t, &resolverSelectionRule{}, data, nil)
if len(st) != 1 || st[0].Status != sdk.StatusOK {
t.Errorf("ok: %+v", st)
}
}
func TestResolversReachableRule(t *testing.T) {
// No resolvers → unknown.
st := runRule(t, &resolversReachableRule{}, &ResolverPropagationData{}, nil)
if len(st) != 1 || st[0].Status != sdk.StatusUnknown {
t.Errorf("empty: %+v", st)
}
// All unreachable → crit.
data := &ResolverPropagationData{
Zone: "ex.",
Resolvers: map[string]*ResolverView{
"a": {ID: "a", Reachable: false},
},
}
st = runRule(t, &resolversReachableRule{}, data, nil)
if len(st) != 1 || st[0].Status != sdk.StatusCrit || st[0].Code != CodeAllResolversDown {
t.Errorf("all-down: %+v", st)
}
// One reachable → ok.
data.Resolvers["a"].Reachable = true
data.Stats.ReachableResolvers = 1
data.Stats.TotalResolvers = 1
st = runRule(t, &resolversReachableRule{}, data, nil)
if len(st) != 1 || st[0].Status != sdk.StatusOK {
t.Errorf("reach: %+v", st)
}
}
func TestConsensusRule_PartialPropagation(t *testing.T) {
key := "ex./A"
data := &ResolverPropagationData{
Resolvers: map[string]*ResolverView{
"a": mkResolver("a", "eu", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "1.1.1.1")}),
"b": mkResolver("b", "na", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "9.9.9.9")}),
},
RRsets: map[string]*RRsetView{key: {Name: "ex.", Type: "A"}},
}
st := runRule(t, &consensusRule{}, data, nil)
codes := statesByCode(st)
if _, ok := codes[CodePartialPropagation]; !ok {
t.Errorf("want partial propagation, got %+v", st)
}
}
func TestConsensusRule_AllAgree(t *testing.T) {
key := "ex./A"
data := &ResolverPropagationData{
Resolvers: map[string]*ResolverView{
"a": mkResolver("a", "eu", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "1.1.1.1")}),
"b": mkResolver("b", "na", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "1.1.1.1")}),
},
RRsets: map[string]*RRsetView{key: {Name: "ex.", Type: "A"}},
}
st := runRule(t, &consensusRule{}, data, nil)
if len(st) != 1 || st[0].Status != sdk.StatusOK {
t.Errorf("want OK, got %+v", st)
}
}
func TestAuthoritativeMatchRule(t *testing.T) {
key := "ex./A"
mkData := func(expected, returned string) *ResolverPropagationData {
return &ResolverPropagationData{
Resolvers: map[string]*ResolverView{
"a": mkResolver("a", "eu", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", returned)}),
},
RRsets: map[string]*RRsetView{key: {Name: "ex.", Type: "A", Expected: expected}},
}
}
// Match.
st := runRule(t, &authoritativeMatchRule{}, mkData("1.1.1.1", "1.1.1.1"), nil)
if len(st) != 1 || st[0].Status != sdk.StatusOK {
t.Errorf("match: %+v", st)
}
// Drift.
st = runRule(t, &authoritativeMatchRule{}, mkData("1.1.1.1", "9.9.9.9"), nil)
if len(st) != 1 || st[0].Code != CodeAnswerDrift {
t.Errorf("drift: %+v", st)
}
// No expected anywhere → skipped.
skipped := &ResolverPropagationData{
Resolvers: map[string]*ResolverView{"a": mkResolver("a", "eu", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "1")})},
RRsets: map[string]*RRsetView{key: {Name: "ex.", Type: "A"}}, // no Expected
}
st = runRule(t, &authoritativeMatchRule{}, skipped, nil)
if len(st) != 1 || st[0].Status != sdk.StatusUnknown {
t.Errorf("skipped: %+v", st)
}
}
func TestNXDOMAINRule(t *testing.T) {
key := "ex./A"
// Some resolvers say NXDOMAIN, others NOERROR.
data := &ResolverPropagationData{
Resolvers: map[string]*ResolverView{
"a": mkResolver("a", "eu", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "1.1.1.1")}),
"nx": mkResolver("nx", "eu", false, true, map[string]*RRProbe{key: mkProbe("NXDOMAIN", "")}),
},
RRsets: map[string]*RRsetView{key: {Name: "ex.", Type: "A"}},
}
st := runRule(t, &nxdomainRule{}, data, nil)
if _, ok := statesByCode(st)[CodeUnexpectedNXDOMAIN]; !ok {
t.Errorf("want NXDOMAIN finding, got %+v", st)
}
// All same NXDOMAIN ⇒ rule does NOT fire (it's an "unexpected" rule).
for _, rv := range data.Resolvers {
rv.Probes[key] = mkProbe("NXDOMAIN", "")
}
st = runRule(t, &nxdomainRule{}, data, nil)
if _, ok := statesByCode(st)[CodeUnexpectedNXDOMAIN]; ok {
t.Errorf("uniform NXDOMAIN should not trigger, got %+v", st)
}
}
func TestSERVFAILRule(t *testing.T) {
key := "ex./A"
data := &ResolverPropagationData{
Resolvers: map[string]*ResolverView{
"a": mkResolver("a", "eu", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "1.1.1.1")}),
"sf": mkResolver("sf", "eu", false, true, map[string]*RRProbe{key: mkProbe("SERVFAIL", "")}),
},
RRsets: map[string]*RRsetView{key: {Name: "ex.", Type: "A"}},
}
st := runRule(t, &servfailRule{}, data, nil)
if _, ok := statesByCode(st)[CodeUnexpectedSERVFAIL]; !ok {
t.Errorf("want SERVFAIL finding, got %+v", st)
}
}
func TestRegionalSplitRule(t *testing.T) {
key := "ex./A"
// EU resolvers all see "9.9.9.9", global resolvers see "1.1.1.1" (consensus).
data := &ResolverPropagationData{
Resolvers: map[string]*ResolverView{
"g1": mkResolver("g1", "global", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "1.1.1.1")}),
"g2": mkResolver("g2", "global", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "1.1.1.1")}),
"g3": mkResolver("g3", "global", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "1.1.1.1")}),
"eu1": mkResolver("eu1", "eu", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "9.9.9.9")}),
"eu2": mkResolver("eu2", "eu", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "9.9.9.9")}),
},
RRsets: map[string]*RRsetView{key: {Name: "ex.", Type: "A"}},
}
st := runRule(t, &regionalSplitRule{}, data, nil)
if _, ok := statesByCode(st)[CodeRegionalSplit]; !ok {
t.Errorf("want regional split, got %+v", st)
}
}
func TestSerialDriftRule(t *testing.T) {
soaKey := rrsetKey("ex.", "SOA")
mk := func(serial string) *RRProbe {
return &RRProbe{Rcode: "NOERROR", Records: []string{"ns. hm. " + serial + " 1 2 3 4"}, Transport: TransportUDP}
}
data := &ResolverPropagationData{
Zone: "ex.",
Resolvers: map[string]*ResolverView{
"a": mkResolver("a", "eu", false, true, map[string]*RRProbe{soaKey: mk("100")}),
"b": mkResolver("b", "eu", false, true, map[string]*RRProbe{soaKey: mk("100")}),
"c": mkResolver("c", "eu", false, true, map[string]*RRProbe{soaKey: mk("99")}),
},
RRsets: map[string]*RRsetView{soaKey: {Name: "ex.", Type: "SOA"}},
}
st := runRule(t, &serialDriftRule{}, data, nil)
if len(st) != 1 || st[0].Code != CodeSerialDrift {
t.Errorf("want serial drift, got %+v", st)
}
// All same → ok.
data.Resolvers["c"].Probes[soaKey] = mk("100")
st = runRule(t, &serialDriftRule{}, data, nil)
if len(st) != 1 || st[0].Status != sdk.StatusOK {
t.Errorf("want ok, got %+v", st)
}
// SOA not probed → skipped.
delete(data.RRsets, soaKey)
st = runRule(t, &serialDriftRule{}, data, nil)
if len(st) != 1 || st[0].Status != sdk.StatusUnknown {
t.Errorf("want skipped, got %+v", st)
}
}
func TestStaleCacheRule(t *testing.T) {
soaKey := rrsetKey("ex.", "SOA")
mk := func(serial string) *RRProbe {
return &RRProbe{Rcode: "NOERROR", Records: []string{"ns. hm. " + serial + " 1 2 3 4"}, Transport: TransportUDP}
}
// No declared serial → skipped.
data := &ResolverPropagationData{Zone: "ex.", RRsets: map[string]*RRsetView{soaKey: {Type: "SOA"}}}
st := runRule(t, &staleCacheRule{}, data, nil)
if st[0].Status != sdk.StatusUnknown {
t.Errorf("no declared: %+v", st)
}
// Below declared → info.
data = &ResolverPropagationData{
Zone: "ex.",
DeclaredSerial: 100,
Resolvers: map[string]*ResolverView{
"old": mkResolver("old", "eu", false, true, map[string]*RRProbe{soaKey: mk("99")}),
"new": mkResolver("new", "eu", false, true, map[string]*RRProbe{soaKey: mk("100")}),
},
RRsets: map[string]*RRsetView{soaKey: {Type: "SOA"}},
}
st = runRule(t, &staleCacheRule{}, data, nil)
if len(st) != 1 || st[0].Code != CodeStaleCache {
t.Errorf("stale: %+v", st)
}
if !strings.Contains(st[0].Message, "old") {
t.Errorf("stale msg should name resolver: %q", st[0].Message)
}
// All up-to-date.
data.Resolvers["old"].Probes[soaKey] = mk("100")
st = runRule(t, &staleCacheRule{}, data, nil)
if st[0].Status != sdk.StatusOK {
t.Errorf("ok: %+v", st)
}
}
func TestDNSSECRule(t *testing.T) {
soaKey := rrsetKey("ex.", "SOA")
data := &ResolverPropagationData{
Zone: "ex.",
Resolvers: map[string]*ResolverView{
// validating + AD set → no finding
"cloudflare": mkResolver("cloudflare", "global", false, true, map[string]*RRProbe{soaKey: {Rcode: "NOERROR", AD: true}}),
// validating + SERVFAIL → DNSSEC failure
"google": mkResolver("google", "global", false, true, map[string]*RRProbe{soaKey: {Rcode: "SERVFAIL"}}),
// validating + NOERROR + AD=false → unvalidated info
"quad9": mkResolver("quad9", "global", false, true, map[string]*RRProbe{soaKey: {Rcode: "NOERROR", AD: false}}),
// non-validating: ignored
"opendns": mkResolver("opendns", "global", false, true, map[string]*RRProbe{soaKey: {Rcode: "SERVFAIL"}}),
},
RRsets: map[string]*RRsetView{soaKey: {Type: "SOA"}},
}
st := runRule(t, &dnssecRule{}, data, nil)
codes := statesByCode(st)
if _, ok := codes[CodeDNSSECFailure]; !ok {
t.Errorf("want DNSSEC failure, got %+v", st)
}
if _, ok := codes[CodeDNSSECUnvalidated]; !ok {
t.Errorf("want DNSSEC unvalidated, got %+v", st)
}
}
func TestResolverLatencyRule(t *testing.T) {
key := "ex./A"
data := &ResolverPropagationData{
Resolvers: map[string]*ResolverView{
"slow": mkResolver("slow", "eu", false, true, map[string]*RRProbe{key: {Rcode: "NOERROR", LatencyMs: 1500, Transport: TransportUDP}}),
"fast": mkResolver("fast", "eu", false, true, map[string]*RRProbe{key: {Rcode: "NOERROR", LatencyMs: 30, Transport: TransportUDP}}),
"absent": mkResolver("absent", "eu", false, false, map[string]*RRProbe{key: {Error: "timeout", Transport: TransportUDP}}),
},
}
st := runRule(t, &resolverLatencyRule{}, data, sdk.CheckerOptions{"latencyThresholdMs": 500})
codes := statesByCode(st)
if _, ok := codes[CodeResolverHighLatency]; !ok {
t.Errorf("want high latency for 'slow', got %+v", st)
}
if _, ok := codes[CodeResolverUnreachable]; !ok {
t.Errorf("want unreachable for 'absent', got %+v", st)
}
}
func TestFilteredHitRule(t *testing.T) {
key := "ex./A"
data := &ResolverPropagationData{
Resolvers: map[string]*ResolverView{
"clean1": mkResolver("clean1", "eu", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "1.1.1.1")}),
"clean2": mkResolver("clean2", "eu", false, true, map[string]*RRProbe{key: mkProbe("NOERROR", "1.1.1.1")}),
"filt": mkResolver("filt", "eu", true, true, map[string]*RRProbe{key: mkProbe("NOERROR", "0.0.0.0")}),
},
RRsets: map[string]*RRsetView{key: {Name: "ex.", Type: "A"}},
}
st := runRule(t, &filteredHitRule{}, data, nil)
if _, ok := statesByCode(st)[CodeResolverFilteredHit]; !ok {
t.Errorf("want filtered hit, got %+v", st)
}
}