Add catalog-zone-gen tool
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.
This commit is contained in:
332
catalog_test.go
Normal file
332
catalog_test.go
Normal file
@@ -0,0 +1,332 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user