checker-reverse-zone/checker/rules_test.go

366 lines
11 KiB
Go

package checker
import (
"context"
"encoding/json"
"errors"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// mockObs is a lightweight ObservationGetter for rule unit tests.
type mockObs struct {
data *ReverseZoneData
err error
}
func (m *mockObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
if m.err != nil {
return m.err
}
if key != ObservationKey || m.data == nil {
return errors.New("not found")
}
b, err := json.Marshal(m.data)
if err != nil {
return err
}
return json.Unmarshal(b, dest)
}
func (m *mockObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
func evalRule(t *testing.T, r sdk.CheckRule, data *ReverseZoneData, opts sdk.CheckerOptions) []sdk.CheckState {
t.Helper()
return r.Evaluate(context.Background(), &mockObs{data: data}, opts)
}
func findCode(states []sdk.CheckState, code string) *sdk.CheckState {
for i := range states {
if states[i].Code == code {
return &states[i]
}
}
return nil
}
// ---------- loadData ----------
func TestLoadData_Error(t *testing.T) {
obs := &mockObs{err: errors.New("boom")}
data, st := loadData(context.Background(), obs)
if data != nil {
t.Errorf("expected nil data, got %+v", data)
}
if st == nil || st.Status != sdk.StatusError {
t.Errorf("expected error CheckState, got %+v", st)
}
if st != nil && st.Code != "reverse_zone.observation_error" {
t.Errorf("Code=%q, want reverse_zone.observation_error", st.Code)
}
}
// ---------- isReverseZoneRule ----------
func TestIsReverseZoneRule(t *testing.T) {
r := &isReverseZoneRule{}
// LoadError surfaces.
st := evalRule(t, r, &ReverseZoneData{LoadError: "broken"}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone.load_error" {
t.Errorf("LoadError path: %+v", st)
}
// Not under arpa → critical.
st = evalRule(t, r, &ReverseZoneData{Zone: "example.com.", IsReverseZone: false}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone_not_arpa" || st[0].Status != sdk.StatusCrit {
t.Errorf("not-arpa path: %+v", st)
}
// Reverse zone → ok.
st = evalRule(t, r, &ReverseZoneData{Zone: "1.168.192.in-addr.arpa.", IsReverseZone: true}, nil)
if len(st) != 1 || st[0].Status != sdk.StatusOK {
t.Errorf("ok path: %+v", st)
}
}
// ---------- hasPTRsRule ----------
func TestHasPTRsRule(t *testing.T) {
r := &hasPTRsRule{}
// Not a reverse zone → skip.
st := evalRule(t, r, &ReverseZoneData{IsReverseZone: false}, nil)
if len(st) != 1 || st[0].Status != sdk.StatusUnknown {
t.Errorf("non-reverse skip: %+v", st)
}
// Reverse zone, no PTRs → warn.
st = evalRule(t, r, &ReverseZoneData{IsReverseZone: true, Zone: "z.", PTRCount: 0}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone_empty" || st[0].Status != sdk.StatusWarn {
t.Errorf("empty zone: %+v", st)
}
// Reverse zone with PTRs → ok.
st = evalRule(t, r, &ReverseZoneData{IsReverseZone: true, Zone: "z.", PTRCount: 3}, nil)
if len(st) != 1 || st[0].Status != sdk.StatusOK {
t.Errorf("ok: %+v", st)
}
}
// ---------- fcrdnsRule ----------
func TestFcrdnsRule(t *testing.T) {
r := &fcrdnsRule{}
// No entries → skip.
st := evalRule(t, r, &ReverseZoneData{}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone.fcrdns.skipped" {
t.Errorf("no entries: %+v", st)
}
// Mix: one OK, one mismatch, one unresolved (skipped here), one with no
// targets (skipped).
data := &ReverseZoneData{
Entries: []PTREntry{
{OwnerName: "a", ReverseIP: "192.0.2.1", Targets: []string{"a.example."}, TargetResolves: true, ForwardMatch: true},
{OwnerName: "b", ReverseIP: "192.0.2.2", Targets: []string{"b.example."}, TargetResolves: true, ForwardMatch: false,
ForwardAddresses: []ForwardAddress{{Address: "203.0.113.1"}}},
{OwnerName: "c", ReverseIP: "192.0.2.3", Targets: []string{"c.example."}, TargetResolves: false},
{OwnerName: "d", ReverseIP: "192.0.2.4", Targets: nil},
},
}
st = evalRule(t, r, data, nil)
if len(st) != 2 {
t.Fatalf("expected 2 states (OK + mismatch), got %d: %+v", len(st), st)
}
if findCode(st, "reverse_zone.fcrdns.ok") == nil {
t.Errorf("missing ok state: %+v", st)
}
mis := findCode(st, "ptr_forward_mismatch")
if mis == nil || mis.Status != sdk.StatusCrit {
t.Errorf("expected critical mismatch state: %+v", st)
}
// requireForwardMatch=false downgrades mismatch to warning.
st = evalRule(t, r, data, sdk.CheckerOptions{"requireForwardMatch": false})
mis = findCode(st, "ptr_forward_mismatch")
if mis == nil || mis.Status != sdk.StatusWarn {
t.Errorf("expected warn mismatch when requireForwardMatch=false: %+v", st)
}
// All entries unresolved or no targets → skipped (nothing to compare).
st = evalRule(t, r, &ReverseZoneData{
Entries: []PTREntry{
{OwnerName: "x", ReverseIP: "192.0.2.9", Targets: []string{"x.example."}, TargetResolves: false},
},
}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone.fcrdns.skipped" {
t.Errorf("all-unresolved skip: %+v", st)
}
}
// ---------- targetResolvesRule ----------
func TestTargetResolvesRule(t *testing.T) {
r := &targetResolvesRule{}
st := evalRule(t, r, &ReverseZoneData{}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone.target_resolves.skipped" {
t.Errorf("no entries: %+v", st)
}
data := &ReverseZoneData{
Entries: []PTREntry{
{OwnerName: "ok", Targets: []string{"ok.example."}, TargetResolves: true},
{OwnerName: "bad", Targets: []string{"bad.example."}, TargetResolves: false, ForwardError: "NXDOMAIN", ReverseIP: "192.0.2.1"},
},
}
st = evalRule(t, r, data, nil)
if len(st) != 1 || st[0].Code != "ptr_target_unresolvable" || st[0].Status != sdk.StatusCrit {
t.Errorf("expected one critical: %+v", st)
}
// requireForwardMatch=false → warning.
st = evalRule(t, r, data, sdk.CheckerOptions{"requireForwardMatch": false})
if len(st) != 1 || st[0].Status != sdk.StatusWarn {
t.Errorf("expected warn when requireForwardMatch=false: %+v", st)
}
// All resolve → ok.
st = evalRule(t, r, &ReverseZoneData{
Entries: []PTREntry{{OwnerName: "ok", Targets: []string{"ok.example."}, TargetResolves: true}},
}, nil)
if len(st) != 1 || st[0].Status != sdk.StatusOK {
t.Errorf("expected ok: %+v", st)
}
}
// ---------- singlePTRRule ----------
func TestSinglePTRRule(t *testing.T) {
r := &singlePTRRule{}
// Explicit allow → skip.
st := evalRule(t, r, &ReverseZoneData{}, sdk.CheckerOptions{"allowMultiplePTR": true})
if len(st) != 1 || st[0].Status != sdk.StatusUnknown {
t.Errorf("allowMultiplePTR skip: %+v", st)
}
// No entries → skip.
st = evalRule(t, r, &ReverseZoneData{}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone.single_ptr_per_ip.skipped" {
t.Errorf("no entries: %+v", st)
}
// One IP with two PTRs → warn.
data := &ReverseZoneData{Entries: []PTREntry{
{OwnerName: "a", Targets: []string{"a.example.", "b.example."}},
{OwnerName: "b", Targets: []string{"c.example."}},
}}
st = evalRule(t, r, data, nil)
if len(st) != 1 || st[0].Code != "ptr_multiple" || st[0].Status != sdk.StatusWarn {
t.Errorf("expected one warn: %+v", st)
}
}
// ---------- targetSyntaxRule ----------
func TestTargetSyntaxRule(t *testing.T) {
r := &targetSyntaxRule{}
st := evalRule(t, r, &ReverseZoneData{}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone.target_syntax.skipped" {
t.Errorf("no entries: %+v", st)
}
data := &ReverseZoneData{Entries: []PTREntry{
{OwnerName: "a", Targets: []string{"!!!bad"}, TargetSyntaxValid: false},
{OwnerName: "b", Targets: []string{"good.example."}, TargetSyntaxValid: true},
}}
st = evalRule(t, r, data, nil)
if len(st) != 1 || st[0].Code != "ptr_target_invalid" {
t.Errorf("expected one invalid: %+v", st)
}
// All valid → ok.
st = evalRule(t, r, &ReverseZoneData{Entries: []PTREntry{
{OwnerName: "b", Targets: []string{"good.example."}, TargetSyntaxValid: true},
}}, nil)
if len(st) != 1 || st[0].Status != sdk.StatusOK {
t.Errorf("expected ok: %+v", st)
}
}
// ---------- genericHostnameRule ----------
func TestGenericHostnameRule(t *testing.T) {
r := &genericHostnameRule{}
// Disabled by config → skip.
st := evalRule(t, r, &ReverseZoneData{}, sdk.CheckerOptions{"flagGenericPTR": false})
if len(st) != 1 || st[0].Status != sdk.StatusUnknown {
t.Errorf("disabled skip: %+v", st)
}
st = evalRule(t, r, &ReverseZoneData{}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone.generic_hostname.skipped" {
t.Errorf("no entries: %+v", st)
}
data := &ReverseZoneData{Entries: []PTREntry{
{OwnerName: "a", Targets: []string{"dhcp-1-2-3-4.example."}, TargetLooksGeneric: true},
{OwnerName: "b", Targets: []string{"mail.example."}, TargetLooksGeneric: false},
}}
st = evalRule(t, r, data, nil)
if len(st) != 1 || st[0].Code != "ptr_generic_hostname" || st[0].Status != sdk.StatusWarn {
t.Errorf("expected one warn: %+v", st)
}
}
// ---------- ttlHygieneRule ----------
func TestTTLHygieneRule(t *testing.T) {
r := &ttlHygieneRule{}
st := evalRule(t, r, &ReverseZoneData{}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone.ttl_hygiene.skipped" {
t.Errorf("no entries: %+v", st)
}
data := &ReverseZoneData{Entries: []PTREntry{
{OwnerName: "a", TTL: 60}, // below default minTTL=300 → warn
{OwnerName: "b", TTL: 3600}, // ok
{OwnerName: "c", TTL: 0}, // unknown TTL → ignored
}}
st = evalRule(t, r, data, nil)
if len(st) != 1 || st[0].Code != "ptr_low_ttl" {
t.Errorf("expected one low-ttl warn: %+v", st)
}
// Custom higher minTTL flags the previously-OK entry too.
st = evalRule(t, r, data, sdk.CheckerOptions{"minTTL": float64(7200)})
if len(st) != 2 {
t.Errorf("expected 2 warns at minTTL=7200, got %d: %+v", len(st), st)
}
}
// ---------- truncationRule ----------
func TestTruncationRule(t *testing.T) {
r := &truncationRule{}
st := evalRule(t, r, &ReverseZoneData{Truncated: false}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone.truncated.skipped" {
t.Errorf("not truncated skip: %+v", st)
}
st = evalRule(t, r, &ReverseZoneData{Truncated: true, PTRCount: 100, Entries: make([]PTREntry, 10)}, nil)
if len(st) != 1 || st[0].Code != "reverse_zone_truncated" || st[0].Status != sdk.StatusInfo {
t.Errorf("truncated info: %+v", st)
}
}
// ---------- helpers ----------
func TestStateHelpers(t *testing.T) {
if s := passState("c", "m", "sub"); s.Status != sdk.StatusOK || s.Code != "c" || s.Subject != "sub" || s.Message != "m" {
t.Errorf("passState: %+v", s)
}
if s := skipState("c", "m"); s.Status != sdk.StatusUnknown {
t.Errorf("skipState: %+v", s)
}
if s := critState("c", "m", "sub", "fix"); s.Status != sdk.StatusCrit || s.Meta["hint"] != "fix" {
t.Errorf("critState: %+v", s)
}
if s := warnState("c", "m", "sub", ""); s.Status != sdk.StatusWarn || s.Meta != nil {
t.Errorf("warnState (no hint should leave Meta nil): %+v", s)
}
if s := infoState("c", "m", "sub"); s.Status != sdk.StatusInfo {
t.Errorf("infoState: %+v", s)
}
}
func TestRulesList(t *testing.T) {
rs := Rules()
if len(rs) == 0 {
t.Fatal("Rules() returned empty slice")
}
seen := map[string]bool{}
for _, r := range rs {
name := r.Name()
if name == "" {
t.Errorf("rule with empty Name(): %T", r)
}
if r.Description() == "" {
t.Errorf("rule %s has empty Description()", name)
}
if seen[name] {
t.Errorf("duplicate rule name: %s", name)
}
seen[name] = true
}
}