Files
jarl/internal/validate/validate_test.go
T
adamandClaude Opus 5.5 12013b12d5 Stats heatmap: empty hours same height; cname_chain only for long chains
The "Busiest times" grid's hours without data used the class "empty",
which picked up the padding of empty messages. One CNAME to another no
longer warns; three or more get an info note showing the chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:58:40 +01:00

397 lines
13 KiB
Go

package validate
import (
"strings"
"testing"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/rr"
)
type builder struct {
s *model.State
id int64
}
func newBuilder() *builder { return &builder{s: model.NewState()} }
func (b *builder) next() int64 { b.id++; return b.id }
func (b *builder) view(name string, clients ...string) *model.View {
v := &model.View{ID: b.next(), Name: name, Position: len(b.s.Views), MatchClients: clients}
b.s.Views[v.ID] = v
return v
}
func (b *builder) zone(name string, view *model.View, typ string) *model.Zone {
z := &model.Zone{ID: b.next(), Name: dnsname.Fqdn(name), Type: typ, DefaultTTL: 3600,
SOA: model.SOA{MName: "ns1." + dnsname.Fqdn(name), RName: "hostmaster." + dnsname.Fqdn(name), Serial: 1, Refresh: 3600, Retry: 900, Expire: 1209600, Minimum: 300}}
if view != nil {
z.ViewID = view.ID
}
b.s.Zones[z.ID] = z
return z
}
// primary creates a valid primary zone with NS and glue.
func (b *builder) primary(name string, view *model.View) *model.Zone {
z := b.zone(name, view, model.ZonePrimary)
b.rec(z, "@", "NS", "ns1")
b.rec(z, "ns1", "A", "192.0.2.1")
return z
}
func (b *builder) rec(z *model.Zone, name, typ, data string) *model.Record {
owner, err := dnsname.Owner(name, z.Name)
if err != nil {
panic(err)
}
p, err := rr.Parse(z.Name, owner, typ, data)
if err != nil {
panic(name + " " + typ + " " + data + ": " + err.Error())
}
r := &model.Record{ID: b.next(), ZoneID: z.ID, Name: owner, Type: p.Type, Data: p.Data}
b.s.Records[r.ID] = r
return r
}
func (b *builder) opt(scope string, scopeID int64, name, value string) *model.Option {
o := &model.Option{ID: b.next(), Scope: scope, ScopeID: scopeID, Name: name, Value: value}
b.s.Options[o.ID] = o
return o
}
func codes(ps []Problem, sev Severity) []string {
var out []string
for _, p := range ps {
if p.Severity == sev {
out = append(out, p.Code)
}
}
return out
}
func hasCode(ps []Problem, code string) bool {
for _, p := range ps {
if p.Code == code {
return true
}
}
return false
}
func TestValidZoneIsClean(t *testing.T) {
b := newBuilder()
z := b.primary("example.com", nil)
b.rec(z, "www", "A", "192.0.2.10")
b.rec(z, "www", "AAAA", "2001:db8::10")
b.rec(z, "@", "MX", "10 mail")
b.rec(z, "mail", "A", "192.0.2.25")
b.rec(z, "@", "TXT", "v=spf1 mx -all")
b.rec(z, "_dmarc", "TXT", "v=DMARC1; p=none")
b.rec(z, "ftp", "CNAME", "www")
b.rec(z, "_sip._tcp", "SRV", "0 5 5060 www")
b.rec(z, "*", "A", "192.0.2.99")
b.rec(z, "sub", "NS", "ns.sub")
b.rec(z, "ns.sub", "A", "192.0.2.53")
b.rec(z, "sub", "DS", "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
rz := b.primary("2.0.192.in-addr.arpa", nil)
rz.SOA.MName = "ns1.example.com."
b.rec(rz, "10", "PTR", "www.example.com.")
b.opt(model.ScopeOptions, 0, "recursion", "yes")
b.opt(model.ScopeOptions, 0, "allow-recursion", "{ 192.168.0.0/16; localhost; }")
ps := State(b.s, Options{})
for _, p := range ps {
if p.Severity != Info {
t.Errorf("unexpected problem: %s (%s)", p, p.Code)
}
}
}
func TestRecordConflicts(t *testing.T) {
cases := []struct {
name string
build func(b *builder, z *model.Zone)
code string
sev Severity
}{
{"cname and A", func(b *builder, z *model.Zone) {
b.rec(z, "www", "CNAME", "ns1")
b.rec(z, "www", "A", "192.0.2.2")
}, "cname_conflict", Error},
{"two cnames", func(b *builder, z *model.Zone) {
b.rec(z, "www", "CNAME", "ns1")
b.rec(z, "www", "CNAME", "other.example.org.")
}, "cname_multiple", Error},
{"cname at apex", func(b *builder, z *model.Zone) { b.rec(z, "@", "CNAME", "other.example.org.") }, "cname_apex", Error},
{"duplicate", func(b *builder, z *model.Zone) {
b.rec(z, "x", "A", "192.0.2.2")
b.rec(z, "x", "A", "192.0.2.2")
}, "record_duplicate", Error},
{"ttl mismatch", func(b *builder, z *model.Zone) {
r := b.rec(z, "x", "A", "192.0.2.2")
r.TTL = model.Ptr(uint32(60))
b.rec(z, "x", "A", "192.0.2.3")
}, "rrset_ttl_mismatch", Error},
{"mx to cname", func(b *builder, z *model.Zone) {
b.rec(z, "mail", "CNAME", "ns1")
b.rec(z, "@", "MX", "10 mail")
}, "mx_cname", Error},
{"srv to cname", func(b *builder, z *model.Zone) {
b.rec(z, "alias", "CNAME", "ns1")
b.rec(z, "_sip._tcp", "SRV", "0 0 5060 alias")
}, "srv_cname", Error},
{"missing glue", func(b *builder, z *model.Zone) { b.rec(z, "sub", "NS", "ns.sub") }, "glue_missing", Error},
{"apex ns without address", func(b *builder, z *model.Zone) { b.rec(z, "@", "NS", "ns2") }, "ns_no_address", Error},
{"ds not at delegation", func(b *builder, z *model.Zone) {
b.rec(z, "x", "DS", "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
}, "ds_not_at_delegation", Error},
{"below dname", func(b *builder, z *model.Zone) {
b.rec(z, "d", "DNAME", "other.example.org.")
b.rec(z, "x.d", "A", "192.0.2.2")
}, "below_dname", Error},
{"occluded", func(b *builder, z *model.Zone) {
b.rec(z, "sub", "NS", "ns.other.org.")
b.rec(z, "www.sub", "A", "192.0.2.2")
}, "occluded", Warning},
{"cname loop", func(b *builder, z *model.Zone) {
b.rec(z, "a", "CNAME", "b")
b.rec(z, "b", "CNAME", "a")
}, "cname_loop", Error},
{"cname chain", func(b *builder, z *model.Zone) {
b.rec(z, "a", "CNAME", "b")
b.rec(z, "b", "CNAME", "c")
b.rec(z, "c", "CNAME", "ns1")
}, "cname_chain", Info},
{"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning},
{"null mx not alone", func(b *builder, z *model.Zone) {
b.rec(z, "@", "MX", "0 .")
b.rec(z, "@", "MX", "10 ns1")
}, "null_mx_not_alone", Error},
{"two spf", func(b *builder, z *model.Zone) {
b.rec(z, "@", "TXT", "v=spf1 mx -all")
b.rec(z, "@", "TXT", "v=spf1 a -all")
}, "spf_multiple", Warning},
{"mx target missing", func(b *builder, z *model.Zone) { b.rec(z, "@", "MX", "10 nomail") }, "target_no_address", Warning},
{"ns to cname", func(b *builder, z *model.Zone) {
b.rec(z, "nsalias", "CNAME", "ns1")
b.rec(z, "@", "NS", "nsalias")
}, "ns_cname", Error},
}
for _, c := range cases {
b := newBuilder()
z := b.primary("example.com", nil)
c.build(b, z)
ps := State(b.s, Options{})
found := false
for _, p := range ps {
if p.Code == c.code && p.Severity == c.sev {
found = true
}
}
if !found {
t.Errorf("%s: expected %s %s, got errors %v warnings %v", c.name, c.sev, c.code, codes(ps, Error), codes(ps, Warning))
}
}
}
func TestZoneRules(t *testing.T) {
b := newBuilder()
b.zone("empty.com", nil, model.ZonePrimary) // no NS
b.zone("sec.com", nil, model.ZoneSecondary) // no primaries
ps := State(b.s, Options{})
if !hasCode(ps, "apex_ns_missing") || !hasCode(ps, "zone_missing_option") {
t.Errorf("got %v", codes(ps, Error))
}
// Views: zones outside views are not allowed once views exist.
b = newBuilder()
b.primary("example.com", nil)
b.view("internal", "192.168.0.0/16")
ps = State(b.s, Options{})
if !hasCode(ps, "zone_outside_view") {
t.Errorf("zone outside view not reported: %v", codes(ps, Error))
}
// in-view must reference an earlier view.
b = newBuilder()
ext := b.view("external")
in := b.view("internal", "10/8")
b.primary("example.com", in)
iv := b.zone("example.com", ext, model.ZoneInView)
iv.InView = "internal"
ps = State(b.s, Options{})
if !hasCode(ps, "zone_inview_invalid") {
t.Errorf("in-view order not checked: %v", codes(ps, Error))
}
if !hasCode(ps, "view_unreachable") {
t.Errorf("unreachable view not reported: %v", codes(ps, Warning))
}
in.Position, ext.Position = 0, 1
ps = State(b.s, Options{})
if HasErrors(ps) {
t.Errorf("valid in-view setup has errors: %v", Errors(ps))
}
}
func TestACLsAndOptions(t *testing.T) {
b := newBuilder()
b.s.ACLs[1] = &model.ACL{ID: 1, Name: "lan", Elements: []string{"192.168.1.0/24", "!192.168.1.5", "missing", "key nokey"}}
b.s.ACLs[2] = &model.ACL{ID: 2, Name: "a", Elements: []string{"b"}}
b.s.ACLs[3] = &model.ACL{ID: 3, Name: "b", Elements: []string{"a"}}
b.s.ACLs[4] = &model.ACL{ID: 4, Name: "any", Elements: []string{"10.0.0.0/8"}}
b.s.ACLs[5] = &model.ACL{ID: 5, Name: "bad", Elements: []string{"10.0.0.1/8"}}
b.id = 10
b.opt(model.ScopeOptions, 0, "recursion", "maybe")
b.opt(model.ScopeOptions, 0, "frobnicate", "yes")
b.opt(model.ScopeOptions, 0, "allow-query", "{ lan; ghost; }")
b.opt(model.ScopeOptions, 0, "allow-transfer", "{ key nope; }")
b.opt(model.ScopeOptions, 0, "directory", `"/var/cache/bind"`)
b.opt(model.ScopeOptions, 0, "directory", `"/tmp"`)
b.opt(model.ScopeOptions, 0, "dnssec-validation", "sometimes")
b.opt(model.ScopeTopLevel, 0, "acl", "x { any; }")
ps := State(b.s, Options{})
for _, want := range []string{"acl_ref_missing", "key_ref_missing", "acl_cycle", "acl_name_invalid", "acl_element_invalid", "option_value_invalid", "option_unknown", "option_duplicate", "option_managed"} {
if !hasCode(ps, want) {
t.Errorf("missing %s; got %v", want, codes(ps, Error))
}
}
if !hasCode(ps, "acl_shadowed") {
t.Errorf("shadowed element not reported: %v", codes(ps, Warning))
}
ps = State(b.s, Options{ExternalKeysUnknown: true})
for _, p := range ps {
if p.Code == "key_ref_missing" {
t.Error("key refs reported despite ExternalKeysUnknown")
}
}
}
func TestSecurityLints(t *testing.T) {
// allow-query any without allow-recursion: open resolver.
b := newBuilder()
b.primary("example.com", nil)
b.opt(model.ScopeOptions, 0, "allow-query", "{ any; }")
ps := State(b.s, Options{})
if !hasCode(ps, "security_open_resolver") {
t.Errorf("open resolver not detected: %v", codes(ps, Warning))
}
// Fixed by recursion no.
b.opt(model.ScopeOptions, 0, "recursion", "no")
if hasCode(State(b.s, Options{}), "security_open_resolver") {
t.Error("open resolver reported with recursion no")
}
// Through an ACL that contains any.
b = newBuilder()
b.s.ACLs[100] = &model.ACL{ID: 100, Name: "everyone", Elements: []string{"any"}}
b.opt(model.ScopeOptions, 0, "allow-recursion", "{ everyone; }")
if !hasCode(State(b.s, Options{}), "security_open_resolver") {
t.Error("open resolver via ACL not detected")
}
// Inside a view that only matches internal clients it's fine.
b = newBuilder()
v := b.view("internal", "192.168.0.0/16")
b.opt(model.ScopeView, v.ID, "allow-recursion", "{ any; }")
if hasCode(State(b.s, Options{}), "security_open_resolver") {
t.Error("open resolver reported for internal-only view")
}
// Dynamic updates are rejected.
b = newBuilder()
z := b.primary("example.com", nil)
b.opt(model.ScopeZone, z.ID, "allow-update", "{ key ddns; }")
ps = State(b.s, Options{ExternalKeys: []string{"ddns"}})
if !hasCode(ps, "dynamic_update_unsupported") {
t.Errorf("allow-update not rejected: %v", codes(ps, Error))
}
b.s.Options[b.id].Value = "{ none; }"
if hasCode(State(b.s, Options{}), "dynamic_update_unsupported") {
t.Error("allow-update { none; } rejected")
}
// Open transfers.
b.opt(model.ScopeZone, z.ID, "allow-transfer", "{ any; }")
if !hasCode(State(b.s, Options{}), "security_open_transfer") {
t.Error("open transfer not detected")
}
}
func TestIntroduced(t *testing.T) {
b := newBuilder()
z := b.primary("example.com", nil)
before := State(b.s, Options{})
b.rec(z, "www", "CNAME", "ns1")
b.rec(z, "www", "TXT", "x")
after := State(b.s, Options{})
intro := Introduced(before, after)
if len(intro) == 0 || intro[0].Code != "cname_conflict" {
t.Errorf("Introduced = %v", intro)
}
if len(Introduced(after, after)) != 0 {
t.Error("same state introduced problems")
}
}
func TestRecordLimit(t *testing.T) {
b := newBuilder()
z := b.primary("example.com", nil)
for i := 0; i < 101; i++ {
b.rec(z, "many", "TXT", strings.Repeat("x", i+1))
}
if !hasCode(State(b.s, Options{}), "rrset_too_large") {
t.Error("RRset over 100 records not reported")
}
b.opt(model.ScopeOptions, 0, "max-records-per-type", "0")
if hasCode(State(b.s, Options{}), "rrset_too_large") {
t.Error("max-records-per-type 0 (unlimited) not honoured")
}
}
func TestNegativeCachingTTL(t *testing.T) {
for _, tc := range []struct {
defaultTTL, minimum uint32
warn string // "" for no warning
}{
{3600, 300, ""},
{3600, 86400, ""}, // the SOA's own TTL (1h) limits negative caching
{86400, 10800, ""}, // 3 hours is fine
{86400, 86400, "1 day"}, // both long: negative answers live a day
{43200, 86400, "12 hours"},
} {
b := newBuilder()
z := b.primary("example.com", nil)
z.DefaultTTL, z.SOA.Minimum = tc.defaultTTL, tc.minimum
var got *Problem
for _, p := range State(b.s, Options{}) {
if p.Code == "soa_warning" && strings.Contains(p.Message, "negative") {
got = &p
}
}
switch {
case tc.warn == "" && got != nil:
t.Errorf("TTL %d, minimum %d: unexpected warning %q", tc.defaultTTL, tc.minimum, got.Message)
case tc.warn != "" && (got == nil || !strings.Contains(got.Message, tc.warn)):
t.Errorf("TTL %d, minimum %d: want a warning mentioning %q, got %v", tc.defaultTTL, tc.minimum, tc.warn, got)
}
}
}
func TestProblemStringDisplaysZone(t *testing.T) {
p := Problem{Severity: Warning, Message: "m", Zone: "10.0.10.in-addr.arpa.", View: "internal"}
if got, want := p.String(), "warning: zone 10.0.10.in-addr.arpa [view internal]: m"; got != want {
t.Errorf("String() = %q, want %q", got, want)
}
}
// One CNAME to another is common and says nothing.
func TestShortCNAMEChain(t *testing.T) {
b := newBuilder()
z := b.primary("example.com", nil)
b.rec(z, "songshare", "CNAME", "navidrome")
b.rec(z, "navidrome", "CNAME", "ns1")
for _, p := range State(b.s, Options{}) {
if p.Code == "cname_chain" {
t.Errorf("one extra CNAME: %s %s", p.Severity, p.Message)
}
}
}