255 lines
7.5 KiB
Go
255 lines
7.5 KiB
Go
package main
|
|
|
|
// What the tests stand on: an IMAP server in this process (go-imap's own in-memory server, speaking the protocol over
|
|
// a real socket), the index in memory always and on a real postgres when one can be had (MESH_TEST_POSTGRES, or a
|
|
// private server started here from the machine's initdb on a socket of its own, listening on no port), and the
|
|
// attachment store in memory.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/emersion/go-imap/v2"
|
|
"github.com/emersion/go-imap/v2/imapclient"
|
|
"github.com/emersion/go-imap/v2/imapserver"
|
|
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
|
|
|
|
"mesh-mail/cmd/mesh-mail/internal/secret"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
code := m.Run()
|
|
stopPostgres()
|
|
os.Exit(code)
|
|
}
|
|
|
|
// ---- the IMAP server --------------------------------------------------------------------------------------------
|
|
|
|
const (
|
|
testUser = "operator@example.org"
|
|
testPassword = "the-password"
|
|
)
|
|
|
|
type mailServer struct {
|
|
addr string
|
|
srv *imapserver.Server
|
|
}
|
|
|
|
// newMailServer is a server holding one user with an inbox and an Archive folder.
|
|
func newMailServer(t *testing.T) *mailServer {
|
|
t.Helper()
|
|
mem := imapmemserver.New()
|
|
user := imapmemserver.NewUser(testUser, testPassword)
|
|
for _, f := range []string{"INBOX", "Archive"} {
|
|
if err := user.Create(f, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
mem.AddUser(user)
|
|
srv := imapserver.New(&imapserver.Options{
|
|
NewSession: func(*imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
|
return mem.NewSession(), nil, nil
|
|
},
|
|
InsecureAuth: true,
|
|
Caps: imap.CapSet{imap.CapIMAP4rev1: {}, imap.CapIMAP4rev2: {}},
|
|
})
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go func() { _ = srv.Serve(ln) }()
|
|
t.Cleanup(func() { _ = srv.Close() })
|
|
return &mailServer{addr: ln.Addr().String(), srv: srv}
|
|
}
|
|
|
|
// dialer reaches the test server without TLS, which only a test does: the mesh's dialer signs in over TLS alone.
|
|
func (s *mailServer) dialer() Dialer {
|
|
return func(_ Account, h *imapclient.UnilateralDataHandler) (reader, error) {
|
|
c, err := imapclient.DialInsecure(s.addr, &imapclient.Options{UnilateralDataHandler: h})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c, nil
|
|
}
|
|
}
|
|
|
|
// operator is a second client, as the operator's mail program or another sender is: it appends, flags and deletes.
|
|
func (s *mailServer) operator(t *testing.T) *imapclient.Client {
|
|
t.Helper()
|
|
c, err := imapclient.DialInsecure(s.addr, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
if err := c.Login(testUser, testPassword).Wait(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func deliver(t *testing.T, c *imapclient.Client, folder, raw string) {
|
|
t.Helper()
|
|
raw = strings.ReplaceAll(raw, "\n", "\r\n")
|
|
cmd := c.Append(folder, int64(len(raw)), nil)
|
|
if _, err := cmd.Write([]byte(raw)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmd.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cmd.Wait(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func plainMail(subject, body string) string {
|
|
return "From: Alice <alice@example.org>\nTo: Operator <operator@example.org>\nSubject: " + subject +
|
|
"\nMessage-Id: <" + strings.ReplaceAll(subject, " ", "-") + "@example.org>\nDate: Wed, 14 Oct 2026 10:00:00 +0000\n" +
|
|
"MIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n" + body + "\n"
|
|
}
|
|
|
|
// attachmentData is an attachment's content, and attachedMail a message carrying it.
|
|
const attachmentData = "the invoice, in full"
|
|
|
|
func attachedMail(subject string) string {
|
|
return "From: Bob <bob@example.org>\nTo: operator@example.org\nSubject: " + subject + "\nMessage-Id: <att@example.org>\n" +
|
|
"Date: Wed, 14 Oct 2026 11:00:00 +0000\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=XYZ\n\n" +
|
|
"--XYZ\nContent-Type: text/plain; charset=utf-8\n\nPlease find the invoice attached.\n" +
|
|
"--XYZ\nContent-Type: application/pdf; name=invoice.pdf\nContent-Disposition: attachment; filename=invoice.pdf\n" +
|
|
"Content-Transfer-Encoding: base64\n\ndGhlIGludm9pY2UsIGluIGZ1bGw=\n--XYZ--\n"
|
|
}
|
|
|
|
// ---- the account ------------------------------------------------------------------------------------------------
|
|
|
|
func testAccount() Account {
|
|
return Account{Name: "mesh", Kind: KindIMAP, Address: testUser, Incoming: Server{Host: "localhost", Port: 993, Security: "tls"}}
|
|
}
|
|
|
|
func giveSecret(t *testing.T, dir string, a Account, pw string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(secret.Path(dir, a.Secret()), []byte(pw+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// eventually waits for a condition, failing with what it last saw.
|
|
func eventually(t *testing.T, within time.Duration, what string, ok func() (bool, string)) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(within)
|
|
last := ""
|
|
for time.Now().Before(deadline) {
|
|
var done bool
|
|
if done, last = ok(); done {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatalf("%s did not happen within %s; last seen: %s", what, within, last)
|
|
}
|
|
|
|
// ---- postgres ---------------------------------------------------------------------------------------------------
|
|
|
|
var (
|
|
pgOnce sync.Once
|
|
pgBase string
|
|
pgDir string
|
|
pgWhy string
|
|
pgCount int
|
|
pgMu sync.Mutex
|
|
)
|
|
|
|
func startPostgres() {
|
|
if u := os.Getenv("MESH_TEST_POSTGRES"); u != "" {
|
|
pgBase = u
|
|
return
|
|
}
|
|
initdb, err := exec.LookPath("initdb")
|
|
if err != nil {
|
|
pgWhy = "no postgres: MESH_TEST_POSTGRES is unset and the machine has no initdb"
|
|
return
|
|
}
|
|
pgctl := filepath.Join(filepath.Dir(initdb), "pg_ctl")
|
|
dir, err := os.MkdirTemp("", "mmpg")
|
|
if err != nil {
|
|
pgWhy = err.Error()
|
|
return
|
|
}
|
|
pgDir = dir
|
|
if out, err := exec.Command(initdb, "-D", dir+"/data", "-U", "postgres", "-A", "trust", "-E", "UTF8", "--no-locale").CombinedOutput(); err != nil {
|
|
pgWhy = fmt.Sprintf("initdb: %v: %s", err, out)
|
|
return
|
|
}
|
|
if out, err := exec.Command(pgctl, "-D", dir+"/data", "-l", dir+"/log", "-w", "-o",
|
|
"-k "+dir+" -c listen_addresses='' -F", "start").CombinedOutput(); err != nil {
|
|
pgWhy = fmt.Sprintf("pg_ctl start: %v: %s", err, out)
|
|
return
|
|
}
|
|
pgBase = "postgres://postgres@/postgres?host=" + dir
|
|
}
|
|
|
|
func stopPostgres() {
|
|
if pgDir == "" {
|
|
return
|
|
}
|
|
if initdb, err := exec.LookPath("initdb"); err == nil {
|
|
_ = exec.Command(filepath.Join(filepath.Dir(initdb), "pg_ctl"), "-D", pgDir+"/data", "-m", "immediate", "stop").Run()
|
|
}
|
|
_ = os.RemoveAll(pgDir)
|
|
}
|
|
|
|
// freshDatabase is a database of its own for one test, or a skip saying why there is none.
|
|
func freshDatabase(t *testing.T) string {
|
|
t.Helper()
|
|
pgOnce.Do(startPostgres)
|
|
if pgBase == "" {
|
|
t.Skipf("NOT TESTED ON POSTGRES: %s", pgWhy)
|
|
}
|
|
pgMu.Lock()
|
|
pgCount++
|
|
name := fmt.Sprintf("mail_%d_%d", os.Getpid(), pgCount)
|
|
pgMu.Unlock()
|
|
ctx := context.Background()
|
|
admin, err := OpenPgStore(ctx, pgBase)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer admin.Close()
|
|
if _, err := admin.pool.Exec(ctx, "create database "+name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u, err := url.Parse(pgBase)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u.Path = "/" + name
|
|
return u.String()
|
|
}
|
|
|
|
func pgStore(t *testing.T) *PgStore {
|
|
t.Helper()
|
|
s, err := OpenPgStore(context.Background(), freshDatabase(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(s.Close)
|
|
if _, err := s.Prepare(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// eachStore runs a test over the index in memory and on postgres.
|
|
func eachStore(t *testing.T, run func(t *testing.T, s Store)) {
|
|
t.Run("memory", func(t *testing.T) { run(t, newMemStore()) })
|
|
t.Run("postgres", func(t *testing.T) { run(t, pgStore(t)) })
|
|
}
|