package rfc2136 import ( "context" "testing" "github.com/miekg/dns" ) // newUpdate builds a minimal UPDATE message for one zone with zero // prereqs and the given update RRs. Caddy's caddy-dns/rfc2136 module // produces messages in this same shape. func newUpdate(zone string, updates ...dns.RR) *dns.Msg { m := new(dns.Msg) m.SetUpdate(dns.Fqdn(zone)) m.Ns = append(m.Ns, updates...) return m } // applyUpdateNoAuth bypasses the TSIG gate so we can test handler // logic directly without setting up dns.Server-level integration in // unit tests. End-to-end TSIG verification happens in Phase 2 with // nsupdate against the live custom CoreDNS binary. func applyUpdateNoAuth(t *testing.T, p *RFC2136, msg *dns.Msg) (rcode int, response *dns.Msg) { t.Helper() w := &captureWriter{} rcode, _ = p.handleUpdate(w, msg) return rcode, w.msg } func TestUpdate_AddSingleTXT(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) upd := newUpdate("auth.example.com.", mustRR(t, `token-1.auth.example.com. 60 IN TXT "validation-1"`), ) rcode, _ := applyUpdateNoAuth(t, p, upd) if rcode != dns.RcodeSuccess { t.Fatalf("rcode = %d, want NOERROR", rcode) } // Verify via a query through ServeDNS. req := new(dns.Msg) req.SetQuestion("token-1.auth.example.com.", dns.TypeTXT) w := &captureWriter{} p.ServeDNS(context.Background(), w, req) if len(w.msg.Answer) != 1 { t.Fatalf("Answer len = %d, want 1", len(w.msg.Answer)) } if w.msg.Answer[0].(*dns.TXT).Txt[0] != "validation-1" { t.Errorf("TXT mismatch: %v", w.msg.Answer[0]) } } func TestUpdate_DeleteRRset(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) p.store.Add(mustRR(t, `token-1.auth.example.com. 60 IN TXT "old-token"`)) // CLASS=ANY, RDLEN=0 → delete this specific RRset. del := &dns.ANY{Hdr: dns.RR_Header{ Name: "token-1.auth.example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassANY, Ttl: 0, }} upd := newUpdate("auth.example.com.", del) rcode, _ := applyUpdateNoAuth(t, p, upd) if rcode != dns.RcodeSuccess { t.Fatalf("rcode = %d, want NOERROR", rcode) } if got := p.store.Lookup("token-1.auth.example.com.", dns.TypeTXT); got != nil { t.Errorf("RRset should be gone, still got %v", got) } } func TestUpdate_DeleteAllRRsetsAtName(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) p.store.Add(mustRR(t, `foo.auth.example.com. 60 IN TXT "t"`)) p.store.Add(mustRR(t, `foo.auth.example.com. 60 IN A 192.0.2.1`)) // CLASS=ANY, TYPE=ANY, RDLEN=0 → wipe the name. del := &dns.ANY{Hdr: dns.RR_Header{ Name: "foo.auth.example.com.", Rrtype: dns.TypeANY, Class: dns.ClassANY, }} rcode, _ := applyUpdateNoAuth(t, p, newUpdate("auth.example.com.", del)) if rcode != dns.RcodeSuccess { t.Fatalf("rcode = %d", rcode) } if p.store.NameExists("foo.auth.example.com.") { t.Errorf("name should be wiped") } } func TestUpdate_OutsideZone_NOTZONE(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) // Update tries to write into a different zone. upd := newUpdate("auth.example.com.", mustRR(t, `evil.different.tld. 60 IN TXT "nope"`), ) rcode, _ := applyUpdateNoAuth(t, p, upd) if rcode != dns.RcodeNotZone { t.Errorf("rcode = %d, want NOTZONE (%d)", rcode, dns.RcodeNotZone) } } func TestUpdate_ZoneSectionNotSOA_FORMERR(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) // Hand-built broken UPDATE: zone section type is TXT, not SOA. m := new(dns.Msg) m.SetUpdate("auth.example.com.") m.Question[0].Qtype = dns.TypeTXT // <-- wrong; must be SOA per RFC 2136 rcode, _ := applyUpdateNoAuth(t, p, m) if rcode != dns.RcodeFormatError { t.Errorf("rcode = %d, want FORMERR (%d)", rcode, dns.RcodeFormatError) } } func TestUpdate_UnauthorisedZone_NOTAUTH(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) m := new(dns.Msg) m.SetUpdate("not-our-zone.com.") // we're not authoritative for this m.Ns = []dns.RR{mustRR(t, `x.not-our-zone.com. 60 IN TXT "hi"`)} rcode, _ := applyUpdateNoAuth(t, p, m) if rcode != dns.RcodeNotAuth { t.Errorf("rcode = %d, want NOTAUTH (%d)", rcode, dns.RcodeNotAuth) } } func TestUpdate_PrereqNameExists_NXDOMAIN(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) // Prereq: name must exist (CLASS=ANY, TYPE=ANY). It doesn't. prereq := &dns.ANY{Hdr: dns.RR_Header{ Name: "ghost.auth.example.com.", Rrtype: dns.TypeANY, Class: dns.ClassANY, }} m := new(dns.Msg) m.SetUpdate("auth.example.com.") m.Answer = []dns.RR{prereq} m.Ns = []dns.RR{mustRR(t, `x.auth.example.com. 60 IN TXT "y"`)} rcode, _ := applyUpdateNoAuth(t, p, m) if rcode != dns.RcodeNameError { t.Errorf("rcode = %d, want NXDOMAIN (%d)", rcode, dns.RcodeNameError) } } func TestUpdate_PrereqRRsetMustNotExist_YXRRSET(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) p.store.Add(mustRR(t, `existing.auth.example.com. 60 IN TXT "present"`)) // Prereq: TXT RRset must NOT exist at this name (CLASS=NONE, TYPE=TXT). prereq := &dns.ANY{Hdr: dns.RR_Header{ Name: "existing.auth.example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassNONE, }} m := new(dns.Msg) m.SetUpdate("auth.example.com.") m.Answer = []dns.RR{prereq} rcode, _ := applyUpdateNoAuth(t, p, m) if rcode != dns.RcodeYXRrset { t.Errorf("rcode = %d, want YXRRSET (%d)", rcode, dns.RcodeYXRrset) } } func TestUpdate_ApexSOA_RefusedForAdd(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) // Attempting to add an SOA at the apex must be refused — we serve // SOA synthetically. soa := mustRR(t, `auth.example.com. 60 IN SOA ns.example.com. admin.auth.example.com. 1 3600 600 604800 60`) rcode, _ := applyUpdateNoAuth(t, p, newUpdate("auth.example.com.", soa)) if rcode != dns.RcodeRefused { t.Errorf("rcode = %d, want REFUSED (%d) for SOA-at-apex add", rcode, dns.RcodeRefused) } } func TestUpdate_ApexDeletion_Refused(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) // CLASS=ANY, TYPE=ANY at the apex → would wipe the zone. Refuse. del := &dns.ANY{Hdr: dns.RR_Header{ Name: "auth.example.com.", Rrtype: dns.TypeANY, Class: dns.ClassANY, }} rcode, _ := applyUpdateNoAuth(t, p, newUpdate("auth.example.com.", del)) if rcode != dns.RcodeRefused { t.Errorf("rcode = %d, want REFUSED for apex wipe", rcode) } } func TestUpdate_DefaultTTL_Applied(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) p.TTL = 120 // configure non-default // Build an UPDATE add with TTL=0 → plugin should fill in p.TTL. rr := mustRR(t, `foo.auth.example.com. 0 IN TXT "x"`) rcode, _ := applyUpdateNoAuth(t, p, newUpdate("auth.example.com.", rr)) if rcode != dns.RcodeSuccess { t.Fatalf("rcode = %d", rcode) } got := p.store.Lookup("foo.auth.example.com.", dns.TypeTXT) if got[0].Header().Ttl != 120 { t.Errorf("TTL = %d, want default 120", got[0].Header().Ttl) } } func TestUpdate_GenerationBumps(t *testing.T) { p := newTestPlugin("auth.example.com.", "ns.example.com.", nil) start := p.store.generation() upd := newUpdate("auth.example.com.", mustRR(t, `foo.auth.example.com. 60 IN TXT "x"`), ) applyUpdateNoAuth(t, p, upd) if p.store.generation() <= start { t.Errorf("generation did not bump: was %d, still %d", start, p.store.generation()) } }