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