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") } }