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.
278 lines
7.1 KiB
Go
278 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseLine(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
line string
|
|
wantZone string
|
|
wantCats []string
|
|
wantGrp string
|
|
wantCOO string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "simple single catalog",
|
|
line: "zone.example.org catalog1",
|
|
wantZone: "zone.example.org.",
|
|
wantCats: []string{"catalog1"},
|
|
},
|
|
{
|
|
name: "multiple catalogs comma separated",
|
|
line: "zone.example.org catalog1, catalog2",
|
|
wantZone: "zone.example.org.",
|
|
wantCats: []string{"catalog1", "catalog2"},
|
|
},
|
|
{
|
|
name: "with group",
|
|
line: "test.example.net catalog1, group=internal",
|
|
wantZone: "test.example.net.",
|
|
wantCats: []string{"catalog1"},
|
|
wantGrp: "internal",
|
|
},
|
|
{
|
|
name: "with coo",
|
|
line: "zone.example.com catalog2, coo=old-catalog.example.com.",
|
|
wantZone: "zone.example.com.",
|
|
wantCats: []string{"catalog2"},
|
|
wantCOO: "old-catalog.example.com.",
|
|
},
|
|
{
|
|
name: "with group and coo",
|
|
line: "app.example.org catalog1, group=external, coo=migrated.example.com.",
|
|
wantZone: "app.example.org.",
|
|
wantCats: []string{"catalog1"},
|
|
wantGrp: "external",
|
|
wantCOO: "migrated.example.com.",
|
|
},
|
|
{
|
|
name: "trailing dot on zone",
|
|
line: "zone.example.org. catalog1",
|
|
wantZone: "zone.example.org.",
|
|
wantCats: []string{"catalog1"},
|
|
},
|
|
{
|
|
name: "no catalog",
|
|
line: "zone.example.org",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "only properties no catalog",
|
|
line: "zone.example.org group=foo",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unknown property",
|
|
line: "zone.example.org catalog1, foo=bar",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty group value",
|
|
line: "zone.example.org catalog1, group=",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty coo value",
|
|
line: "zone.example.org catalog1, coo=",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
entry, err := parseLine(tt.line, "test.txt", 1)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if entry.Zone != tt.wantZone {
|
|
t.Errorf("zone = %q, want %q", entry.Zone, tt.wantZone)
|
|
}
|
|
if len(entry.Catalogs) != len(tt.wantCats) {
|
|
t.Fatalf("catalogs = %v, want %v", entry.Catalogs, tt.wantCats)
|
|
}
|
|
for i, cat := range entry.Catalogs {
|
|
if cat != tt.wantCats[i] {
|
|
t.Errorf("catalog[%d] = %q, want %q", i, cat, tt.wantCats[i])
|
|
}
|
|
}
|
|
if entry.Group != tt.wantGrp {
|
|
t.Errorf("group = %q, want %q", entry.Group, tt.wantGrp)
|
|
}
|
|
if entry.COO != tt.wantCOO {
|
|
t.Errorf("coo = %q, want %q", entry.COO, tt.wantCOO)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTokenize(t *testing.T) {
|
|
tests := []struct {
|
|
line string
|
|
want []string
|
|
}{
|
|
{"zone.example.org catalog1, catalog2", []string{"zone.example.org", "catalog1", "catalog2"}},
|
|
{"zone.example.org catalog1,catalog2", []string{"zone.example.org", "catalog1", "catalog2"}},
|
|
{"zone.example.org catalog1 , catalog2", []string{"zone.example.org", "catalog1", "catalog2"}},
|
|
{"zone.example.org\tcatalog1", []string{"zone.example.org", "catalog1"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := tokenize(tt.line)
|
|
if len(got) != len(tt.want) {
|
|
t.Errorf("tokenize(%q) = %v, want %v", tt.line, got, tt.want)
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != tt.want[i] {
|
|
t.Errorf("tokenize(%q)[%d] = %q, want %q", tt.line, i, got[i], tt.want[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildCatalogMembers(t *testing.T) {
|
|
cfg := &Config{
|
|
Catalogs: map[string]CatalogConfig{
|
|
"catalog1": {Zone: "catalog1.example.com."},
|
|
"catalog2": {Zone: "catalog2.example.com."},
|
|
},
|
|
}
|
|
|
|
t.Run("valid input", func(t *testing.T) {
|
|
entries := []ZoneEntry{
|
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 1},
|
|
{Zone: "b.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 2},
|
|
}
|
|
|
|
members, err := buildCatalogMembers(entries, cfg)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(members["catalog1"]) != 2 {
|
|
t.Errorf("catalog1 members = %d, want 2", len(members["catalog1"]))
|
|
}
|
|
if len(members["catalog2"]) != 1 {
|
|
t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"]))
|
|
}
|
|
})
|
|
|
|
t.Run("unknown catalog", func(t *testing.T) {
|
|
entries := []ZoneEntry{
|
|
{Zone: "a.example.com.", Catalogs: []string{"unknown"}, File: "test", Line: 1},
|
|
}
|
|
_, err := buildCatalogMembers(entries, cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown catalog")
|
|
}
|
|
})
|
|
|
|
t.Run("duplicate zone in same catalog", func(t *testing.T) {
|
|
entries := []ZoneEntry{
|
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 1},
|
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 2},
|
|
}
|
|
_, err := buildCatalogMembers(entries, cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error for duplicate zone")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseInput(t *testing.T) {
|
|
cfg := &Config{
|
|
Catalogs: map[string]CatalogConfig{
|
|
"catalog1": {Zone: "catalog1.example.com."},
|
|
"catalog2": {Zone: "catalog2.example.com."},
|
|
},
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
inputPath := filepath.Join(dir, "zones.txt")
|
|
|
|
content := `# Comment
|
|
zone.example.org catalog1, catalog2
|
|
|
|
test.example.net catalog1, group=internal
|
|
`
|
|
if err := os.WriteFile(inputPath, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
members, err := parseInput(inputPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(members["catalog1"]) != 2 {
|
|
t.Errorf("catalog1 members = %d, want 2", len(members["catalog1"]))
|
|
}
|
|
if len(members["catalog2"]) != 1 {
|
|
t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"]))
|
|
}
|
|
}
|
|
|
|
func TestParseInputErrors(t *testing.T) {
|
|
cfg := &Config{
|
|
Catalogs: map[string]CatalogConfig{
|
|
"catalog1": {Zone: "catalog1.example.com."},
|
|
},
|
|
}
|
|
|
|
t.Run("missing file", func(t *testing.T) {
|
|
_, err := parseInput("/nonexistent/zones.txt", cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing file")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid line in input", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "zones.txt")
|
|
if err := os.WriteFile(path, []byte("zone-with-no-catalog\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := parseInput(path, cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid line")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildCatalogMembersSameZoneDifferentCatalogs(t *testing.T) {
|
|
cfg := &Config{
|
|
Catalogs: map[string]CatalogConfig{
|
|
"catalog1": {Zone: "catalog1.example.com."},
|
|
"catalog2": {Zone: "catalog2.example.com."},
|
|
},
|
|
}
|
|
|
|
// Same zone in different catalogs is OK
|
|
entries := []ZoneEntry{
|
|
{Zone: "a.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 1},
|
|
}
|
|
|
|
members, err := buildCatalogMembers(entries, cfg)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(members["catalog1"]) != 1 {
|
|
t.Errorf("catalog1 members = %d, want 1", len(members["catalog1"]))
|
|
}
|
|
if len(members["catalog2"]) != 1 {
|
|
t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"]))
|
|
}
|
|
}
|