Zones without a file= property (e.g. ddns zones) are included in catalog zone output for secondaries but skipped in domains.conf. Previously --bind-conf required every zone to have file= set.
540 lines
15 KiB
Go
540 lines
15 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 TestIntegrationCLIBindConfCatalogOnlyZone(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 (catalog-only, e.g. ddns zone)
|
|
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.Fatalf("unexpected error: %v\n%s", err, out)
|
|
}
|
|
|
|
// domains.conf should exist but not contain the catalog-only zone
|
|
data, readErr := os.ReadFile(bindConfPath)
|
|
if readErr != nil {
|
|
t.Fatalf("failed to read domains.conf: %v", readErr)
|
|
}
|
|
got := string(data)
|
|
assertContains(t, got, "# THIS FILE IS GENERATED BY catalog-zone-gen")
|
|
if strings.Contains(got, "zone.example.org") {
|
|
t.Error("catalog-only zone without file= should not appear in domains.conf")
|
|
}
|
|
|
|
// Catalog zone should still be generated
|
|
catZonePath := filepath.Join(dir, "catalog1.example.com.zone")
|
|
catData, catErr := os.ReadFile(catZonePath)
|
|
if catErr != nil {
|
|
t.Fatalf("catalog zone not written: %v", catErr)
|
|
}
|
|
assertContains(t, string(catData), "zone.example.org")
|
|
}
|