245 lines
6.9 KiB
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)
|
|
}
|