fetcher_chunk_test.go

  1package fetcher
  2
  3import (
  4	"bufio"
  5	"crypto/rand"
  6	"crypto/rsa"
  7	"crypto/tls"
  8	"crypto/x509"
  9	"crypto/x509/pkix"
 10	"encoding/pem"
 11	"fmt"
 12	"math/big"
 13	"net"
 14	"strconv"
 15	"strings"
 16	"sync"
 17	"testing"
 18	"time"
 19
 20	"github.com/floatpane/matcha/config"
 21)
 22
 23func TestFetchMailboxEmailsUsesRequestedLimitForSmallFetchChunks(t *testing.T) {
 24	fetchCommands := make(chan string, 1)
 25	addr, closeServer := startFetchRecorderIMAPServer(t, 100, fetchCommands)
 26	defer closeServer()
 27
 28	host, portText, err := net.SplitHostPort(addr)
 29	if err != nil {
 30		t.Fatalf("SplitHostPort(%q): %v", addr, err)
 31	}
 32	port, err := strconv.Atoi(portText)
 33	if err != nil {
 34		t.Fatalf("Atoi(%q): %v", portText, err)
 35	}
 36
 37	account := &config.Account{
 38		ID:              "test-account",
 39		Email:           "user@example.com",
 40		Password:        "password",
 41		ServiceProvider: "custom",
 42		IMAPServer:      host,
 43		IMAPPort:        port,
 44		Insecure:        true,
 45		CatchAll:        true,
 46		SC:              &config.SessionCache{},
 47	}
 48	done := make(chan error, 1)
 49	go func() {
 50		_, err := FetchMailboxEmails(account, "INBOX", 5, 0)
 51		done <- err
 52	}()
 53
 54	select {
 55	case command := <-fetchCommands:
 56		if !strings.Contains(command, "96:100") {
 57			t.Fatalf("first FETCH command = %q, want range 96:100", command)
 58		}
 59	case <-time.After(2 * time.Second):
 60		t.Fatal("timed out waiting for FETCH command")
 61	}
 62
 63	closeServer()
 64	select {
 65	case <-done:
 66	case <-time.After(2 * time.Second):
 67		t.Fatal("FetchMailboxEmails did not return after server closed")
 68	}
 69}
 70
 71func startFetchRecorderIMAPServer(t *testing.T, messages uint32, fetchCommands chan<- string) (string, func()) {
 72	t.Helper()
 73
 74	listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
 75		Certificates: []tls.Certificate{newTestTLSCertificate(t)},
 76	})
 77	if err != nil {
 78		t.Fatalf("starting test IMAP server: %v", err)
 79	}
 80
 81	var closeOnce sync.Once
 82	var connMu sync.Mutex
 83	var conn net.Conn
 84	closeServer := func() {
 85		closeOnce.Do(func() {
 86			connMu.Lock()
 87			if conn != nil {
 88				_ = conn.Close()
 89			}
 90			connMu.Unlock()
 91			_ = listener.Close()
 92		})
 93	}
 94
 95	go func() {
 96		accepted, err := listener.Accept()
 97		if err != nil {
 98			return
 99		}
100		connMu.Lock()
101		conn = accepted
102		connMu.Unlock()
103		serveFetchRecorderIMAPConn(accepted, messages, fetchCommands)
104	}()
105
106	return listener.Addr().String(), closeServer
107}
108
109func serveFetchRecorderIMAPConn(conn net.Conn, messages uint32, fetchCommands chan<- string) {
110	defer conn.Close()
111
112	reader := bufio.NewReader(conn)
113	writer := bufio.NewWriter(conn)
114	writeIMAPLine := func(format string, args ...any) bool {
115		if _, err := fmt.Fprintf(writer, format+"\r\n", args...); err != nil {
116			return false
117		}
118		return writer.Flush() == nil
119	}
120
121	if !writeIMAPLine("* OK matcha test server") {
122		return
123	}
124
125	for {
126		line, err := reader.ReadString('\n')
127		if err != nil {
128			return
129		}
130		line = strings.TrimRight(line, "\r\n")
131		fields := strings.Fields(line)
132		if len(fields) < 2 {
133			return
134		}
135
136		tag := fields[0]
137		switch strings.ToUpper(fields[1]) {
138		case "CAPABILITY":
139			if !writeIMAPLine("* CAPABILITY IMAP4rev1 AUTH=PLAIN") {
140				return
141			}
142			if !writeIMAPLine("%s OK CAPABILITY completed", tag) {
143				return
144			}
145		case "LOGIN":
146			if !writeIMAPLine("%s OK LOGIN completed", tag) {
147				return
148			}
149		case "SELECT":
150			if !writeIMAPLine("* %d EXISTS", messages) {
151				return
152			}
153			if !writeIMAPLine("* FLAGS (\\Seen)") {
154				return
155			}
156			if !writeIMAPLine("%s OK [READ-WRITE] SELECT completed", tag) {
157				return
158			}
159		case "FETCH":
160			fetchCommands <- line
161			_ = writeIMAPLine("%s NO recorded FETCH command", tag)
162			return
163		case "LOGOUT":
164			if !writeIMAPLine("* BYE logging out") {
165				return
166			}
167			_ = writeIMAPLine("%s OK LOGOUT completed", tag)
168			return
169		default:
170			if !writeIMAPLine("%s OK completed", tag) {
171				return
172			}
173		}
174	}
175}
176
177func newTestTLSCertificate(t *testing.T) tls.Certificate {
178	t.Helper()
179
180	key, err := rsa.GenerateKey(rand.Reader, 2048)
181	if err != nil {
182		t.Fatalf("generating private key: %v", err)
183	}
184	template := &x509.Certificate{
185		SerialNumber: big.NewInt(1),
186		Subject:      pkix.Name{CommonName: "127.0.0.1"},
187		NotBefore:    time.Now().Add(-time.Hour),
188		NotAfter:     time.Now().Add(time.Hour),
189		KeyUsage:     x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
190		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
191		IPAddresses:  []net.IP{net.ParseIP("127.0.0.1")},
192	}
193	certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
194	if err != nil {
195		t.Fatalf("creating certificate: %v", err)
196	}
197
198	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
199	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
200	cert, err := tls.X509KeyPair(certPEM, keyPEM)
201	if err != nil {
202		t.Fatalf("parsing certificate: %v", err)
203	}
204	return cert
205}