internal/mailin/mailin_test.go
533 lines · 17520 bytes
1package mailin
2
3import (
4 "errors"
5 "fmt"
6 "os"
7 "slices"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/imapc"
14 "gitbay.org/gitbay/internal/mailreply"
15 "gitbay.org/gitbay/internal/seal"
16 "gitbay.org/gitbay/internal/store"
17)
18
19const replyBase = "reply@gitbay.example"
20
21type fixture struct {
22 p *Processor
23 st *store.Store
24 repo store.Repo
25 issueID int64
26 bob int64
27 secrets [][]byte
28 issued time.Time // when the fixture's tokens are minted
29}
30
31// setup is alice's public repository alice/app with issue #1, and bob,
32// who has a verified address and reply by mail on.
33func setup(t *testing.T) *fixture {
34 t.Helper()
35 st, err := store.Open(":memory:")
36 if err != nil {
37 t.Fatal(err)
38 }
39 t.Cleanup(func() { st.Close() })
40 if err := st.MigrateUp(); err != nil {
41 t.Fatal(err)
42 }
43 key, err := seal.NewKey()
44 if err != nil {
45 t.Fatal(err)
46 }
47 keyFile := t.TempDir() + "/secret.key"
48 if err := seal.WriteKeys(keyFile, []seal.Key{key}); err != nil {
49 t.Fatal(err)
50 }
51 ring, err := seal.Load(keyFile)
52 if err != nil {
53 t.Fatal(err)
54 }
55 st.SetKeyring(ring)
56 alice, err := st.CreateUser("alice", false)
57 if err != nil {
58 t.Fatal(err)
59 }
60 bob, err := st.CreateUser("bob", false)
61 if err != nil {
62 t.Fatal(err)
63 }
64 if err := st.AddEmail(bob, "Bob@Example.test", "admin", true); err != nil {
65 t.Fatal(err)
66 }
67 if err := st.AddEmail(bob, "old@example.test", "", false); err != nil {
68 t.Fatal(err)
69 }
70 st.SetReplyEnabled(bob, true)
71 repoID, err := st.CreateRepo("user", alice, "app", "public")
72 if err != nil {
73 t.Fatal(err)
74 }
75 repo, err := st.RepoByID(repoID)
76 if err != nil {
77 t.Fatal(err)
78 }
79 issueID, err := st.CreateIssue(repo.ID, alice, "title", "", "md")
80 if err != nil {
81 t.Fatal(err)
82 }
83 var cfg config.Config
84 cfg.Server.SiteURL = "https://gitbay.example"
85 cfg.Mail.Inbound = config.MailInbound{Enabled: true, ReplyAddress: replyBase}
86 secrets, err := ring.Derive(mailreply.Purpose)
87 if err != nil {
88 t.Fatal(err)
89 }
90 return &fixture{p: &Processor{St: st, Cfg: cfg}, st: st, repo: repo,
91 issueID: issueID, bob: bob, secrets: secrets, issued: time.Now()}
92}
93
94func (f *fixture) token(t *testing.T, user int64) string {
95 t.Helper()
96 tok, err := mailreply.Mint(f.secrets, mailreply.Target{UserID: user, RepoID: f.repo.ID, Kind: "issue", Number: 1},
97 f.issued.Add(mailreply.Lifetime))
98 if err != nil {
99 t.Fatal(err)
100 }
101 return tok
102}
103
104var msgSeq int
105
106func (f *fixture) message(t *testing.T, from, body string) string {
107 t.Helper()
108 msgSeq++
109 return f.messageAs(t, f.bob, from, fmt.Sprintf("<m%d@example.test>", msgSeq), "", body)
110}
111
112// messageAs is a reply from user's token with the given Message-ID and
113// extra header lines (each ending in CRLF).
114func (f *fixture) messageAs(t *testing.T, user int64, from, msgID, headers, body string) string {
115 t.Helper()
116 return fmt.Sprintf("%sFrom: Someone <%s>\r\nTo: gitbay <%s>\r\nSubject: Re: [alice/app] #1: title\r\nMessage-ID: %s\r\n"+
117 "Content-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n", headers, from, mailreply.Address(replyBase, f.token(t, user)), msgID, body)
118}
119
120func (f *fixture) comments(t *testing.T) []store.IssueComment {
121 t.Helper()
122 cs, err := f.st.ListIssueComments(f.issueID)
123 if err != nil {
124 t.Fatal(err)
125 }
126 return cs
127}
128
129// refusalReasons reads back the audit rows the processor wrote.
130func (f *fixture) refusalReasons(t *testing.T) string {
131 t.Helper()
132 entries, err := f.st.AuditEntries(store.AuditFilter{ActionPrefix: "refused mail reply", Limit: 100})
133 if err != nil {
134 t.Fatal(err)
135 }
136 var b strings.Builder
137 for _, e := range entries {
138 b.WriteString(e.Data + "\n")
139 }
140 return b.String()
141}
142
143func TestReplyPostsComment(t *testing.T) {
144 f := setup(t)
145 res := f.p.Handle([]byte(f.message(t, "bob@example.test", "Looks good.\r\n\r\nOn Mon, Sep 28, 2026 at 9:00 AM gitbay <x@y> wrote:\r\n> opened issue #1\r\n")))
146 if !res.Posted {
147 t.Fatalf("not posted: %+v", res)
148 }
149 cs := f.comments(t)
150 if len(cs) != 1 || cs[0].Body != "Looks good." || cs[0].Author != "bob" {
151 t.Fatalf("comments = %+v", cs)
152 }
153 entries, _ := f.st.AuditEntries(store.AuditFilter{ActionPrefix: "cmd issue comment", Limit: 10})
154 if len(entries) != 1 || !strings.Contains(entries[0].Data, `"source":"mail"`) {
155 t.Fatalf("audit = %+v", entries)
156 }
157}
158
159func TestReplyRefusals(t *testing.T) {
160 for _, tc := range []struct {
161 name string
162 prep func(t *testing.T, f *fixture) string // returns the message
163 reason string
164 }{
165 {"wrong From", func(t *testing.T, f *fixture) string {
166 return f.message(t, "mallory@example.test", "hi")
167 }, "From is not a verified address"},
168 {"unverified From", func(t *testing.T, f *fixture) string {
169 return f.message(t, "old@example.test", "hi")
170 }, "From is not a verified address"},
171 {"revoked access", func(t *testing.T, f *fixture) string {
172 f.st.SetRepoVisibility(f.repo.ID, "private")
173 return f.message(t, "bob@example.test", "hi")
174 }, "comment refused: repository alice/app not found"},
175 {"disabled account", func(t *testing.T, f *fixture) string {
176 f.st.SetUserDisabled(f.bob, true)
177 return f.message(t, "bob@example.test", "hi")
178 }, "account disabled"},
179 {"archived repository", func(t *testing.T, f *fixture) string {
180 f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.Archived = true })
181 return f.message(t, "bob@example.test", "hi")
182 }, "archived"},
183 {"expired token", func(t *testing.T, f *fixture) string {
184 f.p.Now = func() time.Time { return time.Now().Add(mailreply.Lifetime + time.Hour) }
185 return f.message(t, "bob@example.test", "hi")
186 }, "reply token expired"},
187 {"reply turned off", func(t *testing.T, f *fixture) string {
188 f.st.SetReplyEnabled(f.bob, false)
189 return f.message(t, "bob@example.test", "hi")
190 }, "reply by mail is off"},
191 {"forged token", func(t *testing.T, f *fixture) string {
192 m := f.message(t, "bob@example.test", "hi")
193 tok := f.token(t, f.bob)
194 c := "a"
195 if tok[0] == 'a' {
196 c = "b"
197 }
198 return strings.Replace(m, tok, c+tok[1:], 1)
199 }, "does not verify"},
200 {"no reply address", func(t *testing.T, f *fixture) string {
201 return strings.Replace(f.message(t, "bob@example.test", "hi"), "reply+", "other+", 1)
202 }, "not addressed to a reply address"},
203 {"empty after stripping", func(t *testing.T, f *fixture) string {
204 return f.message(t, "bob@example.test", "> quoted only\r\n-- \r\nBob")
205 }, "empty reply"},
206 {"automatic reply", func(t *testing.T, f *fixture) string {
207 return "Auto-Submitted: auto-replied\r\n" + f.message(t, "bob@example.test", "I am away")
208 }, "automatic reply"},
209 {"html only", func(t *testing.T, f *fixture) string {
210 return strings.Replace(f.message(t, "bob@example.test", "<p>hi</p>"), "text/plain", "text/html", 1)
211 }, "no text/plain part"},
212 {"too long", func(t *testing.T, f *fixture) string {
213 return f.message(t, "bob@example.test", strings.Repeat("a", 70<<10))
214 }, "reply too long"},
215 } {
216 t.Run(tc.name, func(t *testing.T) {
217 f := setup(t)
218 res := f.p.Handle([]byte(tc.prep(t, f)))
219 if res.Posted || res.Retry || !strings.Contains(res.Reason, tc.reason) {
220 t.Fatalf("result %+v, want refusal %q", res, tc.reason)
221 }
222 if n := len(f.comments(t)); n != 0 {
223 t.Fatalf("%d comments posted", n)
224 }
225 audit := f.refusalReasons(t)
226 if !strings.Contains(audit, tc.reason) {
227 t.Fatalf("audit does not name the reason:\n%s", audit)
228 }
229 if strings.Contains(audit, "I am away") || strings.Contains(audit, "<p>hi") {
230 t.Fatalf("audit carries message content:\n%s", audit)
231 }
232 })
233 }
234}
235
236func TestDuplicateMessageID(t *testing.T) {
237 f := setup(t)
238 m := []byte(f.message(t, "bob@example.test", "once"))
239 if res := f.p.Handle(m); !res.Posted {
240 t.Fatalf("first: %+v", res)
241 }
242 if res := f.p.Handle(m); res.Posted || !strings.Contains(res.Reason, "already posted") {
243 t.Fatalf("second: %+v", res)
244 }
245 if n := len(f.comments(t)); n != 1 {
246 t.Fatalf("%d comments", n)
247 }
248}
249
250// A refused reply leaves no claim, so the same message is judged afresh.
251func TestRefusalLeavesNoClaim(t *testing.T) {
252 f := setup(t)
253 f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.Archived = true })
254 m := []byte(f.message(t, "bob@example.test", "hi"))
255 if res := f.p.Handle(m); res.Posted {
256 t.Fatal("posted to an archived repository")
257 }
258 f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.Archived = false })
259 if res := f.p.Handle(m); !res.Posted {
260 t.Fatalf("after unarchive: %+v", res)
261 }
262}
263
264type fakeMailbox struct {
265 msgs map[uint32][]byte
266 seen map[uint32]bool
267 errs map[uint32]error
268}
269
270func (m *fakeMailbox) Unseen() ([]uint32, error) {
271 var out []uint32
272 for uid := range m.msgs {
273 if !m.seen[uid] {
274 out = append(out, uid)
275 }
276 }
277 slices.Sort(out)
278 return out, nil
279}
280
281func (m *fakeMailbox) Fetch(uid uint32) ([]byte, error) {
282 if err := m.errs[uid]; err != nil {
283 return nil, err
284 }
285 if m.msgs[uid] == nil {
286 return nil, imapc.ErrTooLarge
287 }
288 return m.msgs[uid], nil
289}
290
291func (m *fakeMailbox) MarkSeen(uid uint32) error {
292 m.seen[uid] = true
293 return nil
294}
295
296// Posted and refused messages alike are marked seen.
297func TestDrainMarksSeen(t *testing.T) {
298 f := setup(t)
299 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{
300 1: []byte(f.message(t, "bob@example.test", "posted")),
301 2: []byte(f.message(t, "mallory@example.test", "refused")),
302 3: nil, // too large
303 }}
304 if err := f.p.Drain(mb); err != nil {
305 t.Fatal(err)
306 }
307 for uid := range mb.msgs {
308 if !mb.seen[uid] {
309 t.Errorf("message %d not marked seen", uid)
310 }
311 }
312 if n := len(f.comments(t)); n != 1 {
313 t.Fatalf("%d comments", n)
314 }
315}
316
317// A message that fails for a reason that may pass stays unseen, until it
318// has failed maxTries times.
319func TestDrainRetriesTransientFailure(t *testing.T) {
320 f := setup(t)
321 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{
322 1: []byte(f.message(t, "bob@example.test", "hi")),
323 }}
324 f.st.SetKeyring(nil) // Handle reads no key: a retry
325 for i := 1; i < maxTries; i++ {
326 if err := f.p.Drain(mb); err != nil {
327 t.Fatal(err)
328 }
329 if mb.seen[1] {
330 t.Fatalf("marked seen after %d tries", i)
331 }
332 }
333 f.p.Drain(mb)
334 if !mb.seen[1] {
335 t.Fatal("not given up on")
336 }
337}
338
339// A deleted repository's id is not handed to the next repository
340// (#306), so a reply meant for it finds no repository.
341func TestDeletedRepositoryID(t *testing.T) {
342 f := setup(t)
343 m := []byte(f.message(t, "bob@example.test", "hi"))
344 if err := f.st.DeleteRepo(f.repo.ID); err != nil {
345 t.Fatal(err)
346 }
347 alice, _ := f.st.UserByUsername("alice")
348 id, err := f.st.CreateRepo("user", alice.ID, "other", "public")
349 if err != nil || id == f.repo.ID {
350 t.Fatalf("new repository has id %d (%v), the deleted one's", id, err)
351 }
352 res := f.p.Handle(m)
353 if res.Posted || !strings.Contains(res.Reason, "repository no longer exists") {
354 t.Fatalf("result %+v", res)
355 }
356}
357
358// A repository id freed and taken before ids stopped being reused does
359// not accept replies meant for the old one.
360func TestReusedRepositoryID(t *testing.T) {
361 f := setup(t)
362 m := []byte(f.message(t, "bob@example.test", "hi"))
363 if err := f.st.DeleteRepo(f.repo.ID); err != nil {
364 t.Fatal(err)
365 }
366 alice, _ := f.st.UserByUsername("alice")
367 id := f.repo.ID
368 if _, err := f.st.DB.Exec(
369 "INSERT INTO repos (id, owner_kind, owner_id, name, visibility) VALUES (?, 'user', ?, 'other', 'public')",
370 id, alice.ID); err != nil {
371 t.Fatal(err)
372 }
373 f.st.CreateIssue(id, alice.ID, "t", "", "md")
374 // Created after the token, as it would be outside a fast test.
375 f.st.DB.Exec("UPDATE repos SET created_at = ? WHERE id = ?",
376 time.Now().Add(5*time.Second).UTC().Format("2006-01-02T15:04:05.000Z"), id)
377 res := f.p.Handle(m)
378 if res.Posted || !strings.Contains(res.Reason, "repository created after the reply token") {
379 t.Fatalf("result %+v", res)
380 }
381 if !strings.Contains(f.refusalReasons(t), "repository created after") {
382 t.Fatal("refusal not audited")
383 }
384}
385
386func TestReusedUserID(t *testing.T) {
387 f := setup(t)
388 f.st.DB.Exec("UPDATE users SET created_at = ? WHERE id = ?",
389 time.Now().Add(5*time.Second).UTC().Format("2006-01-02T15:04:05.000Z"), f.bob)
390 res := f.p.Handle([]byte(f.message(t, "bob@example.test", "hi")))
391 if res.Posted || !strings.Contains(res.Reason, "account created after the reply token") {
392 t.Fatalf("result %+v", res)
393 }
394}
395
396// One account's Message-ID does not suppress another account's reply.
397func TestDedupePerAccount(t *testing.T) {
398 f := setup(t)
399 carol, err := f.st.CreateUser("carol", false)
400 if err != nil {
401 t.Fatal(err)
402 }
403 f.st.AddEmail(carol, "carol@example.test", "admin", true)
404 f.st.SetReplyEnabled(carol, true)
405 if res := f.p.Handle([]byte(f.messageAs(t, f.bob, "bob@example.test", "<same@x>", "", "from bob"))); !res.Posted {
406 t.Fatalf("bob: %+v", res)
407 }
408 if res := f.p.Handle([]byte(f.messageAs(t, carol, "carol@example.test", "<same@x>", "", "from carol"))); !res.Posted {
409 t.Fatalf("carol: %+v", res)
410 }
411 if n := len(f.comments(t)); n != 2 {
412 t.Fatalf("%d comments", n)
413 }
414}
415
416func TestEmptyMessage(t *testing.T) {
417 f := setup(t)
418 if res := f.p.Handle([]byte{}); res.Posted || res.Reason != "empty message" {
419 t.Fatalf("result %+v", res)
420 }
421}
422
423// A fetch that keeps failing counts tries for that message alone; the
424// rest of the mailbox is handled, and after maxTries the failing one is
425// marked seen and audited.
426func TestDrainFetchErrors(t *testing.T) {
427 f := setup(t)
428 mb := &fakeMailbox{seen: map[uint32]bool{},
429 msgs: map[uint32][]byte{1: []byte("x"), 2: []byte(f.message(t, "bob@example.test", "hi"))},
430 errs: map[uint32]error{1: &imapc.RefusedError{Text: "NO [UNAVAILABLE] try later"}}}
431 for i := 1; i < maxTries; i++ {
432 if err := f.p.Drain(mb); err != nil {
433 t.Fatal(err)
434 }
435 if mb.seen[1] {
436 t.Fatalf("marked seen after %d tries", i)
437 }
438 if !mb.seen[2] {
439 t.Fatal("the next message was not handled")
440 }
441 }
442 f.p.Drain(mb)
443 if !mb.seen[1] || !strings.Contains(f.refusalReasons(t), "gave up after") {
444 t.Fatal("not given up on and audited")
445 }
446}
447
448// A fetch the server cut off ends the poll; the message is given up on
449// unread once it has cost maxTries polls.
450func TestDrainLimitEndsPoll(t *testing.T) {
451 f := setup(t)
452 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{1: []byte("x")},
453 errs: map[uint32]error{1: imapc.ErrLimit}}
454 for i := 0; i < maxTries; i++ {
455 if err := f.p.Drain(mb); !errors.Is(err, imapc.ErrLimit) {
456 t.Fatalf("poll %d: %v", i, err)
457 }
458 }
459 if err := f.p.Drain(mb); err != nil || !mb.seen[1] {
460 t.Fatalf("not given up on: %v", err)
461 }
462}
463
464func TestAuthenticationResults(t *testing.T) {
465 const id = "mx.example.net"
466 for _, tc := range []struct {
467 name, headers, from, reason string
468 }{
469 {"dmarc pass",
470 "Authentication-Results: mx.example.net; spf=pass smtp.mailfrom=example.test; dmarc=pass (p=REJECT) header.from=example.test\r\n",
471 "bob@example.test", ""},
472 {"aligned dkim pass",
473 "Authentication-Results: mx.example.net;\r\n dkim=pass header.d=mail.example.test header.s=s1 header.b=abc\r\n",
474 "bob@example.test", ""},
475 {"dkim header.i without header.d",
476 "Authentication-Results: mx.example.net; dkim=pass header.i=@example.test\r\n",
477 "bob@example.test", "sender not authenticated"},
478 {"dmarc fail",
479 "Authentication-Results: mx.example.net; dkim=fail header.d=example.test; dmarc=fail header.from=example.test\r\n",
480 "bob@example.test", "sender not authenticated"},
481 {"missing header", "", "bob@example.test", "no Authentication-Results from mx.example.net"},
482 {"spoofed lower header with the same id",
483 "Authentication-Results: mx.example.net; dmarc=fail header.from=example.test\r\nAuthentication-Results: mx.example.net; dmarc=pass header.from=example.test\r\n",
484 "bob@example.test", "sender not authenticated"},
485 {"other authserv only",
486 "Authentication-Results: evil.example; dmarc=pass header.from=example.test\r\n",
487 "bob@example.test", "no Authentication-Results from mx.example.net"},
488 {"misaligned dkim domain",
489 "Authentication-Results: mx.example.net; dkim=pass header.d=attacker.example; dmarc=none header.from=example.test\r\n",
490 "bob@example.test", "sender not authenticated"},
491 {"dmarc pass for another domain",
492 "Authentication-Results: mx.example.net; dmarc=pass header.from=attacker.example\r\n",
493 "bob@example.test", "sender not authenticated"},
494 } {
495 t.Run(tc.name, func(t *testing.T) {
496 f := setup(t)
497 f.p.Cfg.Mail.Inbound.TrustedAuthservID = id
498 res := f.p.Handle([]byte(f.messageAs(t, f.bob, tc.from, "<a@x>", tc.headers, "hi")))
499 if tc.reason == "" {
500 if !res.Posted {
501 t.Fatalf("not posted: %+v", res)
502 }
503 return
504 }
505 if res.Posted || !strings.Contains(res.Reason, tc.reason) {
506 t.Fatalf("result %+v, want %q", res, tc.reason)
507 }
508 if !strings.Contains(f.refusalReasons(t), tc.reason) {
509 t.Fatal("refusal not audited")
510 }
511 })
512 }
513}
514
515// A timeout mid-poll ends the poll and counts no try, neither for the
516// message being fetched nor for the ones after it.
517func TestDrainTimeoutCountsNoTries(t *testing.T) {
518 f := setup(t)
519 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{
520 1: []byte(f.message(t, "bob@example.test", "first")),
521 2: []byte("x"),
522 3: []byte(f.message(t, "bob@example.test", "third")),
523 }, errs: map[uint32]error{2: fmt.Errorf("read: %w", os.ErrDeadlineExceeded)}}
524 if err := f.p.Drain(mb); !errors.Is(err, os.ErrDeadlineExceeded) {
525 t.Fatalf("Drain = %v", err)
526 }
527 if !mb.seen[1] || mb.seen[2] || mb.seen[3] {
528 t.Fatalf("seen = %v", mb.seen)
529 }
530 if f.p.tries[2] != 0 || f.p.tries[3] != 0 {
531 t.Fatalf("tries = %v", f.p.tries)
532 }
533}