package main import ( "path/filepath" "strings" "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"}, ZonesFile: "test", Line: 1}, {Zone: "a.example.com.", Catalogs: []string{"cat1"}, Group: "mygroup", ZonesFile: "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 strings.Contains(line, "a.example.com.") { if aIdx == -1 { aIdx = i } } if strings.Contains(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 strings.Contains(line, "group.") && strings.Contains(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.", ZonesFile: "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 strings.Contains(line, "coo.") && strings.Contains(line, "old.example.com.") { foundCOO = true } } if !foundCOO { t.Error("expected coo PTR record") } } func TestReadExisting(t *testing.T) { t.Run("file does not exist", func(t *testing.T) { content, serial, err := readExisting("/nonexistent/path.zone") if err != nil { t.Fatalf("unexpected error: %v", err) } if serial != 0 { t.Errorf("serial = %d, want 0", serial) } if content != "" { t.Errorf("content = %q, want empty", content) } }) t.Run("valid zone file", func(t *testing.T) { dir := t.TempDir() zoneContent := "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", zoneContent) content, serial, err := readExisting(filepath.Join(dir, "test.zone")) if err != nil { t.Fatalf("unexpected error: %v", err) } if serial != 2026030205 { t.Errorf("serial = %d, want 2026030205", serial) } if content != zoneContent { t.Errorf("content mismatch: got %q", content) } }) t.Run("zone file with no SOA", func(t *testing.T) { dir := t.TempDir() zoneContent := "example.com. 0 IN NS invalid.\n" writeTestFile(t, dir, "test.zone", zoneContent) content, serial, err := readExisting(filepath.Join(dir, "test.zone")) if err != nil { t.Fatalf("unexpected error: %v", err) } if serial != 0 { t.Errorf("serial = %d, want 0 (no SOA found)", serial) } if content != zoneContent { t.Errorf("content mismatch: got %q", content) } }) } 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) } } }