244 lines
7.2 KiB
Go
244 lines
7.2 KiB
Go
package zonefile
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/owen/vibedns/internal/models"
|
|
)
|
|
|
|
const sampleZone = `$ORIGIN example.com.
|
|
$TTL 3600
|
|
|
|
@ IN SOA ns1.example.com. hostmaster.example.com. (
|
|
2024010101 ; serial
|
|
7200 ; refresh
|
|
3600 ; retry
|
|
1209600 ; expire
|
|
300 ) ; minimum
|
|
|
|
@ IN NS ns1.example.com.
|
|
@ IN NS ns2.example.com.
|
|
@ IN A 192.0.2.10
|
|
@ IN MX 10 mail.example.com.
|
|
www IN CNAME example.com.
|
|
mail IN A 192.0.2.20
|
|
mail IN AAAA 2001:db8::20
|
|
ns1 IN A 192.0.2.53
|
|
txt IN TXT "v=spf1 mx -all"
|
|
_sip._tcp IN SRV 10 20 5060 sip.example.com.
|
|
short 60 IN A 192.0.2.99
|
|
*.wild IN A 192.0.2.100
|
|
`
|
|
|
|
func TestParseZoneFile(t *testing.T) {
|
|
res, err := Parse(strings.NewReader(sampleZone), "example.com.", 3600)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
|
|
if !res.Summary.SOAFound || res.SOA == nil {
|
|
t.Fatal("the SOA was not picked up")
|
|
}
|
|
if res.SOA.Serial != 2024010101 {
|
|
t.Errorf("serial = %d, want 2024010101", res.SOA.Serial)
|
|
}
|
|
if res.SOA.Minttl != 300 {
|
|
t.Errorf("SOA minimum = %d, want 300", res.SOA.Minttl)
|
|
}
|
|
|
|
// The SOA is zone metadata, not a record row.
|
|
for _, r := range res.Records {
|
|
if r.Type == "SOA" {
|
|
t.Error("the SOA should not be stored as a record")
|
|
}
|
|
}
|
|
|
|
byName := map[string][]models.Record{}
|
|
for _, r := range res.Records {
|
|
byName[r.Name] = append(byName[r.Name], r)
|
|
}
|
|
|
|
if len(byName["@"]) != 4 { // 2 NS, 1 A, 1 MX
|
|
t.Errorf("apex records = %d, want 4", len(byName["@"]))
|
|
}
|
|
if got := byName["www"]; len(got) != 1 || got[0].Type != "CNAME" {
|
|
t.Errorf("www = %v, want a single CNAME", got)
|
|
}
|
|
if len(byName["mail"]) != 2 {
|
|
t.Errorf("mail records = %d, want 2 (A and AAAA)", len(byName["mail"]))
|
|
}
|
|
if _, ok := byName["*.wild"]; !ok {
|
|
t.Error("the wildcard record was not parsed")
|
|
}
|
|
if _, ok := byName["_sip._tcp"]; !ok {
|
|
t.Error("the underscore-prefixed SRV name was not parsed")
|
|
}
|
|
|
|
// A record whose TTL differs from the file default keeps an explicit TTL;
|
|
// one that matches inherits (nil).
|
|
for _, r := range byName["short"] {
|
|
if r.TTL == nil || *r.TTL != 60 {
|
|
t.Errorf("short record TTL = %v, want an explicit 60", r.TTL)
|
|
}
|
|
}
|
|
for _, r := range byName["mail"] {
|
|
if r.TTL != nil {
|
|
t.Errorf("mail record TTL = %v, want nil (inherit the zone default)", *r.TTL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsOutOfZoneRecords(t *testing.T) {
|
|
const zone = `$ORIGIN example.com.
|
|
$TTL 3600
|
|
@ IN A 192.0.2.1
|
|
other.org. IN A 192.0.2.2
|
|
`
|
|
res, err := Parse(strings.NewReader(zone), "example.com.", 3600)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if res.Summary.OutOfZone != 1 {
|
|
t.Errorf("out-of-zone count = %d, want 1", res.Summary.OutOfZone)
|
|
}
|
|
if len(res.Summary.Warnings) == 0 {
|
|
t.Error("expected a warning naming the skipped record")
|
|
}
|
|
for _, r := range res.Records {
|
|
if strings.Contains(r.Name, "other") {
|
|
t.Error("an out-of-zone record was imported")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsMalformedFile(t *testing.T) {
|
|
const bad = `$ORIGIN example.com.
|
|
@ IN A this-is-not-an-address
|
|
`
|
|
if _, err := Parse(strings.NewReader(bad), "example.com.", 3600); err == nil {
|
|
t.Error("expected a parse error for invalid record data")
|
|
}
|
|
}
|
|
|
|
func TestParseEmptyFile(t *testing.T) {
|
|
if _, err := Parse(strings.NewReader("; just a comment\n"), "example.com.", 3600); err == nil {
|
|
t.Error("expected an error for a file with no records")
|
|
}
|
|
}
|
|
|
|
func TestExportRoundTrip(t *testing.T) {
|
|
zone := models.Zone{
|
|
ID: 1, Name: "example.com.", Kind: models.ZoneForward, Enabled: true,
|
|
DefaultTTL: 3600, PrimaryNS: "ns1.example.com.", AdminEmail: "hostmaster@example.com",
|
|
Serial: 42, Refresh: 7200, Retry: 3600, Expire: 1209600, Minimum: 300,
|
|
}
|
|
ttl := uint32(60)
|
|
records := []models.Record{
|
|
{Name: "@", Type: "NS", Data: "ns1.example.com.", Enabled: true},
|
|
{Name: "@", Type: "A", Data: "192.0.2.10", Enabled: true},
|
|
{Name: "@", Type: "MX", Data: "10 mail.example.com.", Enabled: true},
|
|
{Name: "www", Type: "CNAME", Data: "example.com.", Enabled: true},
|
|
{Name: "mail", Type: "A", Data: "192.0.2.20", Enabled: true},
|
|
{Name: "short", Type: "A", Data: "192.0.2.99", TTL: &ttl, Enabled: true},
|
|
{Name: "txt", Type: "TXT", Data: `"hello world"`, Enabled: true, Comment: "a note"},
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := Export(&buf, zone, records); err != nil {
|
|
t.Fatalf("export: %v", err)
|
|
}
|
|
out := buf.String()
|
|
|
|
for _, want := range []string{"$ORIGIN example.com.", "$TTL 3600", "SOA", "42"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("export is missing %q\n%s", want, out)
|
|
}
|
|
}
|
|
|
|
// Re-importing the export must reproduce the same records.
|
|
res, err := Parse(strings.NewReader(out), "example.com.", 3600)
|
|
if err != nil {
|
|
t.Fatalf("re-parse the export: %v", err)
|
|
}
|
|
if len(res.Records) != len(records) {
|
|
t.Errorf("round trip produced %d records, want %d", len(res.Records), len(records))
|
|
}
|
|
if res.SOA == nil || res.SOA.Serial != 42 {
|
|
t.Error("the serial did not survive the round trip")
|
|
}
|
|
}
|
|
|
|
func TestExportPreservesDisabledRecordsAsComments(t *testing.T) {
|
|
zone := models.Zone{
|
|
Name: "example.com.", DefaultTTL: 3600, Serial: 1,
|
|
PrimaryNS: "ns1.example.com.", AdminEmail: "a@example.com",
|
|
}
|
|
records := []models.Record{
|
|
{Name: "on", Type: "A", Data: "192.0.2.1", Enabled: true},
|
|
{Name: "off", Type: "A", Data: "192.0.2.2", Enabled: false},
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := Export(&buf, zone, records); err != nil {
|
|
t.Fatalf("export: %v", err)
|
|
}
|
|
out := buf.String()
|
|
|
|
if !strings.Contains(out, "DISABLED") {
|
|
t.Error("a disabled record should be preserved as a comment, not dropped")
|
|
}
|
|
// It must be commented out, so re-importing does not re-enable it.
|
|
res, err := Parse(strings.NewReader(out), "example.com.", 3600)
|
|
if err != nil {
|
|
t.Fatalf("re-parse: %v", err)
|
|
}
|
|
for _, r := range res.Records {
|
|
if r.Name == "off" {
|
|
t.Error("a disabled record was re-imported as active")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMailboxConversion(t *testing.T) {
|
|
tests := []struct{ email, want string }{
|
|
{"hostmaster@example.com", "hostmaster.example.com."},
|
|
{"first.last@example.com", `first\.last.example.com.`},
|
|
{"", "hostmaster.example.com."},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := mailbox(tc.email, "example.com."); got != tc.want {
|
|
t.Errorf("mailbox(%q) = %q, want %q", tc.email, got, tc.want)
|
|
}
|
|
}
|
|
|
|
// And back again.
|
|
backTests := []struct{ mbox, want string }{
|
|
{"hostmaster.example.com.", "hostmaster@example.com"},
|
|
{`first\.last.example.com.`, "first.last@example.com"},
|
|
}
|
|
for _, tc := range backTests {
|
|
if got := emailFromMailbox(tc.mbox); got != tc.want {
|
|
t.Errorf("emailFromMailbox(%q) = %q, want %q", tc.mbox, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateRecordsCatchesBadData(t *testing.T) {
|
|
records := []models.Record{
|
|
{Name: "good", Type: "A", Data: "192.0.2.1"},
|
|
{Name: "bad", Type: "A", Data: "not-an-address"},
|
|
}
|
|
problems := ValidateRecords("example.com.", records, 3600)
|
|
if len(problems) != 1 {
|
|
t.Errorf("problems = %d, want 1: %v", len(problems), problems)
|
|
}
|
|
}
|
|
|
|
func TestSuggestFilename(t *testing.T) {
|
|
if got := SuggestFilename("example.com."); got != "example.com.zone" {
|
|
t.Errorf("filename = %q, want example.com.zone", got)
|
|
}
|
|
}
|