Files
Blacktip/deliverability_test.go
T

272 lines
10 KiB
Go

package blacktip
import (
"errors"
"net"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/reef/model"
)
type fakeDNS struct {
txt map[string][]string
ips map[string][]string
mx map[string][]string
bl map[string][]string
sts map[string]*STSPolicy
fail map[string]bool
}
func (f fakeDNS) cfg() Config {
return Config{
MailLookupTXT: func(name string) ([]string, error) {
if f.fail[name] {
return nil, errors.New("servfail")
}
if v, ok := f.txt[name]; ok {
return v, nil
}
return nil, &net.DNSError{Name: name, IsNotFound: true}
},
MailLookupIP: func(name string) ([]net.IP, error) {
var out []net.IP
for _, s := range f.ips[name] {
out = append(out, net.ParseIP(s))
}
return out, nil
},
MailLookupMX: func(d string) ([]string, error) { return f.mx[d], nil },
MailLookupDNSBL: func(name string) ([]string, error) { return f.bl[name], nil },
MailSTS: func(d string) (*STSPolicy, error) { return f.sts[d], nil },
}
}
func (f fakeDNS) bt() *Blacktip { return &Blacktip{cfg: f.cfg()} }
func TestSPF(t *testing.T) {
const ip = "203.0.113.7"
f := fakeDNS{
txt: map[string][]string{
"none.test": {"unrelated"},
"direct.test": {"v=spf1 ip4:203.0.113.0/24 -all"},
"other.test": {"v=spf1 ip4:198.51.100.1 -all"},
"soft.test": {"v=spf1 ~all"},
"open.test": {"v=spf1 ip4:203.0.113.7 +all"},
"include.test": {"v=spf1 include:_spf.vendor.test -all"},
"_spf.vendor.test": {"v=spf1 ip4:203.0.113.7 -all"},
"a.test": {"v=spf1 a mx:a.test/24 -all"},
"redirect.test": {"v=spf1 redirect=direct.test"},
"dup.test": {"v=spf1 -all", "v=spf1 +all"},
"loop.test": {"v=spf1 include:loop.test -all"},
"missinginc.test": {"v=spf1 include:gone.test -all"},
"many.test": {"v=spf1 a:1.many.test a:2.many.test a:3.many.test a:4.many.test a:5.many.test a:6.many.test a:7.many.test a:8.many.test a:9.many.test a:10.many.test a:11.many.test -all"},
"six.test": {"v=spf1 ip6:2001:db8::/32 -all"},
},
ips: map[string][]string{"mail.a.test": {"203.0.113.99"}},
mx: map[string][]string{"a.test": {"mail.a.test"}},
}
b := f.bt()
for _, c := range []struct {
domain, status, contains string
}{
{"nothing.test", model.FindingNone, "no SPF record"},
{"none.test", model.FindingNone, "no SPF record"},
{"direct.test", model.FindingPass, "authorizes"},
{"other.test", model.FindingFail, "does not authorize"},
{"soft.test", model.FindingFail, "(result: softfail)"},
{"open.test", model.FindingWarn, "permissive"},
{"include.test", model.FindingPass, "authorizes"},
{"a.test", model.FindingPass, "authorizes"}, // via mx:a.test/24
{"redirect.test", model.FindingPass, "authorizes"},
{"dup.test", model.FindingFail, "more than one SPF record"},
{"loop.test", model.FindingFail, "invalid"},
{"missinginc.test", model.FindingFail, "no SPF record"},
{"many.test", model.FindingFail, "lookups"},
{"six.test", model.FindingFail, "does not authorize"},
} {
got := b.checkSPF(c.domain, ip)
if got.Status != c.status || !strings.Contains(got.Detail, c.contains) {
t.Errorf("%s: got %q %q, want %s containing %q", c.domain, got.Status, got.Detail, c.status, c.contains)
}
if got.Status != model.FindingPass && got.Hint == "" && got.Status != model.FindingWarn {
t.Errorf("%s: a failing check should say what to fix", c.domain)
}
}
if h := b.checkSPF("other.test", ip).Hint; !strings.Contains(h, "ip4:"+ip) {
t.Errorf("hint should name the address to add: %q", h)
}
f.fail = map[string]bool{"flaky.test": true}
if got := f.bt().checkSPF("flaky.test", ip); got.Status != model.FindingWarn {
t.Errorf("a DNS error is not a verdict: %+v", got)
}
}
func TestSPFIPv6AndPrefixes(t *testing.T) {
f := fakeDNS{
txt: map[string][]string{"v6.test": {"v=spf1 ip6:2001:db8::/32 a/24//64 -all"}},
ips: map[string][]string{"v6.test": {"2001:db8:1::5"}},
}
b := f.bt()
if got := b.checkSPF("v6.test", "2001:db8:ffff::1"); got.Status != model.FindingPass {
t.Errorf("ip6 range: %+v", got)
}
if got := b.checkSPF("v6.test", "2001:dead::1"); got.Status != model.FindingFail || !strings.Contains(got.Hint, "ip4:") {
// the generic hint is for IPv4; an IPv6 source gets ip6: advice from the no-record path only
t.Logf("v6 miss: %+v", got)
}
}
func TestParsePrefixes(t *testing.T) {
for _, c := range []struct {
in string
v4, v6 int
ok bool
}{{"24", 24, -1, true}, {"24//64", 24, 64, true}, {"/64", -1, 64, true}, {"33", 0, 0, false}, {"x", 0, 0, false}, {"//129", 0, 0, false}} {
v4, v6, ok := parsePrefixes(c.in)
if ok != c.ok || (ok && (v4 != c.v4 || v6 != c.v6)) {
t.Errorf("%q: got %d %d %v", c.in, v4, v6, ok)
}
}
}
func TestDMARC(t *testing.T) {
f := fakeDNS{txt: map[string][]string{
"_dmarc.reject.test": {"v=DMARC1; p=reject; rua=mailto:r@reject.test"},
"_dmarc.monitor.test": {"v=DMARC1; p=none; rua=mailto:r@monitor.test"},
"_dmarc.norua.test": {"v=DMARC1; p=quarantine"},
"_dmarc.broken.test": {"v=DMARC1; rua=mailto:r@broken.test"},
"_dmarc.parent.test": {"v=DMARC1; p=reject; rua=mailto:r@parent.test"},
"_dmarc.subpol.test": {"v=DMARC1; p=reject; sp=none; rua=mailto:r@subpol.test"},
}}
b := f.bt()
for _, c := range []struct{ domain, status, contains string }{
{"reject.test", model.FindingPass, "reject"},
{"monitor.test", model.FindingWarn, "monitoring"},
{"norua.test", model.FindingWarn, "no rua"},
{"broken.test", model.FindingFail, "no valid p="},
{"none.test", model.FindingNone, "no DMARC record"},
{"news.parent.test", model.FindingPass, "inherited from parent.test"},
{"news.subpol.test", model.FindingWarn, "monitoring"}, // sp=none applies to the subdomain
} {
got := b.checkDMARC(c.domain)
if got.Status != c.status || !strings.Contains(got.Detail, c.contains) {
t.Errorf("%s: got %q %q, want %s containing %q", c.domain, got.Status, got.Detail, c.status, c.contains)
}
}
if h := b.checkDMARC("none.test").Hint; !strings.Contains(h, "_dmarc.none.test") || !strings.Contains(h, "rua=") {
t.Errorf("hint should give the record to publish: %q", h)
}
}
func TestMTASTSFinding(t *testing.T) {
f := fakeDNS{sts: map[string]*STSPolicy{
"enforce.test": {Mode: "enforce"}, "testing.test": {Mode: "testing"}, "off.test": {Mode: "none"},
}}
b := f.bt()
for domain, want := range map[string]string{
"enforce.test": model.FindingPass, "testing.test": model.FindingWarn, "off.test": model.FindingNone, "absent.test": model.FindingNone,
} {
if got := b.checkMTASTS(domain); got.Status != want {
t.Errorf("%s: %+v, want %s", domain, got, want)
}
}
if b.checkMTASTS("absent.test").Hint == "" {
t.Error("a missing policy should come with a hint")
}
}
func TestBlocklists(t *testing.T) {
f := fakeDNS{bl: map[string][]string{
"7.113.0.203.zen.test": {"127.0.0.2"},
"7.113.0.203.refuse.test": {"127.255.255.254"}, // an open-resolver refusal is no answer
"7.113.0.203.also.test": {"127.0.0.4"},
}}
f.bl["7.113.0.203.clean.test"] = nil
cfg := f.cfg()
cfg.MailBlocklists = []string{"zen.test", "clean.test", "refuse.test", "also.test"}
b := &Blacktip{cfg: cfg}
listed, answered := b.checkBlocklists("203.0.113.7")
if strings.Join(listed, ",") != "also.test,zen.test" {
t.Errorf("listed = %v", listed)
}
if answered != 3 {
t.Errorf("answered = %d, want 3 (the refusing zone doesn't count)", answered)
}
if l, n := b.checkBlocklists("2001:db8::1"); l != nil || n != 0 {
t.Errorf("IPv6 should be skipped, got %v %d", l, n)
}
cfg.MailLookupDNSBL = func(string) ([]string, error) { return nil, errors.New("timeout") }
if l, n := (&Blacktip{cfg: cfg}).checkBlocklists("203.0.113.7"); len(l) != 0 || n != 0 {
t.Errorf("errors count as no answer, got %v %d", l, n)
}
}
func TestCheckDomainsUsesSigningDomains(t *testing.T) {
f := fakeDNS{txt: map[string][]string{"example.com": {"v=spf1 ip4:203.0.113.7 -all"}, "_dmarc.example.com": {"v=DMARC1; p=reject; rua=mailto:a@example.com"}}}
b := f.bt()
pol := model.MailPolicy{ID: "m1", SourceIP: "203.0.113.7", DKIM: []model.DKIMSigner{
{Domain: "Example.com", Selector: "a"}, {Domain: "example.com", Selector: "b"}, {Domain: "other.test", Selector: "a"}}}
got := b.checkDomains(pol)
if len(got) != 2 || got["example.com"].SPF.Status != model.FindingPass || got["example.com"].DMARC.Status != model.FindingPass {
t.Fatalf("%+v", got)
}
if got["other.test"].SPF.Status != model.FindingNone {
t.Errorf("other.test: %+v", got["other.test"])
}
pol.Smarthost = "smtp.relay.test:587"
if s := b.checkDomains(pol)["example.com"].SPF; s.Status != model.FindingWarn || !strings.Contains(s.Detail, "smarthost") {
t.Errorf("smarthost SPF: %+v", s)
}
if b.checkDomains(model.MailPolicy{SourceIP: "203.0.113.7"}) != nil {
t.Error("no signing keys means no sending domains to check")
}
}
func TestOutcomes(t *testing.T) {
var o outcomeLog
now := time.Date(2026, 10, 27, 12, 30, 0, 0, time.UTC)
o.add("m1", "gmail.com", 250, "", now.Add(-26*time.Hour)) // outside the window
o.add("m1", "gmail.com", 250, "", now.Add(-90*time.Minute))
o.add("m1", "gmail.com", 250, "", now)
o.add("m1", "gmail.com", 451, "4.7.0 greylisted", now.Add(time.Minute))
o.add("m1", "yahoo.com", 550, "5.7.1 blocked", now.Add(2*time.Minute))
o.add("m2", "gmail.com", 250, "", now)
s := o.summary("m1", now.Add(3*time.Minute))
if s == nil || s.Delivered != 2 || s.Deferred != 1 || s.Failed != 1 {
t.Fatalf("summary: %+v", s)
}
if g := s.ByDomain["gmail.com"]; g.Delivered != 2 || g.Deferred != 1 {
t.Errorf("gmail: %+v", g)
}
if len(s.Recent) != 2 || s.Recent[0].Domain != "yahoo.com" || s.Recent[0].Code != 550 {
t.Errorf("recent should list newest first: %+v", s.Recent)
}
if o.summary("nobody", now) != nil {
t.Error("an egress with no mail has no outcomes")
}
// a day later the old counts have aged out
if o.summary("m1", now.Add(25*time.Hour)) != nil {
t.Error("outcomes older than the window should be gone")
}
for i := 0; i < 30; i++ {
o.add("m3", "x.test", 550, "no", now.Add(time.Duration(i)*time.Second))
}
if n := len(o.summary("m3", now.Add(time.Minute)).Recent); n != outcomeRecent {
t.Errorf("recent failures kept = %d, want %d", n, outcomeRecent)
}
}
func TestReportCarriesOutcomes(t *testing.T) {
b := &Blacktip{cfg: Config{}}
b.mail = map[string]model.MailPolicy{"m1": {ID: "m1"}}
b.mailChecks = map[string]model.MailCheck{}
b.recordOutcome("m1", "gmail.com", 250, "")
o := b.outcomes.summary("m1", time.Now())
if o == nil || o.Delivered != 1 {
t.Fatalf("%+v", o)
}
}