366 lines
11 KiB
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
|
|
}
|
|
}
|