Generate RFC 9432 DNS catalog zone files from a declarative input file. Parses zone-to-catalog assignments with optional group and coo properties, produces deterministic BIND-format output with automatic SOA serial management and change detection.
333 lines
7.4 KiB
Go
333 lines
7.4 KiB
Go
package main
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
func TestHashZoneName(t *testing.T) {
|
|
tests := []struct {
|
|
zone string
|
|
want string
|
|
}{
|
|
{"example.com.", "lq2vr60"},
|
|
{"example.org.", "an5oaj8"},
|
|
// Same input should always produce same output
|
|
{"example.com.", "lq2vr60"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := hashZoneName(tt.zone)
|
|
if got != tt.want {
|
|
t.Errorf("hashZoneName(%q) = %q, want %q", tt.zone, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHashZoneNameUniqueness(t *testing.T) {
|
|
zones := []string{
|
|
"example.com.",
|
|
"example.org.",
|
|
"example.net.",
|
|
"test.example.com.",
|
|
"app.example.org.",
|
|
"zone.example.org.",
|
|
"zone.example.com.",
|
|
"test.example.net.",
|
|
}
|
|
|
|
hashes := make(map[string]string)
|
|
for _, zone := range zones {
|
|
h := hashZoneName(zone)
|
|
if existing, ok := hashes[h]; ok {
|
|
t.Errorf("hash collision: %q and %q both hash to %q", existing, zone, h)
|
|
}
|
|
hashes[h] = zone
|
|
}
|
|
}
|
|
|
|
func TestBumpSerial(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
old uint32
|
|
year int
|
|
month int
|
|
day int
|
|
want uint32
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "new serial from zero",
|
|
old: 0,
|
|
year: 2026, month: 3, day: 2,
|
|
want: 2026030201,
|
|
},
|
|
{
|
|
name: "bump same date",
|
|
old: 2026030201,
|
|
year: 2026, month: 3, day: 2,
|
|
want: 2026030202,
|
|
},
|
|
{
|
|
name: "new date",
|
|
old: 2026030205,
|
|
year: 2026, month: 3, day: 3,
|
|
want: 2026030301,
|
|
},
|
|
{
|
|
name: "old serial different format",
|
|
old: 12345,
|
|
year: 2026, month: 3, day: 2,
|
|
want: 2026030201,
|
|
},
|
|
{
|
|
name: "overflow at 99",
|
|
old: 2026030299,
|
|
year: 2026, month: 3, day: 2,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
now := fixedTime(tt.year, tt.month, tt.day)
|
|
got, err := bumpSerial(tt.old, now)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("bumpSerial(%d) = %d, want %d", tt.old, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefaultSerial(t *testing.T) {
|
|
now := fixedTime(2026, 3, 2)
|
|
got := defaultSerial(now)
|
|
want := uint32(2026030201)
|
|
if got != want {
|
|
t.Errorf("defaultSerial() = %d, want %d", got, want)
|
|
}
|
|
}
|
|
|
|
func TestGenerateCatalogZone(t *testing.T) {
|
|
cfg := &Config{
|
|
Catalogs: map[string]CatalogConfig{
|
|
"cat1": {Zone: "catalog.example.com."},
|
|
},
|
|
SOA: SOAConfig{
|
|
Mname: "ns1.example.com.",
|
|
Rname: "hostmaster.example.com.",
|
|
},
|
|
}
|
|
|
|
members := []ZoneEntry{
|
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1},
|
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, Group: "mygroup", File: "test", Line: 2},
|
|
}
|
|
|
|
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Verify SOA is first line
|
|
lines := splitLines(content)
|
|
if len(lines) < 4 {
|
|
t.Fatalf("expected at least 4 lines, got %d", len(lines))
|
|
}
|
|
|
|
// SOA should contain the serial
|
|
assertContains(t, lines[0], "2026030201")
|
|
assertContains(t, lines[0], "SOA")
|
|
|
|
// NS should be second
|
|
assertContains(t, lines[1], "NS")
|
|
assertContains(t, lines[1], "invalid.")
|
|
|
|
// Version TXT should be third
|
|
assertContains(t, lines[2], "TXT")
|
|
assertContains(t, lines[2], "\"2\"")
|
|
|
|
// a.example.com should come before b.example.com (sorted)
|
|
aIdx := -1
|
|
bIdx := -1
|
|
for i, line := range lines {
|
|
if containsStr(line, "a.example.com.") {
|
|
if aIdx == -1 {
|
|
aIdx = i
|
|
}
|
|
}
|
|
if containsStr(line, "b.example.com.") {
|
|
if bIdx == -1 {
|
|
bIdx = i
|
|
}
|
|
}
|
|
}
|
|
if aIdx == -1 || bIdx == -1 {
|
|
t.Fatal("expected both a.example.com and b.example.com in output")
|
|
}
|
|
if aIdx >= bIdx {
|
|
t.Errorf("a.example.com (line %d) should come before b.example.com (line %d)", aIdx, bIdx)
|
|
}
|
|
|
|
// a.example.com should have a group TXT
|
|
foundGroup := false
|
|
for _, line := range lines {
|
|
if containsStr(line, "group.") && containsStr(line, "\"mygroup\"") {
|
|
foundGroup = true
|
|
}
|
|
}
|
|
if !foundGroup {
|
|
t.Error("expected group TXT record for a.example.com")
|
|
}
|
|
}
|
|
|
|
func TestGenerateCatalogZoneCOO(t *testing.T) {
|
|
cfg := &Config{
|
|
Catalogs: map[string]CatalogConfig{
|
|
"cat1": {Zone: "catalog.example.com."},
|
|
},
|
|
SOA: SOAConfig{
|
|
Mname: "ns1.example.com.",
|
|
Rname: "hostmaster.example.com.",
|
|
},
|
|
}
|
|
|
|
members := []ZoneEntry{
|
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, COO: "old.example.com.", File: "test", Line: 1},
|
|
}
|
|
|
|
content, err := generateCatalogZone("cat1", cfg, members, 2026030201)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
foundCOO := false
|
|
for _, line := range splitLines(content) {
|
|
if containsStr(line, "coo.") && containsStr(line, "old.example.com.") {
|
|
foundCOO = true
|
|
}
|
|
}
|
|
if !foundCOO {
|
|
t.Error("expected coo PTR record")
|
|
}
|
|
}
|
|
|
|
func TestReadExistingSerial(t *testing.T) {
|
|
t.Run("file does not exist", func(t *testing.T) {
|
|
serial, err := readExistingSerial("/nonexistent/path.zone")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if serial != 0 {
|
|
t.Errorf("serial = %d, want 0", serial)
|
|
}
|
|
})
|
|
|
|
t.Run("valid zone file", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + "/test.zone"
|
|
content := "example.com. 0 IN SOA ns1.example.com. hostmaster.example.com. 2026030205 900 600 2147483646 0\nexample.com. 0 IN NS invalid.\n"
|
|
writeTestFile(t, dir, "test.zone", content)
|
|
|
|
serial, err := readExistingSerial(path)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if serial != 2026030205 {
|
|
t.Errorf("serial = %d, want 2026030205", serial)
|
|
}
|
|
})
|
|
|
|
t.Run("zone file with no SOA", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := dir + "/test.zone"
|
|
content := "example.com. 0 IN NS invalid.\n"
|
|
writeTestFile(t, dir, "test.zone", content)
|
|
|
|
serial, err := readExistingSerial(path)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if serial != 0 {
|
|
t.Errorf("serial = %d, want 0 (no SOA found)", serial)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestReadExistingContent(t *testing.T) {
|
|
t.Run("file does not exist", func(t *testing.T) {
|
|
content, err := readExistingContent("/nonexistent/path.zone")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if content != "" {
|
|
t.Errorf("content = %q, want empty", content)
|
|
}
|
|
})
|
|
|
|
t.Run("file exists", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeTestFile(t, dir, "test.zone", "hello\n")
|
|
|
|
content, err := readExistingContent(dir + "/test.zone")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if content != "hello\n" {
|
|
t.Errorf("content = %q, want %q", content, "hello\n")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGenerateCatalogZoneEmpty(t *testing.T) {
|
|
cfg := &Config{
|
|
Catalogs: map[string]CatalogConfig{
|
|
"cat1": {Zone: "catalog.example.com."},
|
|
},
|
|
SOA: SOAConfig{
|
|
Mname: "ns1.example.com.",
|
|
Rname: "hostmaster.example.com.",
|
|
},
|
|
}
|
|
|
|
content, err := generateCatalogZone("cat1", cfg, nil, 2026030201)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
lines := splitLines(content)
|
|
if len(lines) != 3 {
|
|
t.Fatalf("expected 3 lines (SOA, NS, version TXT), got %d", len(lines))
|
|
}
|
|
assertContains(t, lines[0], "SOA")
|
|
assertContains(t, lines[1], "NS")
|
|
assertContains(t, lines[2], "TXT")
|
|
}
|
|
|
|
func TestNormalizeFQDN(t *testing.T) {
|
|
tests := []struct {
|
|
in, want string
|
|
}{
|
|
{"example.com.", "example.com."},
|
|
{"example.com", "example.com."},
|
|
{"Example.COM.", "example.com."},
|
|
{" example.com ", "example.com."},
|
|
{"", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := normalizeFQDN(tt.in)
|
|
if got != tt.want {
|
|
t.Errorf("normalizeFQDN(%q) = %q, want %q", tt.in, got, tt.want)
|
|
}
|
|
}
|
|
}
|