1package dmarcdb
2
3import (
4 "context"
5 "encoding/json"
6 "encoding/xml"
7 "fmt"
8 "io"
9 "log/slog"
10 "os"
11 "path/filepath"
12 "reflect"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/mjl-/mox/dmarcrpt"
18 "github.com/mjl-/mox/dns"
19 "github.com/mjl-/mox/mlog"
20 "github.com/mjl-/mox/mox-"
21 "github.com/mjl-/mox/moxio"
22 "github.com/mjl-/mox/queue"
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 EvalDB = nil
45
46 _, err := evalDB(ctxbg)
47 tcheckf(t, err, "database")
48 defer func() {
49 EvalDB.Close()
50 EvalDB = nil
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 mlog.SetConfig(map[string]slog.Level{"": slog.LevelDebug})
161
162 os.RemoveAll("../testdata/dmarcdb/data")
163 mox.Context = ctxbg
164 mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
165 mox.MustLoadConfig(true, false)
166 EvalDB = nil
167
168 db, err := evalDB(ctxbg)
169 tcheckf(t, err, "database")
170 defer func() {
171 EvalDB.Close()
172 EvalDB = nil
173 }()
174
175 resolver := dns.MockResolver{
176 TXT: map[string][]string{
177 "_dmarc.sender.example.": {
178 "v=DMARC1; rua=mailto:dmarcrpt@sender.example; ri=3600",
179 },
180 },
181 }
182
183 end := nextWholeHour(time.Now())
184
185 eval := Evaluation{
186 PolicyDomain: "sender.example",
187 Evaluated: end.Add(-time.Hour / 2),
188 IntervalHours: 1,
189 PolicyPublished: dmarcrpt.PolicyPublished{
190 Domain: "sender.example",
191 ADKIM: dmarcrpt.AlignmentRelaxed,
192 ASPF: dmarcrpt.AlignmentRelaxed,
193 Policy: dmarcrpt.DispositionReject,
194 SubdomainPolicy: dmarcrpt.DispositionReject,
195 Percentage: 100,
196 },
197 SourceIP: "10.1.2.3",
198 Disposition: dmarcrpt.DispositionNone,
199 AlignedDKIMPass: true,
200 AlignedSPFPass: true,
201 EnvelopeTo: "mox.example",
202 EnvelopeFrom: "sender.example",
203 HeaderFrom: "sender.example",
204 DKIMResults: []dmarcrpt.DKIMAuthResult{
205 {
206 Domain: "sender.example",
207 Selector: "test",
208 Result: dmarcrpt.DKIMPass,
209 },
210 },
211 SPFResults: []dmarcrpt.SPFAuthResult{
212 {
213 Domain: "sender.example",
214 Scope: dmarcrpt.SPFDomainScopeMailFrom,
215 Result: dmarcrpt.SPFPass,
216 },
217 },
218 }
219
220 expFeedback := &dmarcrpt.Feedback{
221 XMLName: xml.Name{Local: "feedback"},
222 Version: "1.0",
223 ReportMetadata: dmarcrpt.ReportMetadata{
224 OrgName: "mail.mox.example",
225 Email: "postmaster@mail.mox.example",
226 DateRange: dmarcrpt.DateRange{
227 Begin: end.Add(-1 * time.Hour).Unix(),
228 End: end.Add(-time.Second).Unix(),
229 },
230 },
231 PolicyPublished: dmarcrpt.PolicyPublished{
232 Domain: "sender.example",
233 ADKIM: dmarcrpt.AlignmentRelaxed,
234 ASPF: dmarcrpt.AlignmentRelaxed,
235 Policy: dmarcrpt.DispositionReject,
236 SubdomainPolicy: dmarcrpt.DispositionReject,
237 Percentage: 100,
238 },
239 Records: []dmarcrpt.ReportRecord{
240 {
241 Row: dmarcrpt.Row{
242 SourceIP: "10.1.2.3",
243 Count: 1,
244 PolicyEvaluated: dmarcrpt.PolicyEvaluated{
245 Disposition: dmarcrpt.DispositionNone,
246 DKIM: dmarcrpt.DMARCPass,
247 SPF: dmarcrpt.DMARCPass,
248 },
249 },
250 Identifiers: dmarcrpt.Identifiers{
251 EnvelopeTo: "mox.example",
252 EnvelopeFrom: "sender.example",
253 HeaderFrom: "sender.example",
254 },
255 AuthResults: dmarcrpt.AuthResults{
256 DKIM: []dmarcrpt.DKIMAuthResult{
257 {
258 Domain: "sender.example",
259 Selector: "test",
260 Result: dmarcrpt.DKIMPass,
261 },
262 },
263 SPF: []dmarcrpt.SPFAuthResult{
264 {
265 Domain: "sender.example",
266 Scope: dmarcrpt.SPFDomainScopeMailFrom,
267 Result: dmarcrpt.SPFPass,
268 },
269 },
270 },
271 },
272 },
273 }
274
275 // Set a timeUntil that we steplock and that causes the actual sleep to return immediately when we want to.
276 wait := make(chan struct{})
277 step := make(chan time.Duration)
278 jitteredTimeUntil = func(_ time.Time) time.Duration {
279 wait <- struct{}{}
280 return <-step
281 }
282
283 sleepBetween = func(ctx context.Context, between time.Duration) (ok bool) { return true }
284
285 test := func(evals []Evaluation, expAggrAddrs map[string]struct{}, expErrorAddrs map[string]struct{}, optExpReport *dmarcrpt.Feedback) {
286 t.Helper()
287
288 mox.Shutdown, mox.ShutdownCancel = context.WithCancel(ctxbg)
289
290 for _, e := range evals {
291 err := db.Insert(ctxbg, &e)
292 tcheckf(t, err, "inserting evaluation")
293 }
294
295 aggrAddrs := map[string]struct{}{}
296 errorAddrs := map[string]struct{}{}
297
298 queueAdd = func(ctx context.Context, log mlog.Log, senderAccount string, msgFile *os.File, qml ...queue.Msg) error {
299 if len(qml) != 1 {
300 return fmt.Errorf("queued %d messages, expected 1", len(qml))
301 }
302 qm := qml[0]
303
304 // Read message file. Also write copy to disk for inspection.
305 buf, err := io.ReadAll(&moxio.AtReader{R: msgFile})
306 tcheckf(t, err, "read report message")
307 err = os.WriteFile("../testdata/dmarcdb/data/report.eml", append(append([]byte{}, qm.MsgPrefix...), buf...), 0600)
308 tcheckf(t, err, "write report message")
309
310 var feedback *dmarcrpt.Feedback
311 addr := qm.Recipient().String()
312 isErrorReport := strings.Contains(string(buf), "DMARC aggregate reporting error report")
313 if isErrorReport {
314 errorAddrs[addr] = struct{}{}
315 } else {
316 aggrAddrs[addr] = struct{}{}
317
318 feedback, err = dmarcrpt.ParseMessageReport(log.Logger, msgFile)
319 tcheckf(t, err, "parsing generated report message")
320 }
321
322 if optExpReport != nil {
323 // Parse report in message and compare with expected.
324 optExpReport.ReportMetadata.ReportID = feedback.ReportMetadata.ReportID
325 tcompare(t, feedback, expFeedback)
326 }
327
328 return nil
329 }
330
331 Start(resolver)
332 // Run first loop.
333 <-wait
334 step <- 0
335 <-wait
336 tcompare(t, aggrAddrs, expAggrAddrs)
337 tcompare(t, errorAddrs, expErrorAddrs)
338
339 // Second loop. Evaluations cleaned, should not result in report messages.
340 aggrAddrs = map[string]struct{}{}
341 errorAddrs = map[string]struct{}{}
342 step <- 0
343 <-wait
344 tcompare(t, aggrAddrs, map[string]struct{}{})
345 tcompare(t, errorAddrs, map[string]struct{}{})
346
347 // Caus Start to stop.
348 mox.ShutdownCancel()
349 step <- time.Minute
350 }
351
352 // Typical case, with a single address that receives an aggregate report.
353 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
354
355 // Only optional evaluations, no report at all.
356 evalOpt := eval
357 evalOpt.Optional = true
358 test([]Evaluation{evalOpt}, map[string]struct{}{}, map[string]struct{}{}, nil)
359
360 // Address is suppressed.
361 sa := SuppressAddress{ReportingAddress: "dmarcrpt@sender.example", Until: time.Now().Add(time.Minute)}
362 err = db.Insert(ctxbg, &sa)
363 tcheckf(t, err, "insert suppress address")
364 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
365
366 // Suppression has expired.
367 sa.Until = time.Now().Add(-time.Minute)
368 err = db.Update(ctxbg, &sa)
369 tcheckf(t, err, "update suppress address")
370 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
371
372 // Two RUA's, one with a size limit that doesn't pass, and one that does pass.
373 resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:dmarcrpt1@sender.example!1,mailto:dmarcrpt2@sender.example!10t; ri=3600"}
374 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt2@sender.example": {}}, map[string]struct{}{}, nil)
375
376 // Redirect to external domain, without permission, no report sent.
377 resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:unauthorized@other.example"}
378 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
379
380 // Redirect to external domain, with basic permission.
381 resolver.TXT = map[string][]string{
382 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
383 "sender.example._report._dmarc.other.example.": {"v=DMARC1"},
384 }
385 test([]Evaluation{eval}, map[string]struct{}{"authorized@other.example": {}}, map[string]struct{}{}, nil)
386
387 // Redirect to authorized external domain, with 2 allowed replacements and 1 invalid and 1 refusing due to size.
388 resolver.TXT = map[string][]string{
389 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
390 "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"},
391 }
392 test([]Evaluation{eval}, map[string]struct{}{"good1@other.example": {}, "good2@other.example": {}}, map[string]struct{}{}, nil)
393
394 // Without RUA, we send no message.
395 resolver.TXT = map[string][]string{
396 "_dmarc.sender.example.": {"v=DMARC1;"},
397 }
398 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
399
400 // If message size limit is reached, an error repor is sent.
401 resolver.TXT = map[string][]string{
402 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:dmarcrpt@sender.example!1"},
403 }
404 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{"dmarcrpt@sender.example": {}}, nil)
405}
406