Files
2026-08-16 21:18:45 -05:00

267 lines
9.0 KiB
Go

package authoritative
import (
"testing"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/models"
)
func ttlPtr(v uint32) *uint32 { return &v }
// testZone builds a small but representative zone: an apex, a delegation, a
// wildcard, a CNAME chain and an empty non-terminal.
func testIndex(t *testing.T) *Index {
t.Helper()
zone := models.Zone{
ID: 1, Name: "example.com.", Kind: models.ZoneForward, Enabled: true,
DefaultTTL: 3600, PrimaryNS: "ns1.example.com.", AdminEmail: "hostmaster@example.com",
Serial: 7, Refresh: 7200, Retry: 3600, Expire: 1209600, Minimum: 300,
}
recs := []models.Record{
{ZoneID: 1, Name: "@", Type: "NS", Data: "ns1.example.com.", Enabled: true},
{ZoneID: 1, Name: "@", Type: "A", Data: "192.0.2.1", Enabled: true},
{ZoneID: 1, Name: "ns1", Type: "A", Data: "192.0.2.53", Enabled: true},
{ZoneID: 1, Name: "www", Type: "CNAME", Data: "example.com.", Enabled: true},
{ZoneID: 1, Name: "mail", Type: "A", Data: "192.0.2.20", Enabled: true},
{ZoneID: 1, Name: "mail", Type: "AAAA", Data: "2001:db8::20", Enabled: true},
{ZoneID: 1, Name: "@", Type: "MX", Data: "10 mail.example.com.", Enabled: true},
{ZoneID: 1, Name: "*.wild", Type: "A", Data: "192.0.2.99", Enabled: true},
{ZoneID: 1, Name: "deep.ent.chain", Type: "TXT", Data: `"hello"`, Enabled: true},
{ZoneID: 1, Name: "sub", Type: "NS", Data: "ns1.sub.example.com.", Enabled: true},
{ZoneID: 1, Name: "ns1.sub", Type: "A", Data: "192.0.2.60", Enabled: true},
{ZoneID: 1, Name: "short", Type: "A", Data: "192.0.2.7", TTL: ttlPtr(60), Enabled: true},
}
idx, problems := Build([]models.Zone{zone}, map[int64][]models.Record{1: recs})
for _, p := range problems {
t.Fatalf("unexpected build problem: %v", p)
}
return idx
}
func query(t *testing.T, idx *Index, name string, qtype uint16) *dns.Msg {
t.Helper()
req := new(dns.Msg)
req.SetQuestion(dns.Fqdn(name), qtype)
return idx.Answer(req, false)
}
func TestAnswerBasicLookups(t *testing.T) {
idx := testIndex(t)
tests := []struct {
name string
qname string
qtype uint16
rcode int
wantAns int
wantFirst string
aa bool
}{
{"apex A", "example.com.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.1", true},
{"host A", "mail.example.com.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.20", true},
{"host AAAA", "mail.example.com.", dns.TypeAAAA, dns.RcodeSuccess, 1, "2001:db8::20", true},
{"case insensitive", "MAIL.Example.COM.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.20", true},
{"nodata", "mail.example.com.", dns.TypeTXT, dns.RcodeSuccess, 0, "", true},
{"nxdomain", "nope.example.com.", dns.TypeA, dns.RcodeNameError, 0, "", true},
{"wildcard", "anything.wild.example.com.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.99", true},
{"wildcard nodata", "anything.wild.example.com.", dns.TypeTXT, dns.RcodeSuccess, 0, "", true},
{"explicit MX", "example.com.", dns.TypeMX, dns.RcodeSuccess, 1, "mail.example.com.", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m := query(t, idx, tc.qname, tc.qtype)
if m == nil {
t.Fatal("expected an authoritative answer, got none")
}
if m.Rcode != tc.rcode {
t.Errorf("rcode = %s, want %s", dns.RcodeToString[m.Rcode], dns.RcodeToString[tc.rcode])
}
if len(m.Answer) != tc.wantAns {
t.Fatalf("answer count = %d, want %d (%v)", len(m.Answer), tc.wantAns, m.Answer)
}
if m.Authoritative != tc.aa {
t.Errorf("AA = %v, want %v", m.Authoritative, tc.aa)
}
if tc.wantAns == 0 {
if len(m.Ns) == 0 {
t.Error("negative answer should carry a SOA in the authority section")
} else if _, ok := m.Ns[0].(*dns.SOA); !ok {
t.Errorf("authority section = %T, want *dns.SOA", m.Ns[0])
}
return
}
switch rr := m.Answer[0].(type) {
case *dns.A:
if rr.A.String() != tc.wantFirst {
t.Errorf("A = %s, want %s", rr.A, tc.wantFirst)
}
case *dns.AAAA:
if rr.AAAA.String() != tc.wantFirst {
t.Errorf("AAAA = %s, want %s", rr.AAAA, tc.wantFirst)
}
case *dns.MX:
if rr.Mx != tc.wantFirst {
t.Errorf("MX = %s, want %s", rr.Mx, tc.wantFirst)
}
}
})
}
}
func TestWildcardOwnerNameIsRewritten(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "host.wild.example.com.", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("answer count = %d, want 1", len(m.Answer))
}
if got := m.Answer[0].Header().Name; got != "host.wild.example.com." {
t.Errorf("owner name = %q, want the queried name, not the wildcard", got)
}
}
func TestEmptyNonTerminalIsNoDataNotNXDOMAIN(t *testing.T) {
idx := testIndex(t)
// "chain.example.com." and "ent.chain.example.com." hold no records but
// exist because a name below them does.
for _, name := range []string{"chain.example.com.", "ent.chain.example.com."} {
m := query(t, idx, name, dns.TypeA)
if m.Rcode != dns.RcodeSuccess {
t.Errorf("%s: rcode = %s, want NOERROR (empty non-terminal)",
name, dns.RcodeToString[m.Rcode])
}
}
m := query(t, idx, "missing.chain.example.com.", dns.TypeA)
if m.Rcode != dns.RcodeNameError {
t.Errorf("truly missing name: rcode = %s, want NXDOMAIN", dns.RcodeToString[m.Rcode])
}
}
func TestCNAMEIsFollowedInZone(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "www.example.com.", dns.TypeA)
if len(m.Answer) != 2 {
t.Fatalf("answer count = %d, want CNAME plus target A: %v", len(m.Answer), m.Answer)
}
if _, ok := m.Answer[0].(*dns.CNAME); !ok {
t.Errorf("first answer = %T, want *dns.CNAME", m.Answer[0])
}
if a, ok := m.Answer[1].(*dns.A); !ok || a.A.String() != "192.0.2.1" {
t.Errorf("second answer = %v, want the apex A record", m.Answer[1])
}
// Asking for the CNAME itself must not follow the chain.
m = query(t, idx, "www.example.com.", dns.TypeCNAME)
if len(m.Answer) != 1 {
t.Fatalf("CNAME query answer count = %d, want 1", len(m.Answer))
}
}
func TestDelegationReturnsReferral(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "host.sub.example.com.", dns.TypeA)
if m.Authoritative {
t.Error("a referral must not set the AA bit")
}
if len(m.Answer) != 0 {
t.Errorf("referral answer section = %v, want empty", m.Answer)
}
if len(m.Ns) == 0 {
t.Fatal("referral must carry NS records in the authority section")
}
if _, ok := m.Ns[0].(*dns.NS); !ok {
t.Errorf("authority = %T, want *dns.NS", m.Ns[0])
}
var glued bool
for _, rr := range m.Extra {
if a, ok := rr.(*dns.A); ok && a.A.String() == "192.0.2.60" {
glued = true
}
}
if !glued {
t.Error("referral should include in-zone glue for the child name server")
}
}
func TestAdditionalSectionCarriesMXAddresses(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "example.com.", dns.TypeMX)
var haveA, haveAAAA bool
for _, rr := range m.Extra {
switch v := rr.(type) {
case *dns.A:
haveA = haveA || v.A.String() == "192.0.2.20"
case *dns.AAAA:
haveAAAA = haveAAAA || v.AAAA.String() == "2001:db8::20"
}
}
if !haveA || !haveAAAA {
t.Errorf("MX answer should glue the exchange addresses; extra = %v", m.Extra)
}
}
func TestSOAIsSynthesisedAndSerialUsed(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "example.com.", dns.TypeSOA)
if len(m.Answer) != 1 {
t.Fatalf("SOA answer count = %d, want 1", len(m.Answer))
}
soa, ok := m.Answer[0].(*dns.SOA)
if !ok {
t.Fatalf("answer = %T, want *dns.SOA", m.Answer[0])
}
if soa.Serial != 7 {
t.Errorf("serial = %d, want the zone serial 7", soa.Serial)
}
if soa.Mbox != `hostmaster.example.com.` {
t.Errorf("mbox = %q, want the email address in RNAME form", soa.Mbox)
}
}
func TestPerRecordTTLOverridesZoneDefault(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "short.example.com.", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("answer count = %d, want 1", len(m.Answer))
}
if got := m.Answer[0].Header().Ttl; got != 60 {
t.Errorf("TTL = %d, want the per-record value 60", got)
}
m = query(t, idx, "mail.example.com.", dns.TypeA)
if got := m.Answer[0].Header().Ttl; got != 3600 {
t.Errorf("TTL = %d, want the zone default 3600", got)
}
}
func TestOutOfZoneQueryIsNotAnswered(t *testing.T) {
idx := testIndex(t)
if m := query(t, idx, "example.org.", dns.TypeA); m != nil {
t.Errorf("expected no authoritative answer for an unconfigured zone, got %v", m)
}
}
func TestDisabledZoneIsNotServed(t *testing.T) {
zone := models.Zone{ID: 1, Name: "off.example.", Enabled: false, DefaultTTL: 300}
idx, _ := Build([]models.Zone{zone}, nil)
if idx.Lookup("off.example.") != nil {
t.Error("a disabled zone must not be indexed")
}
}
func TestBuildReportsInvalidRecordsWithoutFailingTheZone(t *testing.T) {
zone := models.Zone{ID: 1, Name: "example.com.", Enabled: true, DefaultTTL: 300}
recs := []models.Record{
{ZoneID: 1, Name: "good", Type: "A", Data: "192.0.2.1", Enabled: true},
{ZoneID: 1, Name: "bad", Type: "A", Data: "not-an-address", Enabled: true},
}
idx, problems := Build([]models.Zone{zone}, map[int64][]models.Record{1: recs})
if len(problems) != 1 {
t.Fatalf("problems = %d, want 1", len(problems))
}
if m := query(t, idx, "good.example.com.", dns.TypeA); len(m.Answer) != 1 {
t.Error("a single bad record must not take the rest of the zone offline")
}
}