Initial commit
This commit is contained in:
commit
1d93a25983
23 changed files with 2654 additions and 0 deletions
366
checker/rules_test.go
Normal file
366
checker/rules_test.go
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue