From 68fc60fe9f985cf9c597a5357a02d36c94a4d273 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Wed, 16 Sep 2026 13:16:35 -0700 Subject: [PATCH] test(pop3): add black-box transaction state tests (#618) Covers the POP3 TRANSACTION state machine via scripted sessions over net.Pipe, mirroring the SMTP handler test harness: STAT/LIST/UIDL/DELE/ RETR/TOP/NOOP/RSET/QUIT including argument error paths, deleted-message behavior, dot-stuffing, RETR/TOP source error paths, mailbox load errors, session read error handling (idle timeout vs other), and the TCP listener lifecycle (Start/serve/Notify/Drain). No production code changes; current behavior is pinned as-is. Two known RFC deviations are documented in tests (RETR/TOP of messages marked deleted) for follow-up fixes. Package coverage: 24.4% -> 93.2%. Signed-off-by: James Hillyerd --- pkg/server/pop3/transaction_test.go | 766 ++++++++++++++++++++++++++++ 1 file changed, 766 insertions(+) create mode 100644 pkg/server/pop3/transaction_test.go diff --git a/pkg/server/pop3/transaction_test.go b/pkg/server/pop3/transaction_test.go new file mode 100644 index 0000000..83ad5c4 --- /dev/null +++ b/pkg/server/pop3/transaction_test.go @@ -0,0 +1,766 @@ +package pop3 + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/mail" + "net/textproto" + "slices" + "strings" + "testing" + "testing/iotest" + "time" + + "github.com/inbucket/inbucket/v3/pkg/config" + "github.com/inbucket/inbucket/v3/pkg/extension" + "github.com/inbucket/inbucket/v3/pkg/extension/event" + "github.com/inbucket/inbucket/v3/pkg/message" + "github.com/inbucket/inbucket/v3/pkg/storage" + "github.com/inbucket/inbucket/v3/pkg/storage/mem" + "github.com/inbucket/inbucket/v3/pkg/test" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// scriptStep describes one command/response exchange in a scripted POP3 +// session. expect holds the exact response lines; multi-line responses include +// every line up to and including the terminating ".". +type scriptStep struct { + send string + expect []string +} + +// playPOP3Session starts a fresh session against server, consumes the greeting, +// and plays script against it. +func playPOP3Session(t *testing.T, server *Server, script []scriptStep) { + t.Helper() + + serverConn, clientConn := net.Pipe() + startPOP3Session(t, server, &mockConn{serverConn}) + t.Cleanup(func() { _ = clientConn.Close() }) + + c := textproto.NewConn(clientConn) + readPOP3Greeting(t, c) + playPOP3Script(t, c, script) +} + +// startPOP3Session runs startSession on serverConn, arranging cleanup so the +// server is drained before the test completes. +func startPOP3Session(t *testing.T, server *Server, serverConn net.Conn) { + t.Helper() + + server.wg.Add(1) + sessionNum++ + go server.startSession(context.Background(), sessionNum, serverConn) + t.Cleanup(server.Drain) +} + +func readPOP3Greeting(t *testing.T, c *textproto.Conn) { + t.Helper() + + greeting, err := c.ReadLine() + require.NoError(t, err, "reading server greeting") + require.True(t, strings.HasPrefix(greeting, "+OK"), "expected +OK greeting, got %q", greeting) +} + +// playPOP3Script sends each step and compares the exact response lines. +func playPOP3Script(t *testing.T, c *textproto.Conn, script []scriptStep) { + t.Helper() + + for i, step := range script { + require.NoErrorf(t, c.PrintfLine("%s", step.send), "step %d: sending %q", i, step.send) + for j, want := range step.expect { + got, err := c.ReadLine() + require.NoErrorf(t, err, "step %d (%q): reading response line %d", i, step.send, j) + require.Equalf(t, want, got, "step %d (%q): response line %d", i, step.send, j) + } + } +} + +// loginSteps returns script steps authenticating as "mailbox"; count is the +// number of messages the greeting is expected to report. +func loginSteps(count int) []scriptStep { + return []scriptStep{ + {"USER mailbox", []string{"+OK Hello mailbox, welcome to Inbucket"}}, + {"PASS anything", []string{fmt.Sprintf("+OK Found %v messages for mailbox", count)}}, + } +} + +// newMemStore returns an empty memory store; it assigns deterministic +// sequential message IDs ("1", "2", ...). +func newMemStore(t *testing.T) storage.Store { + t.Helper() + + store, err := mem.New(config.Storage{}, extension.NewHost()) + require.NoError(t, err) + return store +} + +// deliverRaw stores a message with the exact raw content provided, returning +// its ID and size. +func deliverRaw(t *testing.T, store storage.Store, mailbox, raw string) (string, int64) { + t.Helper() + + delivery := &message.Delivery{ + Meta: event.MessageMetadata{ + Mailbox: mailbox, + Subject: "raw", + }, + Reader: io.NopCloser(strings.NewReader(raw)), + } + id, err := store.AddMessage(delivery) + require.NoError(t, err) + return id, int64(len(raw)) +} + +// fakeMsg is a storage.Message with canned metadata, used to exercise RETR/TOP +// source error paths. +type fakeMsg struct { + mailbox string + id string + size int64 + source func() (io.ReadCloser, error) +} + +func (m *fakeMsg) Mailbox() string { return m.mailbox } +func (m *fakeMsg) ID() string { return m.id } +func (m *fakeMsg) From() *mail.Address { return nil } +func (m *fakeMsg) To() []*mail.Address { return nil } +func (m *fakeMsg) Date() time.Time { return time.Time{} } +func (m *fakeMsg) Subject() string { return "fake" } +func (m *fakeMsg) Size() int64 { return m.size } +func (m *fakeMsg) Seen() bool { return false } +func (m *fakeMsg) Source() (io.ReadCloser, error) { return m.source() } + +// readDeadlineErrorConn fails SetReadDeadline, to exercise the read error +// paths of the session read loop. +type readDeadlineErrorConn struct { + net.Conn + err error +} + +func (c *readDeadlineErrorConn) SetReadDeadline(time.Time) error { return c.err } + +// fakeNetError is a net.Error with a configurable Timeout() result. +type fakeNetError struct { + timeout bool +} + +func (e fakeNetError) Error() string { return "fake network error" } +func (e fakeNetError) Timeout() bool { return e.timeout } +func (e fakeNetError) Temporary() bool { return false } + +func TestAuthorizationCommandErrors(t *testing.T) { + server := setupPOPServer(t, newMemStore(t), false, false) + + tcs := []struct { + name string + script []scriptStep + }{ + { + name: "USER without argument", + script: []scriptStep{{"USER", []string{"-ERR Missing username argument"}}}, + }, + { + name: "PASS before USER", + script: []scriptStep{{"PASS sekrit", []string{"-ERR Command PASS is out of sequence"}}}, + }, + { + name: "transaction command before login", + script: []scriptStep{{"STAT", []string{"-ERR Command STAT is out of sequence"}}}, + }, + { + name: "APOP with one argument", + script: []scriptStep{{"APOP mailbox", []string{"-ERR APOP requires two arguments"}}}, + }, + { + name: "unknown command", + script: []scriptStep{{"FROBNICATE now", []string{"-ERR Syntax error, FROBNICATE command unrecognized"}}}, + }, + { + name: "empty command", + script: []scriptStep{{"", []string{"-ERR Speak up"}}}, + }, + { + name: "QUIT", + script: []scriptStep{{"QUIT", []string{"+OK Goodnight and good luck"}}}, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + playPOP3Session(t, server, tc.script) + }) + } +} + +func TestAuthorizationApopLogin(t *testing.T) { + server := setupPOPServer(t, newMemStore(t), false, false) + + script := []scriptStep{ + {"APOP mailbox digest", []string{"+OK Found 0 messages for mailbox"}}, + {"STAT", []string{"+OK 0 0"}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + } + playPOP3Session(t, server, script) +} + +func TestTransactionStat(t *testing.T) { + ds := newMemStore(t) + _, size1 := test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + _, size2 := test.DeliverToStore(t, ds, "mailbox", "Two", time.Now()) + server := setupPOPServer(t, ds, false, false) + + script := slices.Concat(loginSteps(2), []scriptStep{ + {"STAT", []string{fmt.Sprintf("+OK %v %v", 2, size1+size2)}}, + {"DELE 1", []string{"+OK Deleted message 1"}}, + {"STAT", []string{fmt.Sprintf("+OK %v %v", 1, size2)}}, + {"RSET", []string{"+OK Session reset"}}, + {"STAT", []string{fmt.Sprintf("+OK %v %v", 2, size1+size2)}}, + {"STAT 1", []string{"-ERR STAT command must have no arguments"}}, + {"NOOP", []string{"+OK I have successfully done nothing"}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + }) + playPOP3Session(t, server, script) +} + +func TestTransactionList(t *testing.T) { + ds := newMemStore(t) + _, size1 := test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + _, size2 := test.DeliverToStore(t, ds, "mailbox", "Two", time.Now()) + server := setupPOPServer(t, ds, false, false) + + script := slices.Concat(loginSteps(2), []scriptStep{ + {"LIST", []string{"+OK Listing 2 messages", + fmt.Sprintf("1 %v", size1), fmt.Sprintf("2 %v", size2), "."}}, + {"LIST 2", []string{fmt.Sprintf("+OK 2 %v", size2)}}, + {"DELE 1", []string{"+OK Deleted message 1"}}, + {"LIST", []string{"+OK Listing 1 messages", fmt.Sprintf("2 %v", size2), "."}}, + {"LIST 1", []string{"-ERR You deleted message 1"}}, + {"CAPA", []string{"+OK Capability list follows", + "TOP", "USER", "UIDL", "IMPLEMENTATION Inbucket", "."}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + }) + playPOP3Session(t, server, script) +} + +func TestTransactionListArgumentErrors(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + server := setupPOPServer(t, ds, false, false) + + tcs := []struct { + name string + script []scriptStep + }{ + {"two arguments", []scriptStep{{"LIST 1 2", []string{"-ERR LIST command must have zero or one argument"}}}}, + {"not an integer", []scriptStep{{"LIST x", []string{"-ERR LIST command requires an integer argument"}}}}, + {"zero", []scriptStep{{"LIST 0", []string{"-ERR LIST argument must be greater than 0"}}}}, + {"negative", []scriptStep{{"LIST -1", []string{"-ERR LIST argument must be greater than 0"}}}}, + {"out of range", []scriptStep{{"LIST 2", []string{"-ERR LIST argument must not exceed the number of messages"}}}}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + playPOP3Session(t, server, slices.Concat(loginSteps(1), tc.script)) + }) + } +} + +func TestTransactionUidl(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + test.DeliverToStore(t, ds, "mailbox", "Two", time.Now()) + server := setupPOPServer(t, ds, false, false) + + script := slices.Concat(loginSteps(2), []scriptStep{ + {"UIDL", []string{"+OK Listing 2 messages", "1 1", "2 2", "."}}, + {"UIDL 2", []string{"+OK 2 2"}}, + {"DELE 2", []string{"+OK Deleted message 2"}}, + {"UIDL", []string{"+OK Listing 1 messages", "1 1", "."}}, + {"UIDL 2", []string{"-ERR You deleted message 2"}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + }) + playPOP3Session(t, server, script) +} + +func TestTransactionUidlArgumentErrors(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + server := setupPOPServer(t, ds, false, false) + + tcs := []struct { + name string + script []scriptStep + }{ + {"two arguments", []scriptStep{{"UIDL 1 2", []string{"-ERR UIDL command must have zero or one argument"}}}}, + {"not an integer", []scriptStep{{"UIDL x", []string{"-ERR UIDL command requires an integer argument"}}}}, + {"zero", []scriptStep{{"UIDL 0", []string{"-ERR UIDL argument must be greater than 0"}}}}, + {"out of range", []scriptStep{{"UIDL 2", []string{"-ERR UIDL argument must not exceed the number of messages"}}}}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + playPOP3Session(t, server, slices.Concat(loginSteps(1), tc.script)) + }) + } +} + +func TestTransactionDeleAndRset(t *testing.T) { + ds := newMemStore(t) + _, size1 := test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + test.DeliverToStore(t, ds, "mailbox", "Two", time.Now()) + server := setupPOPServer(t, ds, false, false) + + script := slices.Concat(loginSteps(2), []scriptStep{ + {"DELE 2", []string{"+OK Deleted message 2"}}, + {"DELE 2", []string{"-ERR Message 2 has already been deleted"}}, + {"STAT", []string{fmt.Sprintf("+OK 1 %v", size1)}}, + {"RSET", []string{"+OK Session reset"}}, + {"DELE 2", []string{"+OK Deleted message 2"}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + }) + playPOP3Session(t, server, script) +} + +func TestTransactionDeleArgumentErrors(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + server := setupPOPServer(t, ds, false, false) + + tcs := []struct { + name string + script []scriptStep + }{ + {"no argument", []scriptStep{{"DELE", []string{"-ERR DELE command requires a single argument"}}}}, + {"two arguments", []scriptStep{{"DELE 1 2", []string{"-ERR DELE command requires a single argument"}}}}, + {"not an integer", []scriptStep{{"DELE x", []string{"-ERR DELE command requires an integer argument"}}}}, + {"zero", []scriptStep{{"DELE 0", []string{"-ERR DELE argument must be greater than 0"}}}}, + {"out of range", []scriptStep{{"DELE 2", []string{"-ERR DELE argument must not exceed the number of messages"}}}}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + playPOP3Session(t, server, slices.Concat(loginSteps(1), tc.script)) + }) + } +} + +func TestTransactionQuitProcessesDeletes(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + test.DeliverToStore(t, ds, "mailbox", "Two", time.Now()) + server := setupPOPServer(t, ds, false, false) + + serverConn, clientConn := net.Pipe() + startPOP3Session(t, server, &mockConn{serverConn}) + t.Cleanup(func() { _ = clientConn.Close() }) + + c := textproto.NewConn(clientConn) + readPOP3Greeting(t, c) + playPOP3Script(t, c, slices.Concat(loginSteps(2), []scriptStep{ + {"DELE 1", []string{"+OK Deleted message 1"}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + })) + + // The deletes are processed before the session goroutine completes. + server.Drain() + msgs := test.GetAndCountMessages(t, ds, "mailbox", 1) + assert.Equal(t, "2", msgs[0].ID()) +} + +func TestTransactionQuitPreservesMessagesAfterRset(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + test.DeliverToStore(t, ds, "mailbox", "Two", time.Now()) + server := setupPOPServer(t, ds, false, false) + + serverConn, clientConn := net.Pipe() + startPOP3Session(t, server, &mockConn{serverConn}) + t.Cleanup(func() { _ = clientConn.Close() }) + + c := textproto.NewConn(clientConn) + readPOP3Greeting(t, c) + playPOP3Script(t, c, slices.Concat(loginSteps(2), []scriptStep{ + {"DELE 1", []string{"+OK Deleted message 1"}}, + {"RSET", []string{"+OK Session reset"}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + })) + + server.Drain() + test.GetAndCountMessages(t, ds, "mailbox", 2) +} + +func TestTransactionQuitWithoutDeletes(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + server := setupPOPServer(t, ds, false, false) + + serverConn, clientConn := net.Pipe() + startPOP3Session(t, server, &mockConn{serverConn}) + t.Cleanup(func() { _ = clientConn.Close() }) + + c := textproto.NewConn(clientConn) + readPOP3Greeting(t, c) + playPOP3Script(t, c, slices.Concat(loginSteps(1), []scriptStep{ + {"QUIT", []string{"+OK We will process your deletes"}}, + })) + + server.Drain() + test.GetAndCountMessages(t, ds, "mailbox", 1) +} + +func TestTransactionRetr(t *testing.T) { + ds := newMemStore(t) + _, size1 := test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + test.DeliverToStore(t, ds, "mailbox", "Two", time.Now()) + server := setupPOPServer(t, ds, false, false) + + script := slices.Concat(loginSteps(2), []scriptStep{ + {"RETR 1", []string{fmt.Sprintf("+OK %v bytes follows", size1), + "To: somebody@host", "From: somebodyelse@host", "Subject: One", "", "Test Body", "."}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + }) + playPOP3Session(t, server, script) +} + +func TestTransactionRetrArgumentErrors(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + server := setupPOPServer(t, ds, false, false) + + tcs := []struct { + name string + script []scriptStep + }{ + {"no argument", []scriptStep{{"RETR", []string{"-ERR RETR command requires a single argument"}}}}, + {"two arguments", []scriptStep{{"RETR 1 2", []string{"-ERR RETR command requires a single argument"}}}}, + {"not an integer", []scriptStep{{"RETR x", []string{"-ERR RETR command requires an integer argument"}}}}, + {"zero", []scriptStep{{"RETR 0", []string{"-ERR RETR argument must be greater than 0"}}}}, + {"out of range", []scriptStep{{"RETR 2", []string{"-ERR RETR argument must not exceed the number of messages"}}}}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + playPOP3Session(t, server, slices.Concat(loginSteps(1), tc.script)) + }) + } +} + +// Documents current behavior: RFC 1939 requires a negative status indicator +// when RETR refers to a message marked as deleted, but the handler does not +// check the retain flag. Flagged for a follow-up fix. +func TestTransactionRetrDeletedMessage(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + _, size2 := test.DeliverToStore(t, ds, "mailbox", "Two", time.Now()) + server := setupPOPServer(t, ds, false, false) + + script := slices.Concat(loginSteps(2), []scriptStep{ + {"DELE 2", []string{"+OK Deleted message 2"}}, + {"RETR 2", []string{fmt.Sprintf("+OK %v bytes follows", size2), + "To: somebody@host", "From: somebodyelse@host", "Subject: Two", "", "Test Body", "."}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + }) + playPOP3Session(t, server, script) +} + +func TestTransactionTop(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + server := setupPOPServer(t, ds, false, false) + + headers := []string{"To: somebody@host", "From: somebodyelse@host", "Subject: One"} + + script := slices.Concat(loginSteps(1), []scriptStep{ + // Zero body lines: headers plus the blank separator line only. + {"TOP 1 0", slices.Concat([]string{"+OK Top of message follows"}, headers, []string{""}, []string{"."})}, + // One body line. + {"TOP 1 1", slices.Concat([]string{"+OK Top of message follows"}, headers, []string{"", "Test Body", "."})}, + // More body lines than the message contains. + {"TOP 1 99", slices.Concat([]string{"+OK Top of message follows"}, headers, []string{"", "Test Body", "."})}, + {"QUIT", []string{"+OK We will process your deletes"}}, + }) + playPOP3Session(t, server, script) +} + +func TestTransactionTopArgumentErrors(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + server := setupPOPServer(t, ds, false, false) + + tcs := []struct { + name string + script []scriptStep + }{ + {"one argument", []scriptStep{{"TOP 1", []string{"-ERR TOP command requires two arguments"}}}}, + {"three arguments", []scriptStep{{"TOP 1 2 3", []string{"-ERR TOP command requires two arguments"}}}}, + {"message not an integer", []scriptStep{{"TOP x 1", []string{"-ERR TOP command requires an integer argument"}}}}, + {"message zero", []scriptStep{{"TOP 0 1", []string{"-ERR TOP first argument must be greater than 0"}}}}, + {"message out of range", []scriptStep{{"TOP 2 1", []string{"-ERR TOP first argument must not exceed the number of messages"}}}}, + {"lines not an integer", []scriptStep{{"TOP 1 x", []string{"-ERR TOP command requires an integer argument"}}}}, + {"lines negative", []scriptStep{{"TOP 1 -1", []string{"-ERR TOP second argument must be non-negative"}}}}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + playPOP3Session(t, server, slices.Concat(loginSteps(1), tc.script)) + }) + } +} + +// Documents current behavior: RFC 1939 (TOP, section 7) requires a negative +// status indicator when TOP refers to a message marked as deleted, but the +// handler does not check the retain flag. Flagged for a follow-up fix. +func TestTransactionTopDeletedMessage(t *testing.T) { + ds := newMemStore(t) + test.DeliverToStore(t, ds, "mailbox", "One", time.Now()) + server := setupPOPServer(t, ds, false, false) + + script := slices.Concat(loginSteps(1), []scriptStep{ + {"DELE 1", []string{"+OK Deleted message 1"}}, + {"TOP 1 0", []string{"+OK Top of message follows", + "To: somebody@host", "From: somebodyelse@host", "Subject: One", "", "."}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + }) + playPOP3Session(t, server, script) +} + +func TestTransactionRetrDotStuffing(t *testing.T) { + ds := newMemStore(t) + _, size := deliverRaw(t, ds, "mailbox", "Subject: dots\r\n\r\n.leading\r\n..double\r\n.\r\nplain\r\n") + server := setupPOPServer(t, ds, false, false) + + script := slices.Concat(loginSteps(1), []scriptStep{ + // Each leading period is doubled; a lone "." line becomes "..". + {"RETR 1", []string{fmt.Sprintf("+OK %v bytes follows", size), + "Subject: dots", "", "..leading", "...double", "..", "plain", "."}}, + {"TOP 1 0", []string{"+OK Top of message follows", "Subject: dots", "", "."}}, + {"TOP 1 2", []string{"+OK Top of message follows", + "Subject: dots", "", "..leading", "...double", "."}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + }) + playPOP3Session(t, server, script) +} + +func TestTransactionRetrSourceError(t *testing.T) { + ds := test.NewStore() + _, err := ds.AddMessage(&fakeMsg{ + mailbox: "mailbox", id: "m1", size: 100, + source: func() (io.ReadCloser, error) { return nil, errors.New("source unavailable") }, + }) + require.NoError(t, err) + server := setupPOPServer(t, ds, false, false) + + script := slices.Concat(loginSteps(1), []scriptStep{ + {"RETR 1", []string{"+OK 100 bytes follows", + "-ERR Failed to RETR that message, internal error"}}, + {"TOP 1 0", []string{"+OK Top of message follows", + "-ERR Failed to RETR that message, internal error"}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + }) + playPOP3Session(t, server, script) +} + +func TestTransactionRetrReadError(t *testing.T) { + ds := test.NewStore() + _, err := ds.AddMessage(&fakeMsg{ + mailbox: "mailbox", id: "m1", size: 100, + source: func() (io.ReadCloser, error) { + return io.NopCloser(iotest.ErrReader(errors.New("short read"))), nil + }, + }) + require.NoError(t, err) + server := setupPOPServer(t, ds, false, false) + + script := slices.Concat(loginSteps(1), []scriptStep{ + // A read failure mid-message terminates the multi-line response, then + // reports the error. + {"RETR 1", []string{"+OK 100 bytes follows", ".", + "-ERR Failed to RETR that message, internal error"}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + }) + playPOP3Session(t, server, script) +} + +// Documents current behavior: errors loading the mailbox are logged but not +// reported to the client, which sees an empty mailbox. +func TestTransactionLoadMailboxError(t *testing.T) { + server := setupPOPServer(t, test.NewStore(), false, false) + + script := []scriptStep{ + {"USER messageserr", []string{"+OK Hello messageserr, welcome to Inbucket"}}, + {"PASS anything", []string{"+OK Found 0 messages for messageserr"}}, + {"STAT", []string{"+OK 0 0"}}, + {"LIST", []string{"+OK Listing 0 messages", "."}}, + {"UIDL", []string{"+OK Listing 0 messages", "."}}, + {"QUIT", []string{"+OK We will process your deletes"}}, + } + playPOP3Session(t, server, script) +} + +func TestSessionString(t *testing.T) { + server := setupPOPServer(t, test.NewStore(), false, false) + + serverConn, clientConn := net.Pipe() + t.Cleanup(func() { + _ = clientConn.Close() + _ = serverConn.Close() + }) + + ssn := NewSession(server, 7, &mockConn{serverConn}, zerolog.New(zerolog.NewTestWriter(t))) + assert.Equal(t, "Session{id: 7, state: AUTHORIZATION}", ssn.String()) +} + +func TestReadDeadlineError(t *testing.T) { + tcs := []struct { + name string + err error + want string + }{ + {"timeout", fakeNetError{timeout: true}, "-ERR Idle timeout, bye bye"}, + {"other error", fakeNetError{timeout: false}, "-ERR Connection error, sorry"}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + server := setupPOPServer(t, newMemStore(t), false, false) + + serverConn, clientConn := net.Pipe() + startPOP3Session(t, server, &readDeadlineErrorConn{serverConn, tc.err}) + t.Cleanup(func() { _ = clientConn.Close() }) + + c := textproto.NewConn(clientConn) + readPOP3Greeting(t, c) + + line, err := c.ReadLine() + require.NoError(t, err, "reading error response") + assert.Equal(t, tc.want, line) + + // The session should be closed. + _, err = c.ReadLine() + assert.Error(t, err, "expected connection to be closed") + }) + } +} + +func TestServerStartServeAndQuit(t *testing.T) { + server, err := NewServer(config.POP3{ + Addr: "127.0.0.1:0", + Domain: "inbucket.local", + Timeout: 5 * time.Second, + }, test.NewStore()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan struct{}) + started := make(chan struct{}) + go func() { + defer close(started) + server.Start(ctx, func() { close(ready) }) + }() + <-ready + t.Cleanup(func() { + cancel() + <-started + server.Drain() + }) + + conn, err := (&net.Dialer{}).DialContext(context.Background(), "tcp", server.listener.Addr().String()) + require.NoError(t, err) + c := textproto.NewConn(conn) + readPOP3Greeting(t, c) + require.NoError(t, c.PrintfLine("QUIT")) + + line, err := c.ReadLine() + require.NoError(t, err) + assert.Equal(t, "+OK Goodnight and good luck", line) + require.NoError(t, conn.Close()) +} + +func TestServerStartAddressErrors(t *testing.T) { + // Reserve a port to force a bind failure. + reserved, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = reserved.Close() }) + + tcs := []struct { + name string + addr string + }{ + {"invalid port", "127.0.0.1:notaport"}, + {"port in use", reserved.Addr().String()}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + server, err := NewServer(config.POP3{ + Addr: tc.addr, + Domain: "inbucket.local", + Timeout: 5 * time.Second, + }, test.NewStore()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go server.Start(ctx, func() { t.Error("readyFunc must not be called") }) + + select { + case err := <-server.Notify(): + require.Error(t, err) + case <-time.After(10 * time.Second): + t.Fatal("expected a fatal error notification") + } + }) + } +} + +func TestServerAcceptErrorNotifies(t *testing.T) { + server, err := NewServer(config.POP3{ + Addr: "127.0.0.1:0", + Domain: "inbucket.local", + Timeout: 5 * time.Second, + }, test.NewStore()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan struct{}) + started := make(chan struct{}) + go func() { + defer close(started) + server.Start(ctx, func() { close(ready) }) + }() + <-ready + t.Cleanup(func() { + cancel() + <-started + server.Drain() + }) + + // Closing the listener without cancelling the context makes serve() + // report the accept error via Notify. + require.NoError(t, server.listener.Close()) + + select { + case err := <-server.Notify(): + require.Error(t, err) + case <-time.After(10 * time.Second): + t.Fatal("expected a fatal error notification") + } +} + +func TestNewServerCertificateError(t *testing.T) { + _, err := NewServer(config.POP3{ + Addr: "127.0.0.1:1100", + Domain: "inbucket.local", + Timeout: 5 * time.Second, + TLSEnabled: true, + TLSCert: "/nonexistent/cert.pem", + TLSPrivKey: "/nonexistent/key.pem", + }, test.NewStore()) + require.Error(t, err) +}