internal/mailin/mailin_test.go

main
gitbay/internal/mailin/mailin_test.go history · blame · raw

533 lines · 17520 bytes

28 symbols in this file
  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}