coredns-rfc2136/zonefile_test.go
Ryan Malloy 0f28127284 Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone
Major architectural pivot per the user's "RFC 2136 mechanism for the
existing zonefiles, not a new in-memory thing" framing. The plugin no
longer maintains its own in-memory state OR serves any queries -- both
of those are now the auto plugin's job, reading the same zone files.

The plugin's sole responsibility is now: receive TSIG-authed UPDATE
messages, edit the matching zones/<zone>.zone file, bump the SOA
serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git.

What changed:
- DELETED: store.go (in-memory recordStore), store_test.go (12 tests),
  plugin_test.go (10 ServeDNS query tests), old update_test.go.
- NEW: zonefile.go -- file-backed authority for one zone. loadRRs via
  miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/
  removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR
  slices; bumpSerial with CalVer semantics + NN exhaustion handling;
  writeAtomic via temp-file rename; commit shells to `git add && git
  commit` with configurable author.
- NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/
  write paths.
- REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler;
  everything else → Next. No synthetic SOA/NS, no query serving.
- REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads,
  applies (with prereq checks against the loaded RRs), bumps serial,
  writes, commits. Detects no-op updates to avoid spurious file writes.
- REWRITTEN: setup.go -- new directives: `zones-dir` (required),
  `auto-commit` (default true), `git-author <name> <email>`. Dropped
  `nameserver` and `persist`. Validates each declared zone has a file
  on disk via os.Stat before CoreDNS finishes starting.
- REWRITTEN: setup_test.go -- 17 cases for the new grammar.
- REWRITTEN: update_test.go -- 11 cases using real temp zone files
  via t.TempDir().

Total: 30 tests passing, 0 failures.

Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
2026-05-21 11:26:50 -06:00

262 lines
7.4 KiB
Go

package rfc2136
import (
"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)
}
}
func TestBumpSerial_SameDay_NNIncrement(t *testing.T) {
rrs := []dns.RR{
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Serial: 2026052105},
}
// Use a 'now' that matches today's serial date.
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 != 2026052106 {
t.Errorf("Serial = %d, want 2026052106", got)
}
}
func TestBumpSerial_NewDay_DateAdvancesNN01(t *testing.T) {
rrs := []dns.RR{
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Serial: 2026052099}, // exhausted yesterday
}
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 != 2026052101 {
t.Errorf("Serial = %d, want 2026052101 (today, NN=01)", got)
}
}
func TestBumpSerial_NNExhausted_ReturnsError(t *testing.T) {
rrs := []dns.RR{
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
Serial: 2026052199}, // today, NN=99
}
now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC)
if err := bumpSerial(rrs, now); err == nil {
t.Errorf("expected error on NN=99, got nil")
}
}
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 unix-ish serial, 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 != 2026052101 {
t.Errorf("Serial = %d, want 2026052101", got)
}
}
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)
}
}
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
}