1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-10-29 05:32:31 +00:00
Files
go-inbucket/pkg/server/pop3/transaction_test.go
T
James Hillyerd 8bad098be9 refactor(pop3): extract transaction handler helpers (#619)
- validateMsgNum parses and range-checks message-number arguments,
  replacing five copies of the parse/check/respond sequence in the
  LIST, UIDL, DELE, RETR and TOP arms (wire responses unchanged).
- listMessages implements LIST and UIDL, which differ only in the
  per-message value reported.
- sendMessageLines unifies sendMessage/sendMessageTop via a body line
  limit, negative meaning unlimited.

Adds a direct unit test for validateMsgNum and an equivalence test
asserting TOP with a large line limit matches RETR output. All
scripted session tests from the previous commit pass unchanged.

Signed-off-by: James Hillyerd <james@hillyerd.com>
2026-09-16 18:03:59 -07:00

844 lines
28 KiB
Go

package pop3
import (
"bufio"
"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 size.
func deliverRaw(t *testing.T, store storage.Store, mailbox, raw string) int64 {
t.Helper()
delivery := &message.Delivery{
Meta: event.MessageMetadata{
Mailbox: mailbox,
Subject: "raw",
},
Reader: io.NopCloser(strings.NewReader(raw)),
}
_, err := store.AddMessage(delivery)
require.NoError(t, err)
return 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)
}
// TestValidateMsgNum exercises the shared message-number validation helper
// directly.
func TestValidateMsgNum(t *testing.T) {
server := setupPOPServer(t, newMemStore(t), false, false)
serverConn, clientConn := net.Pipe()
t.Cleanup(func() { _ = clientConn.Close() })
// Drain server->client output so sends do not block.
output := make(chan string, 16)
go func() {
sc := bufio.NewScanner(clientConn)
for sc.Scan() {
output <- sc.Text()
}
close(output)
}()
ssn := NewSession(server, 1, &mockConn{serverConn}, zerolog.New(zerolog.NewTestWriter(t)))
ssn.messages = make([]storage.Message, 3)
ssn.retainAll()
tcs := []struct {
name string
arg string
wantNum int
wantOK bool
wantSend string
}{
{name: "first message", arg: "1", wantNum: 1, wantOK: true},
{name: "last message", arg: "3", wantNum: 3, wantOK: true},
{name: "not an integer", arg: "x", wantSend: "-ERR TEST command requires an integer argument"},
{name: "integer overflow", arg: "9999999999999", wantSend: "-ERR TEST command requires an integer argument"},
{name: "zero", arg: "0", wantSend: "-ERR TEST argument must be greater than 0"},
{name: "negative", arg: "-1", wantSend: "-ERR TEST argument must be greater than 0"},
{name: "exceeds message count", arg: "4", wantSend: "-ERR TEST argument must not exceed the number of messages"},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
num, ok := ssn.validateMsgNum("TEST", "TEST", tc.arg)
assert.Equal(t, tc.wantOK, ok)
assert.Equal(t, tc.wantNum, num)
if tc.wantSend != "" {
select {
case line := <-output:
assert.Equal(t, tc.wantSend, line)
case <-time.After(time.Second):
t.Error("expected an error response")
}
}
})
}
}
// TestTopUnlimitedLinesMatchesRetr guards the shared implementation of RETR
// and TOP: a TOP line limit larger than the message must produce the same
// output as RETR.
func TestTopUnlimitedLinesMatchesRetr(t *testing.T) {
ds := newMemStore(t)
size := deliverRaw(t, ds, "mailbox", "Subject: equivalence\r\n\r\nline 1\r\nline 2\r\n.leading\r\n")
server := setupPOPServer(t, ds, false, false)
body := []string{"Subject: equivalence", "", "line 1", "line 2", "..leading", "."}
retrExpect := slices.Concat([]string{fmt.Sprintf("+OK %v bytes follows", size)}, body)
topExpect := slices.Concat([]string{"+OK Top of message follows"}, body)
script := slices.Concat(loginSteps(1), []scriptStep{
{"RETR 1", retrExpect},
{"TOP 1 999999", topExpect},
{"QUIT", []string{"+OK We will process your deletes"}},
})
playPOP3Session(t, server, script)
}