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, ®ionalSplitRule{}, 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) } }