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