1package dmarcdb
2
3import (
4 "context"
5 "encoding/json"
6 "encoding/xml"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "reflect"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/mjl-/mox/dmarcrpt"
17 "github.com/mjl-/mox/dns"
18 "github.com/mjl-/mox/mlog"
19 "github.com/mjl-/mox/mox-"
20 "github.com/mjl-/mox/moxio"
21 "github.com/mjl-/mox/queue"
22)
23
24func tcheckf(t *testing.T, err error, format string, args ...any) {
25 t.Helper()
26 if err != nil {
27 t.Fatalf("%s: %s", fmt.Sprintf(format, args...), err)
28 }
29}
30
31func tcompare(t *testing.T, got, expect any) {
32 t.Helper()
33 if !reflect.DeepEqual(got, expect) {
34 t.Fatalf("got:\n%v\nexpected:\n%v", got, expect)
35 }
36}
37
38func TestEvaluations(t *testing.T) {
39 os.RemoveAll("../testdata/dmarcdb/data")
40 mox.Context = ctxbg
41 mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
42 mox.MustLoadConfig(true, false)
43
44 os.Remove(mox.DataDirPath("dmarceval.db"))
45 err := Init()
46 tcheckf(t, err, "init")
47 defer func() {
48 err := Close()
49 tcheckf(t, err, "close")
50 }()
51
52 parseJSON := func(s string) (e Evaluation) {
53 t.Helper()
54 err := json.Unmarshal([]byte(s), &e)
55 tcheckf(t, err, "unmarshal")
56 return
57 }
58 packJSON := func(e Evaluation) string {
59 t.Helper()
60 buf, err := json.Marshal(e)
61 tcheckf(t, err, "marshal")
62 return string(buf)
63 }
64
65 e0 := Evaluation{
66 PolicyDomain: "sender1.example",
67 Evaluated: time.Now().Round(0),
68 IntervalHours: 1,
69 PolicyPublished: dmarcrpt.PolicyPublished{
70 Domain: "sender1.example",
71 ADKIM: dmarcrpt.AlignmentRelaxed,
72 ASPF: dmarcrpt.AlignmentRelaxed,
73 Policy: dmarcrpt.DispositionReject,
74 SubdomainPolicy: dmarcrpt.DispositionReject,
75 Percentage: 100,
76 },
77 SourceIP: "10.1.2.3",
78 Disposition: dmarcrpt.DispositionNone,
79 AlignedDKIMPass: true,
80 AlignedSPFPass: true,
81 EnvelopeTo: "mox.example",
82 EnvelopeFrom: "sender1.example",
83 HeaderFrom: "sender1.example",
84 DKIMResults: []dmarcrpt.DKIMAuthResult{
85 {
86 Domain: "sender1.example",
87 Selector: "test",
88 Result: dmarcrpt.DKIMPass,
89 },
90 },
91 SPFResults: []dmarcrpt.SPFAuthResult{
92 {
93 Domain: "sender1.example",
94 Scope: dmarcrpt.SPFDomainScopeMailFrom,
95 Result: dmarcrpt.SPFPass,
96 },
97 },
98 }
99 e1 := e0
100 e2 := parseJSON(strings.ReplaceAll(packJSON(e0), "sender1.example", "sender2.example"))
101 e3 := parseJSON(strings.ReplaceAll(packJSON(e0), "10.1.2.3", "10.3.2.1"))
102 e3.Optional = true
103
104 for i, e := range []*Evaluation{&e0, &e1, &e2, &e3} {
105 e.Evaluated = e.Evaluated.Add(time.Duration(i) * time.Second)
106 err = AddEvaluation(ctxbg, 3600, e)
107 tcheckf(t, err, "add evaluation")
108 }
109
110 expStats := map[string]EvaluationStat{
111 "sender1.example": {
112 Domain: dns.Domain{ASCII: "sender1.example"},
113 Dispositions: []string{"none"},
114 Count: 3,
115 SendReport: true,
116 },
117 "sender2.example": {
118 Domain: dns.Domain{ASCII: "sender2.example"},
119 Dispositions: []string{"none"},
120 Count: 1,
121 SendReport: true,
122 },
123 }
124 stats, err := EvaluationStats(ctxbg)
125 tcheckf(t, err, "evaluation stats")
126 tcompare(t, stats, expStats)
127
128 // EvaluationsDomain
129 evals, err := EvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender1.example"})
130 tcheckf(t, err, "get evaluations for domain")
131 tcompare(t, evals, []Evaluation{e0, e1, e3})
132
133 evals, err = EvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender2.example"})
134 tcheckf(t, err, "get evaluations for domain")
135 tcompare(t, evals, []Evaluation{e2})
136
137 evals, err = EvaluationsDomain(ctxbg, dns.Domain{ASCII: "bogus.example"})
138 tcheckf(t, err, "get evaluations for domain")
139 tcompare(t, evals, []Evaluation{})
140
141 // RemoveEvaluationsDomain
142 err = RemoveEvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender1.example"})
143 tcheckf(t, err, "remove evaluations")
144
145 expStats = map[string]EvaluationStat{
146 "sender2.example": {
147 Domain: dns.Domain{ASCII: "sender2.example"},
148 Dispositions: []string{"none"},
149 Count: 1,
150 SendReport: true,
151 },
152 }
153 stats, err = EvaluationStats(ctxbg)
154 tcheckf(t, err, "evaluation stats")
155 tcompare(t, stats, expStats)
156}
157
158func TestSendReports(t *testing.T) {
159 os.RemoveAll("../testdata/dmarcdb/data")
160 mox.Context = ctxbg
161 mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
162 mox.MustLoadConfig(true, false)
163
164 os.Remove(mox.DataDirPath("dmarceval.db"))
165 err := Init()
166 tcheckf(t, err, "init")
167 defer func() {
168 err := Close()
169 tcheckf(t, err, "close")
170 }()
171
172 resolver := dns.MockResolver{
173 TXT: map[string][]string{
174 "_dmarc.sender.example.": {
175 "v=DMARC1; rua=mailto:dmarcrpt@sender.example; ri=3600",
176 },
177 },
178 }
179
180 end := nextWholeHour(time.Now())
181
182 eval := Evaluation{
183 PolicyDomain: "sender.example",
184 Evaluated: end.Add(-time.Hour / 2),
185 IntervalHours: 1,
186 PolicyPublished: dmarcrpt.PolicyPublished{
187 Domain: "sender.example",
188 ADKIM: dmarcrpt.AlignmentRelaxed,
189 ASPF: dmarcrpt.AlignmentRelaxed,
190 Policy: dmarcrpt.DispositionReject,
191 SubdomainPolicy: dmarcrpt.DispositionReject,
192 Percentage: 100,
193 },
194 SourceIP: "10.1.2.3",
195 Disposition: dmarcrpt.DispositionNone,
196 AlignedDKIMPass: true,
197 AlignedSPFPass: true,
198 EnvelopeTo: "mox.example",
199 EnvelopeFrom: "sender.example",
200 HeaderFrom: "sender.example",
201 DKIMResults: []dmarcrpt.DKIMAuthResult{
202 {
203 Domain: "sender.example",
204 Selector: "test",
205 Result: dmarcrpt.DKIMPass,
206 },
207 },
208 SPFResults: []dmarcrpt.SPFAuthResult{
209 {
210 Domain: "sender.example",
211 Scope: dmarcrpt.SPFDomainScopeMailFrom,
212 Result: dmarcrpt.SPFPass,
213 },
214 },
215 }
216
217 expFeedback := &dmarcrpt.Feedback{
218 XMLName: xml.Name{Local: "feedback"},
219 Version: "1.0",
220 ReportMetadata: dmarcrpt.ReportMetadata{
221 OrgName: "mail.mox.example",
222 Email: "postmaster@mail.mox.example",
223 DateRange: dmarcrpt.DateRange{
224 Begin: end.Add(-1 * time.Hour).Unix(),
225 End: end.Add(-time.Second).Unix(),
226 },
227 },
228 PolicyPublished: dmarcrpt.PolicyPublished{
229 Domain: "sender.example",
230 ADKIM: dmarcrpt.AlignmentRelaxed,
231 ASPF: dmarcrpt.AlignmentRelaxed,
232 Policy: dmarcrpt.DispositionReject,
233 SubdomainPolicy: dmarcrpt.DispositionReject,
234 Percentage: 100,
235 },
236 Records: []dmarcrpt.ReportRecord{
237 {
238 Row: dmarcrpt.Row{
239 SourceIP: "10.1.2.3",
240 Count: 1,
241 PolicyEvaluated: dmarcrpt.PolicyEvaluated{
242 Disposition: dmarcrpt.DispositionNone,
243 DKIM: dmarcrpt.DMARCPass,
244 SPF: dmarcrpt.DMARCPass,
245 },
246 },
247 Identifiers: dmarcrpt.Identifiers{
248 EnvelopeTo: "mox.example",
249 EnvelopeFrom: "sender.example",
250 HeaderFrom: "sender.example",
251 },
252 AuthResults: dmarcrpt.AuthResults{
253 DKIM: []dmarcrpt.DKIMAuthResult{
254 {
255 Domain: "sender.example",
256 Selector: "test",
257 Result: dmarcrpt.DKIMPass,
258 },
259 },
260 SPF: []dmarcrpt.SPFAuthResult{
261 {
262 Domain: "sender.example",
263 Scope: dmarcrpt.SPFDomainScopeMailFrom,
264 Result: dmarcrpt.SPFPass,
265 },
266 },
267 },
268 },
269 },
270 }
271
272 // Set a timeUntil that we steplock and that causes the actual sleep to return immediately when we want to.
273 wait := make(chan struct{})
274 step := make(chan time.Duration)
275 jitteredTimeUntil = func(_ time.Time) time.Duration {
276 wait <- struct{}{}
277 return <-step
278 }
279
280 sleepBetween = func(ctx context.Context, between time.Duration) (ok bool) { return true }
281
282 test := func(evals []Evaluation, expAggrAddrs map[string]struct{}, expErrorAddrs map[string]struct{}, optExpReport *dmarcrpt.Feedback) {
283 t.Helper()
284
285 mox.Shutdown, mox.ShutdownCancel = context.WithCancel(ctxbg)
286
287 for _, e := range evals {
288 err := EvalDB.Insert(ctxbg, &e)
289 tcheckf(t, err, "inserting evaluation")
290 }
291
292 aggrAddrs := map[string]struct{}{}
293 errorAddrs := map[string]struct{}{}
294
295 queueAdd = func(ctx context.Context, log mlog.Log, senderAccount string, msgFile *os.File, qml ...queue.Msg) error {
296 if len(qml) != 1 {
297 return fmt.Errorf("queued %d messages, expected 1", len(qml))
298 }
299 qm := qml[0]
300
301 // Read message file. Also write copy to disk for inspection.
302 buf, err := io.ReadAll(&moxio.AtReader{R: msgFile})
303 tcheckf(t, err, "read report message")
304 err = os.WriteFile("../testdata/dmarcdb/data/report.eml", append(append([]byte{}, qm.MsgPrefix...), buf...), 0600)
305 tcheckf(t, err, "write report message")
306
307 var feedback *dmarcrpt.Feedback
308 addr := qm.Recipient().String()
309 isErrorReport := strings.Contains(string(buf), "DMARC aggregate reporting error report")
310 if isErrorReport {
311 errorAddrs[addr] = struct{}{}
312 } else {
313 aggrAddrs[addr] = struct{}{}
314
315 feedback, err = dmarcrpt.ParseMessageReport(log.Logger, msgFile)
316 tcheckf(t, err, "parsing generated report message")
317 }
318
319 if optExpReport != nil {
320 // Parse report in message and compare with expected.
321 optExpReport.ReportMetadata.ReportID = feedback.ReportMetadata.ReportID
322 tcompare(t, feedback, expFeedback)
323 }
324
325 return nil
326 }
327
328 Start(resolver)
329 // Run first loop.
330 <-wait
331 step <- 0
332 <-wait
333 tcompare(t, aggrAddrs, expAggrAddrs)
334 tcompare(t, errorAddrs, expErrorAddrs)
335
336 // Second loop. Evaluations cleaned, should not result in report messages.
337 aggrAddrs = map[string]struct{}{}
338 errorAddrs = map[string]struct{}{}
339 step <- 0
340 <-wait
341 tcompare(t, aggrAddrs, map[string]struct{}{})
342 tcompare(t, errorAddrs, map[string]struct{}{})
343
344 // Caus Start to stop.
345 mox.ShutdownCancel()
346 step <- time.Minute
347 }
348
349 // Typical case, with a single address that receives an aggregate report.
350 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
351
352 // Only optional evaluations, no report at all.
353 evalOpt := eval
354 evalOpt.Optional = true
355 test([]Evaluation{evalOpt}, map[string]struct{}{}, map[string]struct{}{}, nil)
356
357 // Address is suppressed.
358 sa := SuppressAddress{ReportingAddress: "dmarcrpt@sender.example", Until: time.Now().Add(time.Minute)}
359 err = EvalDB.Insert(ctxbg, &sa)
360 tcheckf(t, err, "insert suppress address")
361 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
362
363 // Suppression has expired.
364 sa.Until = time.Now().Add(-time.Minute)
365 err = EvalDB.Update(ctxbg, &sa)
366 tcheckf(t, err, "update suppress address")
367 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
368
369 // Two RUA's, one with a size limit that doesn't pass, and one that does pass.
370 resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:dmarcrpt1@sender.example!1,mailto:dmarcrpt2@sender.example!10t; ri=3600"}
371 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt2@sender.example": {}}, map[string]struct{}{}, nil)
372
373 // Redirect to external domain, without permission, no report sent.
374 resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:unauthorized@other.example"}
375 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
376
377 // Redirect to external domain, with basic permission.
378 resolver.TXT = map[string][]string{
379 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
380 "sender.example._report._dmarc.other.example.": {"v=DMARC1"},
381 }
382 test([]Evaluation{eval}, map[string]struct{}{"authorized@other.example": {}}, map[string]struct{}{}, nil)
383
384 // Redirect to authorized external domain, with 2 allowed replacements and 1 invalid and 1 refusing due to size.
385 resolver.TXT = map[string][]string{
386 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
387 "sender.example._report._dmarc.other.example.": {"v=DMARC1; rua=mailto:good1@other.example,mailto:bad1@yetanother.example,mailto:good2@other.example,mailto:badsize@other.example!1"},
388 }
389 test([]Evaluation{eval}, map[string]struct{}{"good1@other.example": {}, "good2@other.example": {}}, map[string]struct{}{}, nil)
390
391 // Without RUA, we send no message.
392 resolver.TXT = map[string][]string{
393 "_dmarc.sender.example.": {"v=DMARC1;"},
394 }
395 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
396
397 // If message size limit is reached, an error repor is sent.
398 resolver.TXT = map[string][]string{
399 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:dmarcrpt@sender.example!1"},
400 }
401 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{"dmarcrpt@sender.example": {}}, nil)
402}
403