Files
2026-08-16 21:18:45 -05:00

358 lines
11 KiB
Go

package validate
import (
"strings"
"testing"
)
func TestNormaliseFQDN(t *testing.T) {
tests := []struct {
in string
want string
wantErr bool
}{
{"example.com", "example.com.", false},
{"example.com.", "example.com.", false},
{"EXAMPLE.COM", "example.com.", false},
{" example.com ", "example.com.", false},
{"a.b.c.example.com", "a.b.c.example.com.", false},
{"_dmarc.example.com", "_dmarc.example.com.", false}, // underscores are needed by SRV/DKIM
{".", ".", false},
{"", "", true},
{"example..com", "", true},
{"-bad.example.com", "", true},
{"bad-.example.com", "", true},
{"exa mple.com", "", true},
{strings.Repeat("a", 64) + ".example.com", "", true}, // label too long
{strings.Repeat("a.", 130) + "example.com", "", true}, // name too long
}
for _, tc := range tests {
t.Run(tc.in, func(t *testing.T) {
got, err := NormaliseFQDN(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("expected an error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestNormaliseRecordName(t *testing.T) {
const zone = "example.com."
tests := []struct {
in string
want string
wantErr bool
}{
{"", "@", false},
{"@", "@", false},
{"www", "www", false},
{"WWW", "www", false},
{"www.example.com.", "www", false}, // absolute inside the zone
{"example.com.", "@", false}, // the apex itself
{"*", "*", false}, // wildcard
{"*.sub", "*.sub", false},
{"a.b.c", "a.b.c", false},
{"www.example.org.", "", true}, // outside the zone
{"sub.*", "", true}, // wildcard must be leftmost
}
for _, tc := range tests {
t.Run(tc.in, func(t *testing.T) {
got, err := NormaliseRecordName(tc.in, zone)
if tc.wantErr {
if err == nil {
t.Errorf("expected an error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
// TestReverseZone covers the feature that spares the operator from reversing
// octets by hand.
func TestReverseZone(t *testing.T) {
tests := []struct {
cidr string
want string
wantNote bool
wantErr bool
}{
{"192.168.1.0/24", "1.168.192.in-addr.arpa.", false, false},
{"10.0.0.0/8", "10.in-addr.arpa.", false, false},
{"172.16.0.0/16", "16.172.in-addr.arpa.", false, false},
{"192.0.2.10", "10.2.0.192.in-addr.arpa.", false, false}, // bare host
// Non-octet boundaries round down to the enclosing zone with a note.
{"192.168.1.0/25", "1.168.192.in-addr.arpa.", true, false},
{"10.1.2.3/30", "2.1.10.in-addr.arpa.", true, false},
{"2001:db8::/32", "8.b.d.0.1.0.0.2.ip6.arpa.", false, false},
{"2001:db8:1::/48", "1.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", false, false},
{"2001:db8::/33", "8.b.d.0.1.0.0.2.ip6.arpa.", true, false},
{"not-a-cidr", "", false, true},
{"192.168.1.0/4", "", false, true}, // finer than a /8 leaves no IPv4 zone
}
for _, tc := range tests {
t.Run(tc.cidr, func(t *testing.T) {
zone, note, err := ReverseZone(tc.cidr)
if tc.wantErr {
if err == nil {
t.Errorf("expected an error, got %q", zone)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if zone != tc.want {
t.Errorf("zone = %q, want %q", zone, tc.want)
}
if tc.wantNote && note == "" {
t.Error("expected a note explaining the rounding")
}
if !tc.wantNote && note != "" {
t.Errorf("unexpected note: %s", note)
}
})
}
}
func TestPTRName(t *testing.T) {
tests := []struct{ ip, want string }{
{"192.0.2.10", "10.2.0.192.in-addr.arpa."},
{"10.1.2.3", "3.2.1.10.in-addr.arpa."},
}
for _, tc := range tests {
got, err := PTRName(tc.ip)
if err != nil {
t.Fatalf("PTRName(%q): %v", tc.ip, err)
}
if got != tc.want {
t.Errorf("PTRName(%q) = %q, want %q", tc.ip, got, tc.want)
}
}
if _, err := PTRName("not-an-ip"); err == nil {
t.Error("expected an error for an invalid address")
}
}
func TestBuildRRValidation(t *testing.T) {
const zone = "example.com."
tests := []struct {
name string
rtype string
data string
wantErr bool
}{
{"valid A", "A", "192.0.2.1", false},
{"A with IPv6", "A", "2001:db8::1", true},
{"A with garbage", "A", "not-an-ip", true},
{"valid AAAA", "AAAA", "2001:db8::1", false},
{"AAAA with IPv4", "AAAA", "192.0.2.1", true},
{"valid CNAME", "CNAME", "target.example.com.", false},
{"CNAME with two names", "CNAME", "a.example.com. b.example.com.", true},
{"valid MX", "MX", "10 mail.example.com.", false},
{"MX without preference", "MX", "mail.example.com.", true},
{"MX with bad preference", "MX", "abc mail.example.com.", true},
{"valid TXT", "TXT", `"some text"`, false},
{"unquoted TXT", "TXT", "some text", true},
{"valid SRV", "SRV", "10 20 5060 sip.example.com.", false},
{"SRV missing fields", "SRV", "10 20 sip.example.com.", true},
{"valid CAA", "CAA", `0 issue "letsencrypt.org"`, false},
{"valid TLSA", "TLSA", "3 1 1 abcdef0123456789", false},
{"valid SSHFP", "SSHFP", "4 2 abcdef0123456789", false},
{"valid DS", "DS", "12345 13 2 abcdef0123456789", false},
{"valid HTTPS", "HTTPS", "1 . alpn=h2,h3", false},
{"valid NS", "NS", "ns1.example.com.", false},
{"valid PTR", "PTR", "host.example.com.", false},
{"unknown type", "NOTATYPE", "whatever", true},
{"meta type rejected", "ANY", "whatever", true},
{"RFC3597 unknown type", "TYPE65280", `\# 4 0A0B0C0D`, false},
{"empty data", "A", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := BuildRR(zone, "test", tc.rtype, tc.data, 3600)
if tc.wantErr && err == nil {
t.Error("expected an error, got none")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
// TestCNAMEConflict covers the RFC 1034 rule that trips people up most often.
func TestCNAMEConflict(t *testing.T) {
tests := []struct {
name string
newType string
existing []string
wantErr bool
}{
{"CNAME on an empty name", "CNAME", nil, false},
{"second CNAME", "CNAME", []string{"CNAME"}, true},
{"CNAME alongside an A", "CNAME", []string{"A"}, true},
{"A alongside a CNAME", "A", []string{"CNAME"}, true},
{"MX alongside a CNAME", "MX", []string{"CNAME"}, true},
{"A alongside another A", "A", []string{"A"}, false},
{"A alongside AAAA", "A", []string{"AAAA"}, false},
// DNSSEC types are the standard exception.
{"RRSIG alongside a CNAME", "RRSIG", []string{"CNAME"}, false},
{"CNAME alongside RRSIG only", "CNAME", []string{"RRSIG", "NSEC"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := CNAMEConflict(tc.newType, tc.existing)
if tc.wantErr && err == nil {
t.Error("expected a conflict, got none")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected conflict: %v", err)
}
})
}
}
func TestApexRestrictions(t *testing.T) {
if err := ApexRestricted("CNAME"); err == nil {
t.Error("a CNAME at the apex must be rejected")
}
if err := ApexRestricted("DNAME"); err == nil {
t.Error("a DNAME at the apex must be rejected")
}
for _, ok := range []string{"A", "AAAA", "MX", "TXT", "NS", "HTTPS"} {
if err := ApexRestricted(ok); err != nil {
t.Errorf("%s should be allowed at the apex: %v", ok, err)
}
}
}
func TestQuoteTXT(t *testing.T) {
tests := []struct{ in, want string }{
{"hello", `"hello"`},
{"", `""`},
{`already "quoted"`, `"already \"quoted\""`},
{`"pre-quoted"`, `"pre-quoted"`},
}
for _, tc := range tests {
if got := QuoteTXT(tc.in); got != tc.want {
t.Errorf("QuoteTXT(%q) = %q, want %q", tc.in, got, tc.want)
}
}
// A string past the 255-byte character-string limit must be split into
// several quoted chunks, not truncated.
long := strings.Repeat("a", 600)
got := QuoteTXT(long)
if strings.Count(got, `"`) != 6 {
t.Errorf("a 600 character string produced %d quotes, want 6 (three chunks)", strings.Count(got, `"`))
}
if _, err := BuildRR("example.com.", "long", "TXT", got, 300); err != nil {
t.Errorf("chunked TXT did not parse: %v", err)
}
}
func TestAssembleAndSplitRData(t *testing.T) {
tests := []struct {
rtype string
fields map[string]string
want string
}{
{"A", map[string]string{"address": "192.0.2.1"}, "192.0.2.1"},
{"MX", map[string]string{"preference": "10", "exchange": "mail.example.com."}, "10 mail.example.com."},
{"SRV", map[string]string{"priority": "10", "weight": "20", "port": "5060", "target": "sip.example.com."},
"10 20 5060 sip.example.com."},
{"TXT", map[string]string{"text": "v=spf1 -all"}, `"v=spf1 -all"`},
{"CAA", map[string]string{"flags": "0", "tag": "issue", "value": "letsencrypt.org"},
`0 issue "letsencrypt.org"`},
}
for _, tc := range tests {
t.Run(tc.rtype, func(t *testing.T) {
got, err := AssembleRData(tc.rtype, tc.fields)
if err != nil {
t.Fatalf("assemble: %v", err)
}
if got != tc.want {
t.Errorf("assembled = %q, want %q", got, tc.want)
}
if _, err := BuildRR("example.com.", "test", tc.rtype, got, 3600); err != nil {
t.Errorf("assembled rdata does not parse: %v", err)
}
// Round trip: splitting must return the values we started with.
split := SplitRData(tc.rtype, got)
for k, v := range tc.fields {
if split[k] != v {
t.Errorf("round trip field %q = %q, want %q", k, split[k], v)
}
}
})
}
}
func TestAssembleRequiresMandatoryFields(t *testing.T) {
if _, err := AssembleRData("MX", map[string]string{"exchange": "mail.example.com."}); err == nil {
t.Error("expected an error when a required field is missing")
}
}
func TestTypeCatalogue(t *testing.T) {
// Every type the brief asks for must have a dedicated editor.
required := []string{
"A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "PTR", "CAA", "SOA",
"NAPTR", "TLSA", "SSHFP", "SVCB", "HTTPS", "DS", "DNSKEY",
}
for _, want := range required {
info, ok := TypeInfoFor(want)
if !ok {
t.Errorf("no editor is defined for %s", want)
continue
}
if len(info.Fields) == 0 {
t.Errorf("%s has an editor with no fields", want)
}
}
if _, ok := TypeInfoFor("RAW"); !ok {
t.Error("the advanced raw editor is missing")
}
}
func TestIsSubdomain(t *testing.T) {
tests := []struct {
child, parent string
want bool
}{
{"www.example.com.", "example.com.", true},
{"example.com.", "example.com.", true},
{"a.b.example.com.", "example.com.", true},
{"notexample.com.", "example.com.", false},
{"example.org.", "example.com.", false},
{"anything.", ".", true},
}
for _, tc := range tests {
if got := IsSubdomain(tc.child, tc.parent); got != tc.want {
t.Errorf("IsSubdomain(%q, %q) = %v, want %v", tc.child, tc.parent, got, tc.want)
}
}
}