Generate a BIND-format domains.conf file alongside catalog zones. New input properties: file= (zone data path) and dnssec (bare flag). When --bind-conf is set, every zone must have file= or it errors. Renames ZoneEntry.File to ZonesFile (input path for error messages) and adds ZoneFile (BIND file path) and DNSSEC (bool) fields.
522 lines
14 KiB
Go
522 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestIntegrationEndToEnd(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Write config
|
|
configContent := `catalogs:
|
|
catalog1:
|
|
zone: catalog1.example.com.
|
|
catalog2:
|
|
zone: catalog2.example.com.
|
|
|
|
soa:
|
|
mname: ns1.example.com.
|
|
rname: hostmaster.example.com.
|
|
`
|
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
|
|
|
// Write input
|
|
inputContent := `# Test zones
|
|
zone.example.org catalog1, catalog2
|
|
zone.example.com catalog2, coo=old-catalog.example.com.
|
|
test.example.net catalog1, group=internal
|
|
app.example.org catalog1, group=external, coo=migrated.example.com.
|
|
`
|
|
writeTestFile(t, dir, "zones.txt", inputContent)
|
|
|
|
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("loadConfig: %v", err)
|
|
}
|
|
|
|
_, members, err := parseInput(filepath.Join(dir, "zones.txt"), cfg)
|
|
if err != nil {
|
|
t.Fatalf("parseInput: %v", err)
|
|
}
|
|
|
|
now := fixedTime(2026, 1, 15)
|
|
|
|
// First run: should create files
|
|
for catName, catMembers := range members {
|
|
changed, err := processCatalog(catName, cfg, catMembers, dir, now)
|
|
if err != nil {
|
|
t.Fatalf("processCatalog(%s): %v", catName, err)
|
|
}
|
|
if !changed {
|
|
t.Errorf("expected %s to be changed on first run", catName)
|
|
}
|
|
}
|
|
|
|
// Verify files exist
|
|
cat1Path := filepath.Join(dir, "catalog1.example.com.zone")
|
|
cat2Path := filepath.Join(dir, "catalog2.example.com.zone")
|
|
|
|
cat1Content := readTestFile(t, cat1Path)
|
|
cat2Content := readTestFile(t, cat2Path)
|
|
|
|
// Verify catalog1 content
|
|
assertContains(t, cat1Content, "catalog1.example.com.\t0\tIN\tSOA")
|
|
assertContains(t, cat1Content, "catalog1.example.com.\t0\tIN\tNS\tinvalid.")
|
|
assertContains(t, cat1Content, "version.catalog1.example.com.\t0\tIN\tTXT\t\"2\"")
|
|
assertContains(t, cat1Content, "IN\tPTR\tapp.example.org.")
|
|
assertContains(t, cat1Content, "IN\tPTR\ttest.example.net.")
|
|
assertContains(t, cat1Content, "IN\tPTR\tzone.example.org.")
|
|
assertContains(t, cat1Content, "IN\tTXT\t\"internal\"")
|
|
assertContains(t, cat1Content, "IN\tTXT\t\"external\"")
|
|
assertContains(t, cat1Content, "IN\tPTR\tmigrated.example.com.")
|
|
|
|
// Verify catalog2 content
|
|
assertContains(t, cat2Content, "catalog2.example.com.\t0\tIN\tSOA")
|
|
assertContains(t, cat2Content, "IN\tPTR\tzone.example.com.")
|
|
assertContains(t, cat2Content, "IN\tPTR\tzone.example.org.")
|
|
assertContains(t, cat2Content, "IN\tPTR\told-catalog.example.com.")
|
|
|
|
// Verify serial format
|
|
assertContains(t, cat1Content, "2026011501")
|
|
|
|
// Second run (same time): should be unchanged
|
|
for catName, catMembers := range members {
|
|
changed, err := processCatalog(catName, cfg, catMembers, dir, now)
|
|
if err != nil {
|
|
t.Fatalf("processCatalog(%s) second run: %v", catName, err)
|
|
}
|
|
if changed {
|
|
t.Errorf("expected %s to be unchanged on second run", catName)
|
|
}
|
|
}
|
|
|
|
// Verify content hasn't changed
|
|
cat1After := readTestFile(t, cat1Path)
|
|
if cat1After != cat1Content {
|
|
t.Error("catalog1 content changed on idempotent run")
|
|
}
|
|
}
|
|
|
|
func TestIntegrationSerialBump(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
configContent := `catalogs:
|
|
cat1:
|
|
zone: cat1.example.com.
|
|
|
|
soa:
|
|
mname: ns1.example.com.
|
|
rname: hostmaster.example.com.
|
|
`
|
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
|
|
|
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
members1 := []ZoneEntry{
|
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
|
}
|
|
|
|
now := fixedTime(2026, 1, 15)
|
|
|
|
// First run
|
|
changed, err := processCatalog("cat1", cfg, members1, dir, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !changed {
|
|
t.Error("expected changed on first run")
|
|
}
|
|
|
|
content1 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
|
assertContains(t, content1, "2026011501")
|
|
|
|
// Second run with different input (same day)
|
|
members2 := []ZoneEntry{
|
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
|
|
}
|
|
|
|
changed, err = processCatalog("cat1", cfg, members2, dir, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !changed {
|
|
t.Error("expected changed when input differs")
|
|
}
|
|
|
|
content2 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
|
assertContains(t, content2, "2026011502")
|
|
|
|
// Third run with same input: unchanged
|
|
changed, err = processCatalog("cat1", cfg, members2, dir, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if changed {
|
|
t.Error("expected unchanged on third run")
|
|
}
|
|
|
|
// Fourth run next day with same input: new serial
|
|
nextDay := fixedTime(2026, 1, 16)
|
|
changed, err = processCatalog("cat1", cfg, members2, dir, nextDay)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Content hasn't changed, so this should be unchanged even though date differs
|
|
if changed {
|
|
t.Error("expected unchanged when content hasn't changed")
|
|
}
|
|
}
|
|
|
|
func TestIntegrationSortOrder(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
configContent := `catalogs:
|
|
cat1:
|
|
zone: cat1.example.com.
|
|
|
|
soa:
|
|
mname: ns1.example.com.
|
|
rname: hostmaster.example.com.
|
|
`
|
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
|
|
|
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Input in reverse order
|
|
members := []ZoneEntry{
|
|
{Zone: "z.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
|
{Zone: "m.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
|
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 3},
|
|
}
|
|
|
|
now := fixedTime(2026, 1, 15)
|
|
_, err = processCatalog("cat1", cfg, members, dir, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
content := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
|
lines := splitLines(content)
|
|
|
|
// Find PTR lines and verify order
|
|
var ptrZones []string
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "\tPTR\t") && !strings.Contains(line, "coo.") {
|
|
// Extract the PTR target
|
|
parts := strings.Split(line, "\t")
|
|
ptrZones = append(ptrZones, parts[len(parts)-1])
|
|
}
|
|
}
|
|
|
|
if len(ptrZones) != 3 {
|
|
t.Fatalf("expected 3 PTR records, got %d", len(ptrZones))
|
|
}
|
|
if ptrZones[0] != "a.example.com." || ptrZones[1] != "m.example.com." || ptrZones[2] != "z.example.com." {
|
|
t.Errorf("PTR records not sorted: %v", ptrZones)
|
|
}
|
|
}
|
|
|
|
func TestIntegrationCLI(t *testing.T) {
|
|
// Build the binary
|
|
binary := filepath.Join(t.TempDir(), "catalog-zone-gen")
|
|
cmd := exec.Command("go", "build", "-o", binary, ".")
|
|
cmd.Dir = projectDir(t)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("go build failed: %v\n%s", err, out)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
|
|
configContent := `catalogs:
|
|
catalog1:
|
|
zone: catalog1.example.com.
|
|
|
|
soa:
|
|
mname: ns1.example.com.
|
|
rname: hostmaster.example.com.
|
|
`
|
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
|
|
|
inputContent := "a.example.com. catalog1\n"
|
|
writeTestFile(t, dir, "zones.txt", inputContent)
|
|
|
|
// Run the binary
|
|
cmd = exec.Command(binary, "--config", filepath.Join(dir, "catz.yaml"), "--output-dir", dir, filepath.Join(dir, "zones.txt"))
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("catalog-zone-gen failed: %v\n%s", err, out)
|
|
}
|
|
|
|
// Verify output file exists
|
|
zonePath := filepath.Join(dir, "catalog1.example.com.zone")
|
|
if _, err := os.Stat(zonePath); err != nil {
|
|
t.Fatalf("output file not created: %v", err)
|
|
}
|
|
|
|
content := readTestFile(t, zonePath)
|
|
assertContains(t, content, "catalog1.example.com.")
|
|
assertContains(t, content, "a.example.com.")
|
|
}
|
|
|
|
func TestIntegrationCLIErrors(t *testing.T) {
|
|
binary := filepath.Join(t.TempDir(), "catalog-zone-gen")
|
|
cmd := exec.Command("go", "build", "-o", binary, ".")
|
|
cmd.Dir = projectDir(t)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("go build failed: %v\n%s", err, out)
|
|
}
|
|
|
|
t.Run("no arguments", func(t *testing.T) {
|
|
cmd := exec.Command(binary)
|
|
err := cmd.Run()
|
|
if err == nil {
|
|
t.Error("expected error with no arguments")
|
|
}
|
|
})
|
|
|
|
t.Run("missing input file", func(t *testing.T) {
|
|
cmd := exec.Command(binary, "/nonexistent/zones.txt")
|
|
err := cmd.Run()
|
|
if err == nil {
|
|
t.Error("expected error with missing input file")
|
|
}
|
|
})
|
|
|
|
t.Run("unknown catalog in input", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeTestFile(t, dir, "catz.yaml", `catalogs:
|
|
cat1:
|
|
zone: cat1.example.com.
|
|
soa:
|
|
mname: ns1.example.com.
|
|
rname: hostmaster.example.com.
|
|
`)
|
|
writeTestFile(t, dir, "zones.txt", "a.example.com. unknown_catalog\n")
|
|
|
|
cmd := exec.Command(binary,
|
|
"--config", filepath.Join(dir, "catz.yaml"),
|
|
"--output-dir", dir,
|
|
filepath.Join(dir, "zones.txt"))
|
|
out, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Error("expected error for unknown catalog")
|
|
}
|
|
assertContains(t, string(out), "unknown catalog")
|
|
})
|
|
}
|
|
|
|
func writeTestFile(t *testing.T, dir, name, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func readTestFile(t *testing.T, path string) string {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func projectDir(t *testing.T) string {
|
|
t.Helper()
|
|
// Find the project root by looking for go.mod
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
return dir
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
t.Fatal("could not find project root (go.mod)")
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// Verify the serial stays the same when the same time is used but content changes on a new day.
|
|
func TestIntegrationSerialNewDay(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
configContent := `catalogs:
|
|
cat1:
|
|
zone: cat1.example.com.
|
|
|
|
soa:
|
|
mname: ns1.example.com.
|
|
rname: hostmaster.example.com.
|
|
`
|
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
|
|
|
cfg, err := loadConfig(filepath.Join(dir, "catz.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
members := []ZoneEntry{
|
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
|
}
|
|
|
|
day1 := fixedTime(2026, 1, 15)
|
|
_, err = processCatalog("cat1", cfg, members, dir, day1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
content1 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
|
assertContains(t, content1, "2026011501")
|
|
|
|
// Add a zone on day 2
|
|
members2 := []ZoneEntry{
|
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
|
|
}
|
|
|
|
day2 := fixedTime(2026, 1, 16)
|
|
changed, err := processCatalog("cat1", cfg, members2, dir, day2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !changed {
|
|
t.Error("expected changed with new zone")
|
|
}
|
|
|
|
content2 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
|
assertContains(t, content2, "2026011601")
|
|
|
|
// Simulate not using the tool for a while, running on a much later date
|
|
day3 := fixedTime(2026, 6, 15)
|
|
members3 := []ZoneEntry{
|
|
{Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1},
|
|
{Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2},
|
|
{Zone: "c.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 3},
|
|
}
|
|
|
|
changed, err = processCatalog("cat1", cfg, members3, dir, day3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !changed {
|
|
t.Error("expected changed with new zone")
|
|
}
|
|
|
|
content3 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone"))
|
|
assertContains(t, content3, "2026061501")
|
|
}
|
|
|
|
func TestIntegrationCLIBindConf(t *testing.T) {
|
|
binary := filepath.Join(t.TempDir(), "catalog-zone-gen")
|
|
cmd := exec.Command("go", "build", "-o", binary, ".")
|
|
cmd.Dir = projectDir(t)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("go build failed: %v\n%s", err, out)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
|
|
configContent := `catalogs:
|
|
catalog1:
|
|
zone: catalog1.example.com.
|
|
|
|
soa:
|
|
mname: ns1.example.com.
|
|
rname: hostmaster.example.com.
|
|
`
|
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
|
|
|
inputContent := `zone.example.org catalog1, file=data/zones/example.org, dnssec
|
|
zone.example.com catalog1, file=data/zones/example.com
|
|
`
|
|
writeTestFile(t, dir, "zones.txt", inputContent)
|
|
|
|
bindConfPath := filepath.Join(dir, "domains.conf")
|
|
|
|
cmd = exec.Command(binary,
|
|
"--config", filepath.Join(dir, "catz.yaml"),
|
|
"--output-dir", dir,
|
|
"--bind-conf", bindConfPath,
|
|
filepath.Join(dir, "zones.txt"))
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("catalog-zone-gen failed: %v\n%s", err, out)
|
|
}
|
|
|
|
// Verify bind conf was written
|
|
content := readTestFile(t, bindConfPath)
|
|
assertContains(t, content, "# THIS FILE IS GENERATED BY catalog-zone-gen")
|
|
|
|
// zone.example.com should come before zone.example.org (sorted)
|
|
comIdx := strings.Index(content, "zone.example.com")
|
|
orgIdx := strings.Index(content, "zone.example.org")
|
|
if comIdx >= orgIdx {
|
|
t.Error("expected zone.example.com before zone.example.org (alphabetical)")
|
|
}
|
|
|
|
// DNSSEC zone
|
|
assertContains(t, content, "dnssec-policy standard; inline-signing yes;")
|
|
// Non-DNSSEC zone should not have it
|
|
lines := splitLines(content)
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "example.com") && strings.Contains(line, "file") {
|
|
if strings.Contains(line, "dnssec-policy") {
|
|
t.Error("example.com should not have dnssec-policy")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIntegrationCLIBindConfMissingFile(t *testing.T) {
|
|
binary := filepath.Join(t.TempDir(), "catalog-zone-gen")
|
|
cmd := exec.Command("go", "build", "-o", binary, ".")
|
|
cmd.Dir = projectDir(t)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("go build failed: %v\n%s", err, out)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
|
|
configContent := `catalogs:
|
|
catalog1:
|
|
zone: catalog1.example.com.
|
|
|
|
soa:
|
|
mname: ns1.example.com.
|
|
rname: hostmaster.example.com.
|
|
`
|
|
writeTestFile(t, dir, "catz.yaml", configContent)
|
|
|
|
// Zone without file= property
|
|
inputContent := "zone.example.org catalog1\n"
|
|
writeTestFile(t, dir, "zones.txt", inputContent)
|
|
|
|
bindConfPath := filepath.Join(dir, "domains.conf")
|
|
|
|
cmd = exec.Command(binary,
|
|
"--config", filepath.Join(dir, "catz.yaml"),
|
|
"--output-dir", dir,
|
|
"--bind-conf", bindConfPath,
|
|
filepath.Join(dir, "zones.txt"))
|
|
out, err := cmd.CombinedOutput()
|
|
if err == nil {
|
|
t.Fatal("expected error when file= is missing with --bind-conf")
|
|
}
|
|
assertContains(t, string(out), "missing file=")
|
|
}
|