package rfc2136 import ( "math" "os" "path/filepath" "strings" "testing" "time" "github.com/miekg/dns" ) // mustRR is a test helper that parses an RR string or fails the test. // Used widely in zonefile + update tests. func mustRR(t *testing.T, s string) dns.RR { t.Helper() rr, err := dns.NewRR(s) if err != nil { t.Fatalf("failed to parse RR %q: %v", s, err) } return rr } func TestZoneFile_LoadRRs(t *testing.T) { dir := withTempZonesDir(t, "auth.example.com") zf := openZoneFile(filepath.Join(dir, "auth.example.com.zone"), "auth.example.com.") rrs, _, err := zf.loadRRs() if err != nil { t.Fatalf("loadRRs: %v", err) } if len(rrs) < 2 { t.Fatalf("expected >=2 RRs (SOA + NS), got %d", len(rrs)) } // First RR should be the SOA. if _, ok := rrs[0].(*dns.SOA); !ok { t.Errorf("first RR is %T, want SOA", rrs[0]) } } func TestZoneFile_LoadRRs_MissingFile(t *testing.T) { zf := openZoneFile("/nope/missing.zone", "missing.") if _, _, err := zf.loadRRs(); err == nil { t.Errorf("expected error loading missing file, got nil") } } func TestLookupIn_CaseInsensitive(t *testing.T) { rrs := []dns.RR{ mustRR(t, `FOO.example.com. 60 IN TXT "hello"`), } if got := lookupIn(rrs, "foo.EXAMPLE.com.", dns.TypeTXT); len(got) != 1 { t.Errorf("case-insensitive lookup failed: %d results", len(got)) } } func TestLookupIn_WrongTypeReturnsEmpty(t *testing.T) { rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`)} if got := lookupIn(rrs, "foo.example.com.", dns.TypeTXT); len(got) != 0 { t.Errorf("wrong-type lookup returned %d results, want 0", len(got)) } } func TestNameExistsIn(t *testing.T) { rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`)} if !nameExistsIn(rrs, "foo.example.com.") { t.Errorf("nameExistsIn returned false for present name") } if nameExistsIn(rrs, "bar.example.com.") { t.Errorf("nameExistsIn returned true for absent name") } } func TestRemoveRRsetFrom(t *testing.T) { rrs := []dns.RR{ mustRR(t, `foo.example.com. 60 IN TXT "a"`), mustRR(t, `foo.example.com. 60 IN TXT "b"`), mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`), mustRR(t, `bar.example.com. 60 IN A 192.0.2.2`), } out := removeRRsetFrom(rrs, "foo.example.com.", dns.TypeTXT) if len(out) != 2 { t.Errorf("after removing TXT rrset, got %d records, want 2", len(out)) } // The remaining records: foo's A + bar's A for _, rr := range out { if rr.Header().Rrtype == dns.TypeTXT { t.Errorf("a TXT slipped through removal: %s", rr.String()) } } } func TestRemoveRRFrom_ExactMatchOnly(t *testing.T) { rrs := []dns.RR{ mustRR(t, `foo.example.com. 60 IN TXT "keep"`), mustRR(t, `foo.example.com. 60 IN TXT "drop"`), } out := removeRRFrom(rrs, mustRR(t, `foo.example.com. 60 IN TXT "drop"`)) if len(out) != 1 || out[0].(*dns.TXT).Txt[0] != "keep" { t.Errorf("removeRRFrom wrong result: %v", out) } } func TestRemoveNameFrom(t *testing.T) { rrs := []dns.RR{ mustRR(t, `foo.example.com. 60 IN TXT "x"`), mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`), mustRR(t, `bar.example.com. 60 IN A 192.0.2.2`), } out := removeNameFrom(rrs, "foo.example.com.") if len(out) != 1 || out[0].Header().Name != "bar.example.com." { t.Errorf("removeNameFrom left %v, want only bar", out) } } func TestAddRRTo_Dedupes(t *testing.T) { rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN TXT "same"`)} out := addRRTo(rrs, mustRR(t, `foo.example.com. 60 IN TXT "same"`)) if len(out) != 1 { t.Errorf("identical RR was duplicated: %v", out) } } // bumpSerial tests below pin the YYMMDD*10000+NNNN encoding. For // 2026-05-21 (used as `now` in most cases): YYMMDD=260521, baseline // "today, NNNN=0001" = 2,605,210,001. func TestBumpSerial_SameDay_NNNNIncrement(t *testing.T) { rrs := []dns.RR{ &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA}, Serial: 2605210105}, // today, NNNN=0105 } now := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) if err := bumpSerial(rrs, now); err != nil { t.Fatalf("bumpSerial: %v", err) } if got := rrs[0].(*dns.SOA).Serial; got != 2605210106 { t.Errorf("Serial = %d, want 2605210106", got) } } func TestBumpSerial_NewDay_DateAdvancesNNNN0001(t *testing.T) { rrs := []dns.RR{ &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA}, Serial: 2605209999}, // exhausted yesterday (in new format) } now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC) if err := bumpSerial(rrs, now); err != nil { t.Fatalf("bumpSerial: %v", err) } if got := rrs[0].(*dns.SOA).Serial; got != 2605210001 { t.Errorf("Serial = %d, want 2605210001 (today, NNNN=0001)", got) } } func TestBumpSerial_NNNN9999_RollsToNextEncodedDay(t *testing.T) { rrs := []dns.RR{ &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA}, Serial: 2605219999}, // today, NNNN=9999 (the burst-day overflow case) } now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC) if err := bumpSerial(rrs, now); err != nil { t.Fatalf("bumpSerial: %v", err) } if got := rrs[0].(*dns.SOA).Serial; got != 2605220001 { t.Errorf("Serial = %d, want 2605220001 (next encoded day, NNNN=0001)", got) } } func TestBumpSerial_FutureEncodedDate_DoesNotRegress(t *testing.T) { // Reproduces the bug from 2026-05-22: an earlier bumpSerial would // downgrade a future-encoded serial back to today's NNNN=0001. Now, // future-encoded serials are honoured: their NNNN increments. rrs := []dns.RR{ &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA}, Serial: 2605220500}, // tomorrow (encoded), NNNN=0500 } now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC) if err := bumpSerial(rrs, now); err != nil { t.Fatalf("bumpSerial: %v", err) } if got := rrs[0].(*dns.SOA).Serial; got != 2605220501 { t.Errorf("Serial = %d, want 2605220501 (future date NNNN+1, no regression)", got) } } func TestBumpSerial_LegacyYYYYMMDDNNFormat_MigratesForward(t *testing.T) { // The migration path: any production zone whose SOA serial is in the // old 10-digit YYYYMMDDNN format (e.g., 2026052299) gets numerically // crushed by today's YYMMDDNNNN minimum, so the "older/unparseable" // branch fires and rewrites in place. rrs := []dns.RR{ &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA}, Serial: 2026052299}, // legacy YYYYMMDDNN, exhausted day } now := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) // 2026-05-22 if err := bumpSerial(rrs, now); err != nil { t.Fatalf("bumpSerial: %v", err) } got := rrs[0].(*dns.SOA).Serial if got != 2605220001 { t.Errorf("Serial = %d, want 2605220001 (migrated to new format)", got) } if got <= 2026052299 { t.Errorf("migration regressed serial: %d <= legacy %d", got, 2026052299) } } func TestBumpSerial_NonCalVerFormat_ResetsToToday(t *testing.T) { rrs := []dns.RR{ &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA}, Serial: 12345}, // random small value, not CalVer } now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC) if err := bumpSerial(rrs, now); err != nil { t.Fatalf("bumpSerial: %v", err) } if got := rrs[0].(*dns.SOA).Serial; got != 2605210001 { t.Errorf("Serial = %d, want 2605210001", got) } } // TestBumpSerial_MaxUint32_RefusesWrap covers H5: the defensive // branch must not wrap soa.Serial to 0. Wrap-to-0 makes downstream // secondaries treat the zone as reset per RFC 1982 and refuse to AXFR // the new value, taking the zone dark. func TestBumpSerial_MaxUint32_RefusesWrap(t *testing.T) { rrs := []dns.RR{ &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA}, Serial: math.MaxUint32}, } now := time.Date(2026, 5, 22, 1, 0, 0, 0, time.UTC) if err := bumpSerial(rrs, now); err == nil { t.Errorf("bumpSerial accepted MaxUint32; expected refusal to prevent wrap-to-0") } if got := rrs[0].(*dns.SOA).Serial; got == 0 { t.Errorf("Serial wrapped to 0 (catastrophic): %d", got) } } // TestLoadRRs_NoSOA_Refused covers H4: a zone file that's missing its // SOA (because the parser ate it on a malformed prior line, or because // it was never there) must fail loadRRs rather than be silently treated // as a valid empty-of-SOA zone. func TestLoadRRs_NoSOA_Refused(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "noSoA.example.com.zone") content := "$ORIGIN noSoA.example.com.\nfoo.noSoA.example.com. 60 IN A 192.0.2.1\n" if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("write fixture: %v", err) } zf := openZoneFile(path, "noSoA.example.com.") if _, _, err := zf.loadRRs(); err == nil { t.Errorf("loadRRs accepted SOA-less zone file; expected refusal") } } // TestLoadRRs_MultipleSOAs_Refused covers H3: two SOAs at the apex // produce inconsistent zone state visible to AXFR clients. We refuse // to operate on such a file rather than bumping only the first SOA. func TestLoadRRs_MultipleSOAs_Refused(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "dupe.example.com.zone") content := `$ORIGIN dupe.example.com. dupe.example.com. 3600 IN SOA ns.example. admin.example. 1 60 60 60 60 dupe.example.com. 3600 IN SOA ns.example. admin.example. 2 60 60 60 60 dupe.example.com. 3600 IN NS ns.example. ` if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("write fixture: %v", err) } zf := openZoneFile(path, "dupe.example.com.") if _, _, err := zf.loadRRs(); err == nil { t.Errorf("loadRRs accepted dual-SOA zone; expected refusal") } } // TestLoadRRs_NonApexSOA_Refused covers H3: an SOA owned by a name // other than the zone apex is a parse-error red flag and must be // refused, not silently bumped. func TestLoadRRs_NonApexSOA_Refused(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "weird.example.com.zone") content := `$ORIGIN weird.example.com. sub.weird.example.com. 3600 IN SOA ns.example. admin.example. 1 60 60 60 60 weird.example.com. 3600 IN NS ns.example. ` if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("write fixture: %v", err) } zf := openZoneFile(path, "weird.example.com.") if _, _, err := zf.loadRRs(); err == nil { t.Errorf("loadRRs accepted non-apex SOA; expected refusal") } } func TestBumpSerial_NoSOA_ReturnsError(t *testing.T) { rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`)} now := time.Now() if err := bumpSerial(rrs, now); err == nil { t.Errorf("expected error when no SOA in zone, got nil") } } func TestZoneFile_WriteAtomic_RoundTrip(t *testing.T) { dir := withTempZonesDir(t, "auth.example.com") path := filepath.Join(dir, "auth.example.com.zone") zf := openZoneFile(path, "auth.example.com.") zf.AutoCommit = false // not testing commit here rrs, _, err := zf.loadRRs() if err != nil { t.Fatalf("initial load: %v", err) } originalCount := len(rrs) // Add a record + write. rrs = addRRTo(rrs, mustRR(t, `new.auth.example.com. 60 IN TXT "added"`)) if err := zf.writeAtomic(rrs, time.Now()); err != nil { t.Fatalf("writeAtomic: %v", err) } // Re-load and verify. after, _, err := zf.loadRRs() if err != nil { t.Fatalf("re-load: %v", err) } if len(after) != originalCount+1 { t.Errorf("RR count = %d, want %d", len(after), originalCount+1) } found := false for _, rr := range after { if txt, ok := rr.(*dns.TXT); ok && txt.Hdr.Name == "new.auth.example.com." { found = true break } } if !found { t.Errorf("added TXT not in reloaded zone") } } func TestZoneFile_WriteAtomic_LeavesNoTempFile(t *testing.T) { dir := withTempZonesDir(t, "auth.example.com") path := filepath.Join(dir, "auth.example.com.zone") zf := openZoneFile(path, "auth.example.com.") zf.AutoCommit = false rrs, _, _ := zf.loadRRs() _ = zf.writeAtomic(rrs, time.Now()) // No .rfc2136-*.zone temp files should remain. matches, _ := filepath.Glob(filepath.Join(dir, ".rfc2136-*.zone")) if len(matches) != 0 { t.Errorf("temp files leaked: %v", matches) } } // TestZoneFile_CheckUnchanged_DetectsExternalModification covers H1: // between loadRRs and the next writeAtomic, if anything (rsync, manual // edit, git checkout) clobbered the file, checkUnchanged returns an // error so the caller refuses the UPDATE instead of losing the // external write. func TestZoneFile_CheckUnchanged_DetectsExternalModification(t *testing.T) { dir := withTempZonesDir(t, "auth.example.com") path := filepath.Join(dir, "auth.example.com.zone") zf := openZoneFile(path, "auth.example.com.") zf.AutoCommit = false _, snap, err := zf.loadRRs() if err != nil { t.Fatalf("loadRRs: %v", err) } // Pristine snapshot — checkUnchanged should pass. if err := zf.checkUnchanged(snap); err != nil { t.Errorf("pristine snapshot rejected: %v", err) } // Simulate an external editor clobbering the file (rsync-style: // new content, different size, mtime advances). time.Sleep(10 * time.Millisecond) // ensure mtime differs if err := os.WriteFile(path, []byte("; external edit\n$ORIGIN auth.example.com.\nauth.example.com. 3600 IN SOA ns.example. admin.example. 1 60 60 60 60\n"), 0644); err != nil { t.Fatalf("simulated external edit: %v", err) } if err := zf.checkUnchanged(snap); err == nil { t.Errorf("checkUnchanged accepted modified file; expected concurrent-modification error") } } func TestZoneFile_WriteAtomic_FileEndsWithNewline(t *testing.T) { dir := withTempZonesDir(t, "auth.example.com") path := filepath.Join(dir, "auth.example.com.zone") zf := openZoneFile(path, "auth.example.com.") zf.AutoCommit = false rrs, _, _ := zf.loadRRs() _ = zf.writeAtomic(rrs, time.Now()) data, _ := os.ReadFile(path) if !strings.HasSuffix(string(data), "\n") { t.Errorf("written file should end with newline; tail: %q", data[max(0, len(data)-20):]) } } func max(a, b int) int { if a > b { return a } return b }