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 } }