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