mlisting

Mailing list service

git clone git://git.lin.moe/go/mlisting.git

  1package service
  2
  3import (
  4	"bytes"
  5	"context"
  6	"fmt"
  7	"log/slog"
  8	"os"
  9	"slices"
 10	"strings"
 11	"testing"
 12	"time"
 13
 14	"git.lin.moe/go/mlisting/storage"
 15	"git.lin.moe/go/mlisting/storage/sqlite"
 16	"git.lin.moe/go/mlisting/tools/testdata"
 17	"github.com/emersion/go-message/mail"
 18	"github.com/emersion/go-message/textproto"
 19	"github.com/emersion/go-smtp"
 20)
 21
 22var (
 23	st                                     *sqlite.Storage
 24	private_list, strict_list, public_list storage.List
 25	memberPerms                            = map[string]uint8{
 26		"admin@base.lan":  storage.PERM_BROWSE | storage.PERM_REPLY | storage.PERM_POST,
 27		"member@base.lan": storage.PERM_BROWSE | storage.PERM_REPLY,
 28		"reader@base.lan": storage.PERM_BROWSE,
 29	}
 30	members = []string{"admin@base.lan", "member@base.lan", "reader@base.lan"}
 31)
 32
 33const (
 34	PRIVATE_LIST = "private@base.lan" // default: storage.PERM_BROWSE
 35	STRICT_LIST  = "strict@base.lan"  // default: storage.PERM_BROWSE | storage.PERM_REPLY
 36	PUBLIC_LIST  = "public@base.lan"  // default: storage.PERM_BROWSE | storage.PERM_REPLY | storage.PERM_POST,
 37)
 38
 39func TestMain(m *testing.M) {
 40	var err error
 41	st, err = sqlite.NewStorage(":memory:")
 42	if err != nil {
 43		panic(err)
 44	}
 45	ctx, cancel := context.WithTimeout(context.TODO(), time.Second*30)
 46	defer cancel()
 47	defer st.Shutdown(ctx)
 48
 49	if private_list, err = st.NewList(ctx,
 50		"private", PRIVATE_LIST, "",
 51		storage.PERM_BROWSE); err != nil {
 52		panic(err)
 53	}
 54	if strict_list, err = st.NewList(ctx,
 55		"strict", STRICT_LIST, "",
 56		storage.PERM_BROWSE|storage.PERM_REPLY); err != nil {
 57		panic(err)
 58	}
 59	if public_list, err = st.NewList(ctx,
 60		"public", PUBLIC_LIST, "",
 61		storage.PERM_BROWSE|storage.PERM_REPLY|storage.PERM_POST); err != nil {
 62		panic(err)
 63	}
 64
 65	for mem := range memberPerms {
 66		private_list.NewMember(ctx, mem)
 67		private_list.UpdateMember(ctx, mem, memberPerms[mem])
 68
 69		strict_list.NewMember(ctx, mem)
 70		strict_list.UpdateMember(ctx, mem, memberPerms[mem])
 71
 72		public_list.NewMember(ctx, mem)
 73		public_list.UpdateMember(ctx, mem, memberPerms[mem])
 74	}
 75
 76	m.Run()
 77}
 78
 79func testPostMessage(ctx context.Context, mta MTA, from string, rcpt storage.List) error {
 80	ctx, cancel := context.WithTimeout(ctx, time.Second*10)
 81	defer cancel()
 82
 83	var msg = testdata.TestMessages[0]
 84	var buf = bytes.NewBuffer(nil)
 85	header := mail.HeaderFromMap(msg.Header)
 86	header.Set("From", from)
 87	header.Set("To", rcpt.Address())
 88	if err := header.GenerateMessageID(); err != nil {
 89		panic(err)
 90	}
 91
 92	textproto.WriteHeader(buf, header.Header.Header)
 93	buf.Write(msg.Body)
 94
 95	sess := lmtpSession{
 96		ctx:     ctx,
 97		cancel:  cancel,
 98		storage: st,
 99		logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
100			Level: slog.LevelDebug,
101		})),
102		mta:     mta,
103		maxSize: 0,
104		List:    nil,
105		Cmd:     "",
106	}
107	if err := sess.Mail(from, &smtp.MailOptions{}); err != nil {
108		return err
109	}
110	if err := sess.Rcpt(rcpt.Address(), &smtp.RcptOptions{}); err != nil {
111		return err
112	}
113	if err := sess.LMTPData(buf, nil); err != nil {
114		return err
115	}
116
117	return nil
118}
119
120func testReplyMessage(ctx context.Context, mta MTA, from string, rcpt storage.List) error {
121	ctx, cancel := context.WithTimeout(ctx, time.Second*10)
122	defer cancel()
123
124	// message replied to
125	var msg = testdata.TestMessages[0]
126	header := mail.HeaderFromMap(msg.Header)
127	if err := header.GenerateMessageID(); err != nil {
128		panic(err)
129	}
130	msgId, _ := header.MessageID()
131
132	if _, err := rcpt.AddMessage(ctx, header.Map(), msg.Body); err != nil {
133		panic(err)
134	}
135
136	msg = testdata.TestMessages[1]
137	var buf = bytes.NewBuffer(nil)
138	header = mail.HeaderFromMap(msg.Header)
139	header.Set("From", from)
140	header.Set("To", rcpt.Address())
141	if err := header.GenerateMessageID(); err != nil {
142		panic(err)
143	}
144	header.SetMsgIDList("In-Reply-To", []string{msgId})
145	textproto.WriteHeader(buf, header.Header.Header)
146	buf.Write(msg.Body)
147	sess := lmtpSession{
148		ctx:     ctx,
149		cancel:  cancel,
150		storage: st,
151		logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
152			Level: slog.LevelDebug,
153		})),
154		mta:     mta,
155		maxSize: 0,
156		List:    nil,
157		Cmd:     "",
158	}
159	if err := sess.Mail(from, &smtp.MailOptions{}); err != nil {
160		return err
161	}
162	if err := sess.Rcpt(rcpt.Address(), &smtp.RcptOptions{}); err != nil {
163		return err
164	}
165	if err := sess.LMTPData(buf, nil); err != nil {
166		return err
167	}
168
169	return nil
170}
171
172func TestForwardMessage(t *testing.T) {
173	ctx := context.TODO()
174
175	for _, list := range []storage.List{private_list, strict_list, public_list} {
176		mta := MockMTA(func(header textproto.Header, body []byte, sender string, rcpts []string) error {
177			if list.DefaultPerm()&storage.PERM_POST != 0 {
178				exp_rcpts := make([]string, len(members))
179				copy(exp_rcpts, members)
180				slices.Sort(exp_rcpts)
181				slices.Sort(rcpts)
182
183				if !slices.Equal(exp_rcpts, rcpts) {
184					t.Fatalf("post a new thread failed:  rcpts: %v, expect_rcpts: %v",
185						rcpts, exp_rcpts)
186				}
187			} else {
188				// message of dening post
189				if !slices.Equal([]string{"any@base.lan"}, rcpts) {
190					t.Fatalf("expect a reject message, rcpts:%v, expect_rcpts: %v",
191						rcpts, []string{"any@base.lan"})
192				}
193			}
194			return nil
195		})
196		if err := testPostMessage(ctx, &mta, "any@base.lan", list); err != nil {
197			t.Fatal(err)
198		}
199
200		mta = MockMTA(func(header textproto.Header, body []byte, sender string, rcpts []string) error {
201			if list.DefaultPerm()&storage.PERM_REPLY != 0 {
202				exp_rcpts := make([]string, len(members))
203				copy(exp_rcpts, members)
204				slices.Sort(exp_rcpts)
205				slices.Sort(rcpts)
206
207				if !slices.Equal(exp_rcpts, rcpts) {
208					t.Fatalf("reply a thread failed: rcpts: %v, expect_rcpts: %v",
209						rcpts, exp_rcpts)
210				}
211			} else {
212				if !slices.Equal([]string{"any@base.lan"}, rcpts) {
213					t.Fatalf("expect a reject message, rcpts:%v, expect_rcpts: %v",
214						rcpts, []string{"any@base.lan"})
215				}
216			}
217			return nil
218		})
219		if err := testReplyMessage(ctx, &mta, "any@base.lan", list); err != nil {
220			t.Fatal(err)
221		}
222
223		for mem := range memberPerms {
224			mta := MockMTA(func(header textproto.Header, body []byte, sender string, rcpts []string) error {
225				if memberPerms[mem]&storage.PERM_POST != 0 {
226					exp_rcpts := make([]string, len(members))
227					copy(exp_rcpts, members)
228					exp_rcpts = slices.DeleteFunc(exp_rcpts, func(i string) bool { return i == mem })
229					slices.Sort(exp_rcpts)
230					slices.Sort(rcpts)
231
232					if !slices.Equal(exp_rcpts, rcpts) {
233						t.Fatalf("post a new thread failed: rcpts: %v, expect_rcpts: %v",
234							rcpts, exp_rcpts)
235					}
236				} else {
237					if !slices.Equal([]string{mem}, rcpts) {
238						t.Fatalf("expect a reject message, rcpts:%v, expect_rcpts: %v",
239							rcpts, []string{mem})
240					}
241				}
242				return nil
243			})
244			if err := testPostMessage(ctx, &mta, mem, list); err != nil {
245				t.Fatal(err)
246			}
247
248			mta = MockMTA(func(header textproto.Header, body []byte, sender string, rcpts []string) error {
249				if memberPerms[mem]&storage.PERM_REPLY != 0 {
250					exp_rcpts := make([]string, len(members))
251					copy(exp_rcpts, members)
252					exp_rcpts = slices.DeleteFunc(exp_rcpts, func(i string) bool { return i == mem })
253					slices.Sort(exp_rcpts)
254					slices.Sort(rcpts)
255
256					if !slices.Equal(exp_rcpts, rcpts) {
257						t.Fatalf("reply a thread failed: rcpts: %v, expect_rcpts: %v",
258							rcpts, exp_rcpts)
259					}
260				} else {
261					if !slices.Equal([]string{mem}, rcpts) {
262						t.Fatalf("expect a reject message, rcpts:%v, expect_rcpts: %v",
263							rcpts, []string{"any@base.lan"})
264					}
265				}
266				return nil
267			})
268			if err := testReplyMessage(ctx, &mta, mem, list); err != nil {
269				t.Fatal(err)
270			}
271		}
272	}
273}
274
275func TestSubscribe(t *testing.T) {
276	ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10)
277	defer cancel()
278
279	const MEM = "test-sub@base.lan"
280	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
281		Level: slog.LevelDebug,
282	}))
283
284	var msg = testdata.TestMessages[0]
285	var buf = bytes.NewBuffer(nil)
286	header := mail.HeaderFromMap(msg.Header)
287	header.Set("From", MEM)
288	rcpt := (&Address{Address: public_list.Address()}).WithAction(CMD_SUBSCRIBE).String()
289	header.Set("To", rcpt)
290	if err := header.GenerateMessageID(); err != nil {
291		panic(err)
292	}
293
294	textproto.WriteHeader(buf, header.Header.Header)
295	buf.Write(msg.Body)
296
297	var token string
298	mta := MockMTA(func(header textproto.Header, body []byte, sender string, rcpts []string) error {
299		logger.Debug("send out message", "from", header.Get("From"), "rcpts", rcpts)
300		token = strings.TrimPrefix(header.Get("Subject"), "Confirm ")
301		return nil
302	})
303	sess := lmtpSession{
304		ctx:     ctx,
305		cancel:  cancel,
306		storage: st,
307		logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
308			Level: slog.LevelDebug,
309		})),
310		mta:     &mta,
311		maxSize: 0,
312		List:    nil,
313		Cmd:     "",
314	}
315	if err := sess.Mail(MEM, &smtp.MailOptions{}); err != nil {
316		t.Fatal(err)
317	}
318	if err := sess.Rcpt(rcpt, &smtp.RcptOptions{}); err != nil {
319		t.Fatal(err)
320	}
321	if err := sess.LMTPData(buf, nil); err != nil {
322		t.Fatal(err)
323	}
324
325	msg = testdata.TestMessages[1]
326	buf = bytes.NewBuffer(nil)
327	header = mail.HeaderFromMap(msg.Header)
328	header.Set("From", MEM)
329	rcpt = (&Address{Address: public_list.Address()}).WithAction(CMD_CONFIRM).String()
330	header.Set("To", rcpt)
331	header.Set("Subject", fmt.Sprintf("Re: Confirm %s", token))
332	if err := header.GenerateMessageID(); err != nil {
333		panic(err)
334	}
335
336	textproto.WriteHeader(buf, header.Header.Header)
337	buf.Write(msg.Body)
338	mta = MockMTA(func(header textproto.Header, body []byte, sender string, rcpts []string) error {
339		logger.Debug("send out message", "from", header.Get("From"), "rcpts", rcpts)
340		return nil
341	})
342	sess = lmtpSession{
343		ctx:     ctx,
344		cancel:  cancel,
345		storage: st,
346		logger:  logger,
347		mta:     &mta,
348		maxSize: 0,
349		List:    nil,
350		Cmd:     "",
351	}
352	if err := sess.Mail(MEM, &smtp.MailOptions{}); err != nil {
353		t.Fatal(err)
354	}
355	if err := sess.Rcpt(rcpt, &smtp.RcptOptions{}); err != nil {
356		t.Fatal(err)
357	}
358	if err := sess.LMTPData(buf, nil); err != nil {
359		t.Fatal(err)
360	}
361
362	_, err := public_list.GetMember(ctx, MEM)
363	if err != nil {
364		t.Fatal(err)
365	}
366}