From e6abc2d8ffefbe3039c259c1ee6bd6f337cf9b7c Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Tue, 7 Oct 2025 11:54:13 +0200
Subject: [PATCH] groupware: rewrite JMAP integration test to be more reusable,
and upgrade Stalwart container to 0.13.4
---
pkg/jmap/jmap_integration_test.go | 533 ++++++++++++++++++------------
1 file changed, 319 insertions(+), 214 deletions(-)
diff --git a/pkg/jmap/jmap_integration_test.go b/pkg/jmap/jmap_integration_test.go
index ba943a170d..ae21f2bce9 100644
--- a/pkg/jmap/jmap_integration_test.go
+++ b/pkg/jmap/jmap_integration_test.go
@@ -5,6 +5,7 @@ import (
"context"
"crypto/tls"
"fmt"
+ "io"
"log"
"math/rand"
"net"
@@ -13,6 +14,7 @@ import (
"net/url"
"os"
"regexp"
+ "strconv"
"strings"
"testing"
"text/template"
@@ -54,7 +56,7 @@ var (
)
const (
- stalwartImage = "ghcr.io/stalwartlabs/stalwart:v0.13.2-alpine"
+ stalwartImage = "ghcr.io/stalwartlabs/stalwart:v0.13.4-alpine"
httpPort = "8080"
imapsPort = "993"
configTemplate = `
@@ -135,14 +137,270 @@ var formats = []func(string, enmime.MailBuilder) enmime.MailBuilder{
bothFormat,
}
-func fill(require *require.Assertions, i *imapclient.Client, folder string, to string, count int, ccEvery int, bccEvery int) {
+func mailboxId(role string, mailboxes []Mailbox) string {
+ for _, m := range mailboxes {
+ if m.Role == role {
+ return m.Id
+ }
+ }
+ return ""
+}
+
+func skip(t *testing.T) bool {
+ if os.Getenv("CI") == "woodpecker" {
+ t.Skip("Skipping tests because CI==wookpecker")
+ return true
+ }
+ if os.Getenv("CI_SYSTEM_NAME") == "woodpecker" {
+ t.Skip("Skipping tests because CI_SYSTEM_NAME==wookpecker")
+ return true
+ }
+ if os.Getenv("USE_TESTCONTAINERS") == "false" {
+ t.Skip("Skipping tests because USE_TESTCONTAINERS==false")
+ return true
+ }
+ return false
+}
+
+type StalwartTest struct {
+ t *testing.T
+ ip string
+ imapPort int
+ container *testcontainers.DockerContainer
+ ctx context.Context
+ cancelCtx context.CancelFunc
+ client *Client
+ session *Session
+ username string
+ password string
+ logger *clog.Logger
+ userPersonName string
+ userEmail string
+
+ io.Closer
+}
+
+func (s *StalwartTest) Close() error {
+ if s.container != nil {
+ var c testcontainers.Container = s.container
+ testcontainers.CleanupContainer(s.t, c)
+ }
+ if s.cancelCtx != nil {
+ s.cancelCtx()
+ }
+ return nil
+}
+
+func newStalwartTest(t *testing.T) (*StalwartTest, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ var _ context.CancelFunc = cancel // ignore context leak warning: it is passed in the struct and called in Close()
+
+ // A master user name different from "master" does not seem to work as of the current Stalwart version
+ //masterUsernameSuffix, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
+ //require.NoError(err)
+ masterUsername := "master" //"master_" + masterUsernameSuffix
+
+ masterPassword, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
+ if err != nil {
+ return nil, err
+ }
+ masterPasswordHash := ""
+ {
+ hasher, err := shacrypt.New(shacrypt.WithSHA512(), shacrypt.WithIterations(shacrypt.IterationsDefaultOmitted))
+ if err != nil {
+ return nil, err
+ }
+
+ digest, err := hasher.Hash(masterPassword)
+ if err != nil {
+ return nil, err
+ }
+ masterPasswordHash = digest.Encode()
+ }
+
+ usernameSuffix, err := pw.Generate(8, 2, 0, true, true)
+ if err != nil {
+ return nil, err
+ }
+ username := "user_" + usernameSuffix
+
+ password, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
+ if err != nil {
+ return nil, err
+ }
+
+ hostname := "localhost"
+
+ userPersonName := people[rand.Intn(len(people))]
+ var userEmail string
+ {
+ domain := domains[rand.Intn(len(domains))]
+ userEmail = strings.Join(strings.Split(cases.Lower(language.English).String(userPersonName), " "), ".") + "@" + domain
+ }
+
+ configBuf := bytes.NewBufferString("")
+ template.Must(template.New("").Parse(configTemplate)).Execute(configBuf, map[string]any{
+ "hostname": hostname,
+ "password": password,
+ "username": username,
+ "description": userPersonName,
+ "email": userEmail,
+ "masterusername": masterUsername,
+ "masterpassword": masterPasswordHash,
+ "httpPort": httpPort,
+ "imapsPort": imapsPort,
+ })
+ config := configBuf.String()
+ configReader := strings.NewReader(config)
+
+ container, err := testcontainers.Run(
+ ctx,
+ stalwartImage,
+ testcontainers.WithExposedPorts(httpPort+"/tcp", imapsPort+"/tcp"),
+ testcontainers.WithFiles(testcontainers.ContainerFile{
+ Reader: configReader,
+ ContainerFilePath: "/opt/stalwart/etc/config.toml",
+ FileMode: 0o700,
+ }),
+ testcontainers.WithWaitStrategyAndDeadline(
+ 30*time.Second,
+ wait.ForLog(`Network listener started (network.listen-start) listenerId = "imaptls"`),
+ wait.ForLog(`Network listener started (network.listen-start) listenerId = "http"`),
+ ),
+ )
+
+ success := false
+ defer func() {
+ if !success {
+ testcontainers.CleanupContainer(t, container)
+ }
+ }()
+
+ ip, err := container.Host(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ imapPort, err := container.MappedPort(ctx, "993")
+ if err != nil {
+ return nil, err
+ }
+
+ tlsConfig := &tls.Config{InsecureSkipVerify: true}
+
+ loggerImpl := clog.NewLogger()
+ logger := &loggerImpl
+ var j Client
+ var session *Session
+ {
+ tr := http.DefaultTransport.(*http.Transport).Clone()
+ tr.ResponseHeaderTimeout = time.Duration(30 * time.Second)
+ tr.TLSClientConfig = tlsConfig
+ jh := *http.DefaultClient
+ jh.Transport = tr
+
+ wsd := &websocket.Dialer{
+ TLSClientConfig: tlsConfig,
+ HandshakeTimeout: time.Duration(10) * time.Second,
+ }
+
+ jmapPort, err := container.MappedPort(ctx, httpPort)
+ if err != nil {
+ return nil, err
+ }
+ jmapBaseUrl := url.URL{
+ Scheme: "http",
+ Host: ip + ":" + jmapPort.Port(),
+ }
+
+ sessionUrl := jmapBaseUrl.JoinPath(".well-known", "jmap")
+
+ api := NewHttpJmapClient(
+ &jh,
+ masterUsername,
+ masterPassword,
+ nullHttpJmapApiClientEventListener{},
+ )
+
+ wscf, err := NewHttpWsClientFactory(wsd, masterUsername, masterPassword, logger)
+ if err != nil {
+ return nil, err
+ }
+
+ j = NewClient(api, api, api, wscf)
+ s, err := j.FetchSession(sessionUrl, username, logger)
+ if err != nil {
+ return nil, err
+ }
+ // we have to overwrite the hostname in JMAP URL because the container
+ // will know its name to be a random Docker container identifier, or
+ // "localhost" as we defined the hostname in the Stalwart configuration,
+ // and we also need to overwrite the port number as its not mapped
+ s.JmapUrl.Host = jmapBaseUrl.Host
+ session = &s
+ }
+
+ success = true
+ return &StalwartTest{
+ t: t,
+ ip: ip,
+ imapPort: imapPort.Int(),
+ container: container,
+ ctx: ctx,
+ cancelCtx: cancel,
+ client: &j,
+ session: session,
+ username: username,
+ password: password,
+ logger: logger,
+ userPersonName: userPersonName,
+ userEmail: userEmail,
+ }, nil
+}
+
+func (s *StalwartTest) fill(folder string, count int) error {
+ to := fmt.Sprintf("%s <%s>", s.userPersonName, s.userEmail)
+ ccEvery := 2
+ bccEvery := 3
+
+ tlsConfig := &tls.Config{InsecureSkipVerify: true}
+
+ c, err := imapclient.DialTLS(net.JoinHostPort(s.ip, strconv.Itoa(s.imapPort)), &imapclient.Options{TLSConfig: tlsConfig})
+ if err != nil {
+ return err
+ }
+
+ defer func(imap *imapclient.Client) {
+ err := imap.Close()
+ if err != nil {
+ log.Fatal(err)
+ }
+ }(c)
+
+ err = c.Login(s.username, s.password).Wait()
+ if err != nil {
+ return err
+ }
+
+ _, err = c.Select(folder, nil).Wait()
+ if err != nil {
+ return err
+ }
+
address, err := mail.ParseAddress(to)
- require.NoError(err)
+ if err != nil {
+ return err
+ }
displayName := address.Name
addressParts := emailSplitter.FindAllStringSubmatch(address.Address, 3)
- require.Len(addressParts, 1)
- require.Len(addressParts[0], 3)
+ if len(addressParts) != 1 {
+ return fmt.Errorf("address does not have one part: '%v' -> %v", address.Address, addressParts)
+ }
+ if len(addressParts[0]) != 3 {
+ return fmt.Errorf("first address part does not have a size of 3: '%v'", addressParts[0])
+ }
+
domain := addressParts[0][2]
toName := displayName
@@ -189,183 +447,75 @@ func fill(require *require.Assertions, i *imapclient.Client, folder string, to s
mail := buf.String()
size := int64(len(mail))
- appendCmd := i.Append(folder, size, nil)
- _, err := appendCmd.Write([]byte(mail))
- require.NoError(err)
- err = appendCmd.Close()
- require.NoError(err)
- _, err = appendCmd.Wait()
- require.NoError(err)
- }
-}
-
-func mailboxId(role string, mailboxes []Mailbox) string {
- for _, m := range mailboxes {
- if m.Role == role {
- return m.Id
+ appendCmd := c.Append(folder, size, nil)
+ if _, err := appendCmd.Write([]byte(mail)); err != nil {
+ return err
+ }
+ if err = appendCmd.Close(); err != nil {
+ return err
+ }
+ if _, err = appendCmd.Wait(); err != nil {
+ return err
}
}
- return ""
-}
-func skip(t *testing.T) bool {
- if os.Getenv("CI") == "woodpecker" {
- t.Skip("Skipping tests because CI==wookpecker")
- return true
+ listCmd := c.List("", "%", &imap.ListOptions{
+ ReturnStatus: &imap.StatusOptions{
+ NumMessages: true,
+ NumUnseen: true,
+ },
+ })
+ countMap := make(map[string]int)
+ for {
+ mbox := listCmd.Next()
+ if mbox == nil {
+ break
+ }
+ countMap[mbox.Mailbox] = int(*mbox.Status.NumMessages)
}
- if os.Getenv("CI_SYSTEM_NAME") == "woodpecker" {
- t.Skip("Skipping tests because CI_SYSTEM_NAME==wookpecker")
- return true
+
+ inboxCount := -1
+ for f, i := range countMap {
+ if strings.Compare(strings.ToLower(f), strings.ToLower(folder)) == 0 {
+ inboxCount = i
+ break
+ }
}
- if os.Getenv("USE_TESTCONTAINERS") == "false" {
- t.Skip("Skipping tests because USE_TESTCONTAINERS==false")
- return true
+ if inboxCount == -1 {
+ return fmt.Errorf("failed to find folder '%v' via IMAP", folder)
}
- return false
+ if count != inboxCount {
+ return fmt.Errorf("wrong number of emails in the inbox after filling, expecting %v, has %v", count, inboxCount)
+ }
+
+ if err = listCmd.Close(); err != nil {
+ return err
+ }
+
+ return nil
}
func TestWithStalwart(t *testing.T) {
if skip(t) {
return
}
- require := require.New(t)
-
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
- defer cancel()
-
- // A master user name different from "master" does not seem to work as of the current Stalwart version
- //masterUsernameSuffix, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
- //require.NoError(err)
- masterUsername := "master" //"master_" + masterUsernameSuffix
-
- masterPassword, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
- require.NoError(err)
- masterPasswordHash := ""
- {
- hasher, err := shacrypt.New(shacrypt.WithSHA512(), shacrypt.WithIterations(shacrypt.IterationsDefaultOmitted))
- require.NoError(err)
-
- digest, err := hasher.Hash(masterPassword)
- require.NoError(err)
- masterPasswordHash = digest.Encode()
- }
-
- usernameSuffix, err := pw.Generate(8, 2, 0, true, true)
- require.NoError(err)
- username := "user_" + usernameSuffix
-
- password, err := pw.Generate(4+rand.Intn(28), 2, 0, false, true)
- require.NoError(err)
-
- hostname := "localhost"
-
- userPersonName := people[rand.Intn(len(people))]
- var userEmail string
- {
- domain := domains[rand.Intn(len(domains))]
- userEmail = strings.Join(strings.Split(cases.Lower(language.English).String(userPersonName), " "), ".") + "@" + domain
- }
-
- configBuf := bytes.NewBufferString("")
- template.Must(template.New("").Parse(configTemplate)).Execute(configBuf, map[string]any{
- "hostname": hostname,
- "password": password,
- "username": username,
- "description": userPersonName,
- "email": userEmail,
- "masterusername": masterUsername,
- "masterpassword": masterPasswordHash,
- "httpPort": httpPort,
- "imapsPort": imapsPort,
- })
- config := configBuf.String()
- configReader := strings.NewReader(config)
-
- container, err := testcontainers.Run(
- ctx,
- stalwartImage,
- testcontainers.WithExposedPorts(httpPort+"/tcp", imapsPort+"/tcp"),
- testcontainers.WithFiles(testcontainers.ContainerFile{
- Reader: configReader,
- ContainerFilePath: "/opt/stalwart/etc/config.toml",
- FileMode: 0o700,
- }),
- testcontainers.WithWaitStrategyAndDeadline(
- 30*time.Second,
- wait.ForLog(`Network listener started (network.listen-start) listenerId = "imaptls"`),
- wait.ForLog(`Network listener started (network.listen-start) listenerId = "http"`),
- ),
- )
-
- defer func() {
- testcontainers.CleanupContainer(t, container)
- }()
- require.NoError(err)
-
- ip, err := container.Host(ctx)
- require.NoError(err)
-
- port, err := container.MappedPort(ctx, "993")
- require.NoError(err)
-
- tlsConfig := &tls.Config{InsecureSkipVerify: true}
count := 5
- loggerImpl := clog.NewLogger()
- logger := &loggerImpl
- var j Client
- var session *Session
- {
- tr := http.DefaultTransport.(*http.Transport).Clone()
- tr.ResponseHeaderTimeout = time.Duration(30 * time.Second)
- tr.TLSClientConfig = tlsConfig
- jh := *http.DefaultClient
- jh.Transport = tr
+ require := require.New(t)
- wsd := &websocket.Dialer{
- TLSClientConfig: tlsConfig,
- HandshakeTimeout: time.Duration(10) * time.Second,
- }
+ s, err := newStalwartTest(t)
+ require.NoError(err)
+ defer s.Close()
- jmapPort, err := container.MappedPort(ctx, httpPort)
- require.NoError(err)
- jmapBaseUrl := url.URL{
- Scheme: "http",
- Host: ip + ":" + jmapPort.Port(),
- }
-
- sessionUrl := jmapBaseUrl.JoinPath(".well-known", "jmap")
-
- api := NewHttpJmapClient(
- &jh,
- masterUsername,
- masterPassword,
- nullHttpJmapApiClientEventListener{},
- )
-
- wscf, err := NewHttpWsClientFactory(wsd, masterUsername, masterPassword, logger)
- require.NoError(err)
-
- j = NewClient(api, api, api, wscf)
- s, err := j.FetchSession(sessionUrl, username, logger)
- require.NoError(err)
- // we have to overwrite the hostname in JMAP URL because the container
- // will know its name to be a random Docker container identifier, or
- // "localhost" as we defined the hostname in the Stalwart configuration,
- // and we also need to overwrite the port number as its not mapped
- s.JmapUrl.Host = jmapBaseUrl.Host
- session = &s
- }
-
- accountId := session.PrimaryAccounts.Mail
+ accountId := s.session.PrimaryAccounts.Mail
var inboxFolder string
var inboxId string
{
- respByAccountId, sessionState, _, err := j.GetAllMailboxes([]string{accountId}, session, ctx, logger, "")
+ respByAccountId, sessionState, _, err := s.client.GetAllMailboxes([]string{accountId}, s.session, s.ctx, s.logger, "")
require.NoError(err)
- require.Equal(session.State, sessionState)
+ require.Equal(s.session.State, sessionState)
require.Len(respByAccountId, 1)
require.Contains(respByAccountId, accountId)
resp := respByAccountId[accountId]
@@ -389,69 +539,24 @@ func TestWithStalwart(t *testing.T) {
}
{
- c, err := imapclient.DialTLS(net.JoinHostPort(ip, port.Port()), &imapclient.Options{TLSConfig: tlsConfig})
- require.NoError(err)
-
- defer func(imap *imapclient.Client) {
- err := imap.Close()
- if err != nil {
- log.Fatal(err)
- }
- }(c)
-
- err = c.Login(username, password).Wait()
- require.NoError(err)
-
- _, err = c.Select(inboxFolder, nil).Wait()
- require.NoError(err)
-
- fill(require, c, inboxFolder, fmt.Sprintf("%s <%s>", userPersonName, userEmail), count, 2, 3)
-
- listCmd := c.List("", "%", &imap.ListOptions{
- ReturnStatus: &imap.StatusOptions{
- NumMessages: true,
- NumUnseen: true,
- },
- })
- countMap := make(map[string]int)
- for {
- mbox := listCmd.Next()
- if mbox == nil {
- break
- }
- countMap[mbox.Mailbox] = int(*mbox.Status.NumMessages)
- }
-
- inboxCount := -1
- for f, i := range countMap {
- if strings.Compare(strings.ToLower(f), strings.ToLower(inboxFolder)) == 0 {
- inboxCount = i
- break
- }
- }
- if inboxCount == -1 {
- require.FailNowf("huh", "failed to find folder '%v' via IMAP", inboxFolder)
- }
- require.Equal(count, inboxCount)
-
- err = listCmd.Close()
+ err := s.fill(inboxFolder, count)
require.NoError(err)
}
{
{
- resp, sessionState, _, err := j.GetIdentity(accountId, session, ctx, logger, "")
+ resp, sessionState, _, err := s.client.GetIdentity(accountId, s.session, s.ctx, s.logger, "")
require.NoError(err)
- require.Equal(session.State, sessionState)
+ require.Equal(s.session.State, sessionState)
require.Len(resp.Identities, 1)
- require.Equal(userEmail, resp.Identities[0].Email)
- require.Equal(userPersonName, resp.Identities[0].Name)
+ require.Equal(s.userEmail, resp.Identities[0].Email)
+ require.Equal(s.userPersonName, resp.Identities[0].Name)
}
{
- respByAccountId, sessionState, _, err := j.GetAllMailboxes([]string{accountId}, session, ctx, logger, "")
+ respByAccountId, sessionState, _, err := s.client.GetAllMailboxes([]string{accountId}, s.session, s.ctx, s.logger, "")
require.NoError(err)
- require.Equal(session.State, sessionState)
+ require.Equal(s.session.State, sessionState)
require.Len(respByAccountId, 1)
require.Contains(respByAccountId, accountId)
resp := respByAccountId[accountId]
@@ -465,9 +570,9 @@ func TestWithStalwart(t *testing.T) {
}
{
- resp, sessionState, _, err := j.GetAllEmailsInMailbox(accountId, session, ctx, logger, "", inboxId, 0, 0, false, 0)
+ resp, sessionState, _, err := s.client.GetAllEmailsInMailbox(accountId, s.session, s.ctx, s.logger, "", inboxId, 0, 0, false, 0)
require.NoError(err)
- require.Equal(session.State, sessionState)
+ require.Equal(s.session.State, sessionState)
require.Len(resp.Emails, count)
for _, e := range resp.Emails {