maddy

git clone git://git.lin.moe/fmaddy/maddy.git

  1/*
  2Maddy Mail Server - Composable all-in-one email server.
  3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
  4
  5This program is free software: you can redistribute it and/or modify
  6it under the terms of the GNU General Public License as published by
  7the Free Software Foundation, either version 3 of the License, or
  8(at your option) any later version.
  9
 10This program is distributed in the hope that it will be useful,
 11but WITHOUT ANY WARRANTY; without even the implied warranty of
 12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 13GNU General Public License for more details.
 14
 15You should have received a copy of the GNU General Public License
 16along with this program.  If not, see <https://www.gnu.org/licenses/>.
 17*/
 18
 19// Package tests provides the framework for integration testing of maddy.
 20//
 21// The packages core object is tests.T object that encapsulates all test
 22// state. It runs the server using test-provided configuration file and acts as
 23// a proxy for all interactions with the server.
 24package tests
 25
 26import (
 27	"bufio"
 28	"bytes"
 29	"flag"
 30	"fmt"
 31	"math/rand"
 32	"net"
 33	"os"
 34	"os/exec"
 35	"path/filepath"
 36	"strconv"
 37	"strings"
 38	"sync"
 39	"testing"
 40	"time"
 41
 42	"github.com/foxcpp/go-mockdns"
 43)
 44
 45var (
 46	TestBinary  = "./maddy"
 47	CoverageOut string
 48	DebugLog    bool
 49)
 50
 51type T struct {
 52	*testing.T
 53
 54	testDir string
 55	cfg     string
 56
 57	dnsServ  *mockdns.Server
 58	env      []string
 59	ports    map[string]uint16
 60	portsRev map[uint16]string
 61
 62	servProc *exec.Cmd
 63}
 64
 65func NewT(t *testing.T) *T {
 66	return &T{
 67		T:        t,
 68		ports:    map[string]uint16{},
 69		portsRev: map[uint16]string{},
 70	}
 71}
 72
 73// Config sets the configuration to use for the server. It must be called
 74// before Run.
 75func (t *T) Config(cfg string) {
 76	t.Helper()
 77
 78	if t.servProc != nil {
 79		panic("tests: Config called after Run")
 80	}
 81
 82	t.cfg = cfg
 83}
 84
 85// DNS sets the DNS zones to emulate for the tested server instance.
 86//
 87// If it is not called before Run, DNS(nil) call is assumed which makes the
 88// mockdns server respond with NXDOMAIN to all queries.
 89func (t *T) DNS(zones map[string]mockdns.Zone) {
 90	t.Helper()
 91
 92	if zones == nil {
 93		zones = map[string]mockdns.Zone{}
 94	}
 95	if _, ok := zones["100.97.109.127.in-addr.arpa."]; !ok {
 96		zones["100.97.109.127.in-addr.arpa."] = mockdns.Zone{PTR: []string{"client.maddy.test."}}
 97	}
 98
 99	if t.dnsServ != nil {
100		t.Log("NOTE: Multiple DNS calls, replacing the server instance...")
101		t.dnsServ.Close()
102	}
103
104	dnsServ, err := mockdns.NewServer(zones, false)
105	if err != nil {
106		t.Fatal("Test configuration failed:", err)
107	}
108	dnsServ.Log = t
109	t.dnsServ = dnsServ
110}
111
112// Port allocates the random TCP port for use by test. It will made accessible
113// in the configuration via environment variables with name in the form
114// TEST_PORT_name.
115//
116// If there is a port with name remote_smtp, it will be passed as the value for
117// the -debug.smtpport parameter.
118func (t *T) Port(name string) uint16 {
119	if port := t.ports[name]; port != 0 {
120		return port
121	}
122
123	// TODO: Try to bind on port to test its usability.
124	port := rand.Int31n(45536) + 20000
125	t.ports[name] = uint16(port)
126	t.portsRev[uint16(port)] = name
127	return uint16(port)
128}
129
130func (t *T) Env(kv string) {
131	t.env = append(t.env, kv)
132}
133
134func (t *T) ensureCanRun() {
135	if t.cfg == "" {
136		panic("tests: Run called without configuration set")
137	}
138	if t.dnsServ == nil {
139		// If there is no DNS zones set in test - start a server that will
140		// respond with NXDOMAIN to all queries to avoid accidentally leaking
141		// any DNS queries to the real world.
142		t.Log("NOTE: Explicit DNS(nil) is recommended.")
143		t.DNS(nil)
144
145		t.Cleanup(func() {
146			// Shutdown the DNS server after maddy to make sure it will not spend time
147			// timing out queries.
148			if err := t.dnsServ.Close(); err != nil {
149				t.Log("Unable to stop the DNS server:", err)
150			}
151			t.dnsServ = nil
152		})
153	}
154
155	// Setup file system, create statedir, runtimedir, write out config.
156	if t.testDir == "" {
157		testDir, err := os.MkdirTemp("", "maddy-tests-")
158		if err != nil {
159			t.Fatal("Test configuration failed:", err)
160		}
161		t.testDir = testDir
162		t.Log("using", t.testDir)
163
164		if err := os.MkdirAll(filepath.Join(t.testDir, "statedir"), os.ModePerm); err != nil {
165			t.Fatal("Test configuration failed:", err)
166		}
167		if err := os.MkdirAll(filepath.Join(t.testDir, "runtimedir"), os.ModePerm); err != nil {
168			t.Fatal("Test configuration failed:", err)
169		}
170
171		t.Cleanup(func() {
172			if !t.Failed() {
173				return
174			}
175
176			t.Log("removing", t.testDir)
177			os.RemoveAll(t.testDir)
178			t.testDir = ""
179		})
180	}
181
182	configPreable := "state_dir " + filepath.Join(t.testDir, "statedir") + "\n" +
183		"runtime_dir " + filepath.Join(t.testDir, "runtimedir") + "\n\n"
184
185	err := os.WriteFile(filepath.Join(t.testDir, "maddy.conf"), []byte(configPreable+t.cfg), os.ModePerm)
186	if err != nil {
187		t.Fatal("Test configuration failed:", err)
188	}
189}
190
191func (t *T) buildCmd(additionalArgs ...string) *exec.Cmd {
192	// Assigning 0 by default will make outbound SMTP unusable.
193	remoteSmtp := "0"
194	if port := t.ports["remote_smtp"]; port != 0 {
195		remoteSmtp = strconv.Itoa(int(port))
196	}
197
198	args := []string{"-config", filepath.Join(t.testDir, "maddy.conf"),
199		"-debug.smtpport", remoteSmtp,
200		"-debug.dnsoverride", t.dnsServ.LocalAddr().String(),
201		"-log", "/tmp/test.log"}
202
203	if CoverageOut != "" {
204		args = append(args, "-test.coverprofile", CoverageOut+"."+strconv.FormatInt(time.Now().UnixNano(), 16))
205	}
206	if DebugLog {
207		args = append(args, "-debug")
208	}
209
210	args = append(args, additionalArgs...)
211
212	cmd := exec.Command(TestBinary, args...)
213
214	pwd, err := os.Getwd()
215	if err != nil {
216		t.Fatal("Test configuration failed:", err)
217	}
218
219	// Set environment variables.
220	cmd.Env = os.Environ()
221	cmd.Env = append(cmd.Env,
222		"TEST_PWD="+pwd,
223		"TEST_STATE_DIR="+filepath.Join(t.testDir, "statedir"),
224		"TEST_RUNTIME_DIR="+filepath.Join(t.testDir, "runtimedir"),
225	)
226	for name, port := range t.ports {
227		cmd.Env = append(cmd.Env, fmt.Sprintf("TEST_PORT_%s=%d", name, port))
228	}
229	cmd.Env = append(cmd.Env, t.env...)
230
231	return cmd
232}
233
234func (t *T) MustRunCLIGroup(args ...[]string) {
235	t.ensureCanRun()
236
237	wg := sync.WaitGroup{}
238	for _, arg := range args {
239		wg.Add(1)
240		go func() {
241			defer wg.Done()
242
243			_, err := t.RunCLI(arg...)
244			if err != nil {
245				t.Printf("maddy %v: %v", arg, err)
246				t.Fail()
247			}
248		}()
249	}
250	wg.Wait()
251}
252
253func (t *T) MustRunCLI(args ...string) string {
254	s, err := t.RunCLI(args...)
255	if err != nil {
256		t.Fatalf("maddy %v: %v", args, err)
257	}
258	return s
259}
260
261func (t *T) RunCLI(args ...string) (string, error) {
262	t.ensureCanRun()
263	cmd := t.buildCmd(args...)
264
265	var stderr, stdout bytes.Buffer
266	cmd.Stderr = &stderr
267	cmd.Stdout = &stdout
268
269	t.Log("launching maddy", cmd.Args)
270	if err := cmd.Run(); err != nil {
271		t.Log("Stderr:", stderr.String())
272		t.Fatal("Test configuration failed:", err)
273	}
274
275	t.Log("Stderr:", stderr.String())
276
277	return stdout.String(), nil
278}
279
280// Run completes the configuration of test environment and starts the test server.
281//
282// T.Close should be called by the end of test to release any resources and
283// shutdown the server.
284//
285// The parameter waitListeners specifies the amount of listeners the server is
286// supposed to configure. Run() will block before all of them are up.
287func (t *T) Run(waitListeners int) {
288	t.ensureCanRun()
289	cmd := t.buildCmd("run")
290
291	// Capture maddy log and redirect it.
292	logOut, err := cmd.StderrPipe()
293	if err != nil {
294		t.Fatal("Test configuration failed:", err)
295	}
296
297	t.Log("launching maddy", cmd.Args)
298	if err := cmd.Start(); err != nil {
299		t.Fatal("Test configuration failed:", err)
300	}
301
302	// Log scanning goroutine checks for the "listening" messages and sends 'true'
303	// on the channel each time.
304	listeningMsg := make(chan bool)
305
306	go func() {
307		defer logOut.Close()
308		defer close(listeningMsg)
309		scnr := bufio.NewScanner(logOut)
310		for scnr.Scan() {
311			line := scnr.Text()
312
313			if strings.Contains(line, "listening on") {
314				listeningMsg <- true
315				line += " (test runner>listener wait trigger<)"
316			}
317
318			t.Log("maddy:", line)
319		}
320		if err := scnr.Err(); err != nil {
321			t.Log("stderr I/O error:", err)
322		}
323	}()
324
325	for i := 0; i < waitListeners; i++ {
326		if !<-listeningMsg {
327			t.Fatal("Log ended before all expected listeners are up. Start-up error?")
328		}
329	}
330
331	t.servProc = cmd
332
333	t.Cleanup(t.killServer)
334}
335
336func (t *T) StateDir() string {
337	return filepath.Join(t.testDir, "statedir")
338}
339
340func (t *T) RuntimeDir() string {
341	return filepath.Join(t.testDir, "statedir")
342}
343
344func (t *T) killServer() {
345	if err := t.servProc.Process.Signal(os.Interrupt); err != nil {
346		t.Log("Unable to kill the server process:", err)
347		os.RemoveAll(t.testDir)
348		return // Return, as now it is pointless to wait for it.
349	}
350
351	go func() {
352		time.Sleep(5 * time.Second)
353		if t.servProc != nil {
354			t.Log("Killing possibly hung server process")
355			t.servProc.Process.Kill() //nolint:errcheck
356		}
357	}()
358
359	if err := t.servProc.Wait(); err != nil {
360		t.Error("The server did not stop cleanly, deadlock?")
361	}
362
363	t.servProc = nil
364
365	if err := os.RemoveAll(t.testDir); err != nil {
366		t.Log("Failed to remove test directory:", err)
367	}
368	t.testDir = ""
369}
370
371func (t *T) Close() {
372	t.Log("close is no-op")
373}
374
375// Printf implements Logger interfaces used by some libraries.
376func (t *T) Printf(f string, a ...interface{}) {
377	t.Logf(f, a...)
378}
379
380// Conn6 connects to the server listener at the specified named port using IPv6 loopback.
381func (t *T) Conn6(portName string) Conn {
382	port := t.ports[portName]
383	if port == 0 {
384		panic("tests: connection for the unused port name is requested")
385	}
386
387	conn, err := net.Dial("tcp6", "[::1]:"+strconv.Itoa(int(port)))
388	if err != nil {
389		t.Fatal("Could not connect, is server listening?", err)
390	}
391
392	return Conn{
393		T:            t,
394		WriteTimeout: 1 * time.Second,
395		ReadTimeout:  15 * time.Second,
396		Conn:         conn,
397		Scanner:      bufio.NewScanner(conn),
398	}
399}
400
401// Conn4 connects to the server listener at the specified named port using one
402// of 127.0.0.0/8 addresses as a source.
403func (t *T) Conn4(sourceIP, portName string) Conn {
404	port := t.ports[portName]
405	if port == 0 {
406		panic("tests: connection for the unused port name is requested")
407	}
408
409	localIP := net.ParseIP(sourceIP)
410	if localIP == nil {
411		panic("tests: invalid localIP argument")
412	}
413	if localIP.To4() == nil {
414		panic("tests: only IPv4 addresses are allowed")
415	}
416
417	conn, err := net.DialTCP("tcp4", &net.TCPAddr{
418		IP:   localIP,
419		Port: 0,
420	}, &net.TCPAddr{
421		IP:   net.IPv4(127, 0, 0, 1),
422		Port: int(port),
423	})
424	if err != nil {
425		t.Fatal("Could not connect, is server listening?", err)
426	}
427
428	return Conn{
429		T:            t,
430		WriteTimeout: 1 * time.Second,
431		ReadTimeout:  15 * time.Second,
432		Conn:         conn,
433		Scanner:      bufio.NewScanner(conn),
434	}
435}
436
437var (
438	DefaultSourceIP    = net.IPv4(127, 109, 97, 100)
439	DefaultSourceIPRev = "100.97.109.127"
440)
441
442func (t *T) ConnUnnamed(port uint16) Conn {
443	conn, err := net.DialTCP("tcp4", &net.TCPAddr{
444		IP:   DefaultSourceIP,
445		Port: 0,
446	}, &net.TCPAddr{
447		IP:   net.IPv4(127, 0, 0, 1),
448		Port: int(port),
449	})
450	if err != nil {
451		t.Fatal("Could not connect, is server listening?", err)
452	}
453
454	return Conn{
455		T:            t,
456		WriteTimeout: 1 * time.Second,
457		ReadTimeout:  15 * time.Second,
458		Conn:         conn,
459		Scanner:      bufio.NewScanner(conn),
460	}
461}
462
463func (t *T) Conn(portName string) Conn {
464	port := t.ports[portName]
465	if port == 0 {
466		panic("tests: connection for the unused port name is requested")
467	}
468
469	return t.ConnUnnamed(port)
470}
471
472func (t *T) Subtest(name string, f func(t *T)) {
473	t.T.Run(name, func(subTT *testing.T) {
474		subT := *t
475		subT.T = subTT
476		f(&subT)
477	})
478}
479
480func init() {
481	flag.StringVar(&TestBinary, "integration.executable", "./maddy", "executable to test")
482	flag.StringVar(&CoverageOut, "integration.coverprofile", "", "write coverage stats to file (requires special maddy executable)")
483	flag.BoolVar(&DebugLog, "integration.debug", false, "pass -debug to maddy executable")
484}