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