348 lines
12 KiB
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, ®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)
|
|
}
|
|
}
|