checker-reverse-zone/checker/collect_test.go

245 lines
6.9 KiB
Go

package checker
import (
"context"
"encoding/json"
"net"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestContains(t *testing.T) {
if !contains([]string{"a", "b", "c"}, "b") {
t.Error("contains: expected true for present element")
}
if contains([]string{"a", "b"}, "z") {
t.Error("contains: expected false for missing element")
}
if contains(nil, "x") {
t.Error("contains: expected false for nil slice")
}
if contains([]string{}, "") {
t.Error("contains: expected false for empty slice")
}
}
func TestLooksGeneric_IPv4(t *testing.T) {
ip := net.ParseIP("203.0.113.42")
cases := []struct {
host string
want bool
}{
{"203.0.113.42.example.net", true}, // dotted IP embedded
{"host-203-0-113-42.isp.example", true}, // dashed IP embedded
{"dhcp-1-2-3-4.client.example.com", true}, // ISP pattern
{"static.203.0.113.42.rev.example", true}, // dotted form
{"pool-100-200-1-2.broadband.example", true}, // pool pattern (different IP but matches regex)
{"mail.example.com", false}, // clean
{"customer.example.com", false}, // clean
}
for _, c := range cases {
if got := looksGeneric(c.host, ip); got != c.want {
t.Errorf("looksGeneric(%q, %v)=%v, want %v", c.host, ip, got, c.want)
}
}
}
func TestLooksGeneric_IPv6(t *testing.T) {
ip := net.ParseIP("2001:db8::1")
if ip == nil {
t.Fatal("parse ip")
}
cases := []struct {
host string
want bool
}{
{"20010db8000000000000000000000001.example.com", true}, // flat 32-nibble
{"2001-0db8-0000-0000.dyn.example", true}, // dashed group
{"2001.0db8.0000.0000.example", true}, // dotted group
{"mail.example.com", false},
}
for _, c := range cases {
if got := looksGeneric(c.host, ip); got != c.want {
t.Errorf("looksGeneric(%q, %v)=%v, want %v", c.host, ip, got, c.want)
}
}
}
func TestBuildOwnerName_Edge(t *testing.T) {
// Lowercases sub, strips trailing dot, joins to FQDN.
cases := []struct {
sub, zone, want string
}{
{"FOO", "1.168.192.in-addr.arpa", "foo.1.168.192.in-addr.arpa."},
{"foo.", "1.168.192.in-addr.arpa", "foo.1.168.192.in-addr.arpa."},
{"foo", "", "foo."},
}
for _, c := range cases {
if got := buildOwnerName(c.sub, c.zone); got != c.want {
t.Errorf("buildOwnerName(%q,%q)=%q, want %q", c.sub, c.zone, got, c.want)
}
}
}
// TestCollect_NoZoneAutofill verifies that Collect returns a structured
// LoadError (not an error) when the host did not provide the zone autofill.
func TestCollect_NoZoneAutofill(t *testing.T) {
p := &reverseZoneProvider{}
opts := sdk.CheckerOptions{"domain_name": "1.168.192.in-addr.arpa"}
out, err := p.Collect(context.Background(), opts)
if err != nil {
t.Fatalf("Collect: %v", err)
}
data, ok := out.(*ReverseZoneData)
if !ok {
t.Fatalf("Collect returned %T, want *ReverseZoneData", out)
}
if data.LoadError == "" {
t.Errorf("expected LoadError to be set, got data=%+v", data)
}
if !data.IsReverseZone {
t.Errorf("IsReverseZone should be true for in-addr.arpa zone")
}
}
// TestCollect_NotReverseZone exercises the path where a non-arpa zone is
// passed: zone metadata is recorded but IsReverseZone stays false.
func TestCollect_NotReverseZone(t *testing.T) {
p := &reverseZoneProvider{}
opts := sdk.CheckerOptions{"domain_name": "example.com"}
out, _ := p.Collect(context.Background(), opts)
data := out.(*ReverseZoneData)
if data.IsReverseZone {
t.Errorf("example.com should not be a reverse zone")
}
if data.IsIPv6 {
t.Errorf("example.com should not be IPv6 reverse zone")
}
}
// TestCollect_PTRDeduplication verifies that multiple PTR entries on the
// same owner are merged into a single PTREntry with merged Targets, and
// that targets are deduplicated.
func TestCollect_PTRDeduplication(t *testing.T) {
zone := buildZoneWithPTRs(t, map[string][]string{
"42": {"a.example.com.", "a.example.com.", "b.example.com."},
"43": {"c.example.com."},
})
opts := sdk.CheckerOptions{
"domain_name": "1.168.192.in-addr.arpa",
"zone": zone,
"maxPTRsToCheck": float64(1024),
}
p := &reverseZoneProvider{}
out, err := p.Collect(context.Background(), opts)
if err != nil {
t.Fatalf("Collect: %v", err)
}
data := out.(*ReverseZoneData)
if data.PTRCount != 4 {
t.Errorf("PTRCount=%d, want 4", data.PTRCount)
}
if len(data.Entries) != 2 {
t.Fatalf("len(Entries)=%d, want 2", len(data.Entries))
}
byOwner := map[string]PTREntry{}
for _, e := range data.Entries {
byOwner[e.OwnerName] = e
}
e42 := byOwner["42.1.168.192.in-addr.arpa."]
if len(e42.Targets) != 2 {
t.Errorf("entry 42 Targets=%v, want 2 unique", e42.Targets)
}
if e42.ReverseIP != "192.168.1.42" {
t.Errorf("entry 42 ReverseIP=%q, want 192.168.1.42", e42.ReverseIP)
}
}
// TestCollect_Truncation ensures the maxPTRsToCheck cap is enforced and
// reported via Truncated.
func TestCollect_Truncation(t *testing.T) {
ptrs := map[string][]string{}
for i := 0; i < 5; i++ {
ptrs[itoa(i)] = []string{"host.example.com."}
}
zone := buildZoneWithPTRs(t, ptrs)
opts := sdk.CheckerOptions{
"domain_name": "1.168.192.in-addr.arpa",
"zone": zone,
"maxPTRsToCheck": float64(2),
}
p := &reverseZoneProvider{}
out, _ := p.Collect(context.Background(), opts)
data := out.(*ReverseZoneData)
if !data.Truncated {
t.Errorf("expected Truncated=true")
}
if data.PTRCount != 5 {
t.Errorf("PTRCount=%d, want 5", data.PTRCount)
}
if len(data.Entries) > 2 {
t.Errorf("inspected %d entries, want <=2", len(data.Entries))
}
}
// buildZoneWithPTRs constructs a zoneMessage round-tripped through JSON so
// that GetOption[zoneMessage] picks it up via the same path the host uses.
func buildZoneWithPTRs(t *testing.T, ptrs map[string][]string) any {
t.Helper()
services := map[string][]map[string]any{}
for sub, targets := range ptrs {
for _, target := range targets {
rec := map[string]any{
"Hdr": map[string]any{
"Name": sub + ".1.168.192.in-addr.arpa.",
"Rrtype": 12,
"Class": 1,
"Ttl": 3600,
},
"Ptr": target,
}
svc := map[string]any{"Record": rec}
svcRaw, _ := json.Marshal(svc)
services[sub] = append(services[sub], map[string]any{
"_svctype": "svcs.PTR",
"_domain": sub,
"Service": json.RawMessage(svcRaw),
})
}
}
zone := map[string]any{
"default_ttl": 3600,
"services": services,
}
// Round-trip through JSON so the value lives in the options map exactly
// as it would when received from the SDK.
raw, err := json.Marshal(zone)
if err != nil {
t.Fatalf("marshal zone: %v", err)
}
var generic any
if err := json.Unmarshal(raw, &generic); err != nil {
t.Fatalf("unmarshal zone: %v", err)
}
return generic
}
func itoa(i int) string {
const digits = "0123456789"
if i == 0 {
return "0"
}
var buf [3]byte
n := 0
for i > 0 {
buf[n] = digits[i%10]
i /= 10
n++
}
out := make([]byte, n)
for j := 0; j < n; j++ {
out[j] = buf[n-1-j]
}
return string(out)
}