maddy/tests/t.go
2025-01-30 21:50:28 +03:00

484 lines
12 KiB
Go

/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package tests provides the framework for integration testing of maddy.
//
// The packages core object is tests.T object that encapsulates all test
// state. It runs the server using test-provided configuration file and acts as
// a proxy for all interactions with the server.
package tests
import (
"bufio"
"bytes"
"flag"
"fmt"
"math/rand"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/foxcpp/go-mockdns"
)
var (
TestBinary = "./maddy"
CoverageOut string
DebugLog bool
)
type T struct {
*testing.T
testDir string
cfg string
dnsServ *mockdns.Server
env []string
ports map[string]uint16
portsRev map[uint16]string
servProc *exec.Cmd
}
func NewT(t *testing.T) *T {
return &T{
T: t,
ports: map[string]uint16{},
portsRev: map[uint16]string{},
}
}
// Config sets the configuration to use for the server. It must be called
// before Run.
func (t *T) Config(cfg string) {
t.Helper()
if t.servProc != nil {
panic("tests: Config called after Run")
}
t.cfg = cfg
}
// DNS sets the DNS zones to emulate for the tested server instance.
//
// If it is not called before Run, DNS(nil) call is assumed which makes the
// mockdns server respond with NXDOMAIN to all queries.
func (t *T) DNS(zones map[string]mockdns.Zone) {
t.Helper()
if zones == nil {
zones = map[string]mockdns.Zone{}
}
if _, ok := zones["100.97.109.127.in-addr.arpa."]; !ok {
zones["100.97.109.127.in-addr.arpa."] = mockdns.Zone{PTR: []string{"client.maddy.test."}}
}
if t.dnsServ != nil {
t.Log("NOTE: Multiple DNS calls, replacing the server instance...")
t.dnsServ.Close()
}
dnsServ, err := mockdns.NewServer(zones, false)
if err != nil {
t.Fatal("Test configuration failed:", err)
}
dnsServ.Log = t
t.dnsServ = dnsServ
}
// Port allocates the random TCP port for use by test. It will made accessible
// in the configuration via environment variables with name in the form
// TEST_PORT_name.
//
// If there is a port with name remote_smtp, it will be passed as the value for
// the -debug.smtpport parameter.
func (t *T) Port(name string) uint16 {
if port := t.ports[name]; port != 0 {
return port
}
// TODO: Try to bind on port to test its usability.
port := rand.Int31n(45536) + 20000
t.ports[name] = uint16(port)
t.portsRev[uint16(port)] = name
return uint16(port)
}
func (t *T) Env(kv string) {
t.env = append(t.env, kv)
}
func (t *T) ensureCanRun() {
if t.cfg == "" {
panic("tests: Run called without configuration set")
}
if t.dnsServ == nil {
// If there is no DNS zones set in test - start a server that will
// respond with NXDOMAIN to all queries to avoid accidentally leaking
// any DNS queries to the real world.
t.Log("NOTE: Explicit DNS(nil) is recommended.")
t.DNS(nil)
t.Cleanup(func() {
// Shutdown the DNS server after maddy to make sure it will not spend time
// timing out queries.
if err := t.dnsServ.Close(); err != nil {
t.Log("Unable to stop the DNS server:", err)
}
t.dnsServ = nil
})
}
// Setup file system, create statedir, runtimedir, write out config.
if t.testDir == "" {
testDir, err := os.MkdirTemp("", "maddy-tests-")
if err != nil {
t.Fatal("Test configuration failed:", err)
}
t.testDir = testDir
t.Log("using", t.testDir)
if err := os.MkdirAll(filepath.Join(t.testDir, "statedir"), os.ModePerm); err != nil {
t.Fatal("Test configuration failed:", err)
}
if err := os.MkdirAll(filepath.Join(t.testDir, "runtimedir"), os.ModePerm); err != nil {
t.Fatal("Test configuration failed:", err)
}
t.Cleanup(func() {
if !t.Failed() {
return
}
t.Log("removing", t.testDir)
os.RemoveAll(t.testDir)
t.testDir = ""
})
}
configPreable := "state_dir " + filepath.Join(t.testDir, "statedir") + "\n" +
"runtime_dir " + filepath.Join(t.testDir, "runtimedir") + "\n\n"
err := os.WriteFile(filepath.Join(t.testDir, "maddy.conf"), []byte(configPreable+t.cfg), os.ModePerm)
if err != nil {
t.Fatal("Test configuration failed:", err)
}
}
func (t *T) buildCmd(additionalArgs ...string) *exec.Cmd {
// Assigning 0 by default will make outbound SMTP unusable.
remoteSmtp := "0"
if port := t.ports["remote_smtp"]; port != 0 {
remoteSmtp = strconv.Itoa(int(port))
}
args := []string{"-config", filepath.Join(t.testDir, "maddy.conf"),
"-debug.smtpport", remoteSmtp,
"-debug.dnsoverride", t.dnsServ.LocalAddr().String(),
"-log", "/tmp/test.log"}
if CoverageOut != "" {
args = append(args, "-test.coverprofile", CoverageOut+"."+strconv.FormatInt(time.Now().UnixNano(), 16))
}
if DebugLog {
args = append(args, "-debug")
}
args = append(args, additionalArgs...)
cmd := exec.Command(TestBinary, args...)
pwd, err := os.Getwd()
if err != nil {
t.Fatal("Test configuration failed:", err)
}
// Set environment variables.
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env,
"TEST_PWD="+pwd,
"TEST_STATE_DIR="+filepath.Join(t.testDir, "statedir"),
"TEST_RUNTIME_DIR="+filepath.Join(t.testDir, "runtimedir"),
)
for name, port := range t.ports {
cmd.Env = append(cmd.Env, fmt.Sprintf("TEST_PORT_%s=%d", name, port))
}
cmd.Env = append(cmd.Env, t.env...)
return cmd
}
func (t *T) MustRunCLIGroup(args ...[]string) {
t.ensureCanRun()
wg := sync.WaitGroup{}
for _, arg := range args {
wg.Add(1)
go func() {
defer wg.Done()
_, err := t.RunCLI(arg...)
if err != nil {
t.Printf("maddy %v: %v", arg, err)
t.Fail()
}
}()
}
wg.Wait()
}
func (t *T) MustRunCLI(args ...string) string {
s, err := t.RunCLI(args...)
if err != nil {
t.Fatalf("maddy %v: %v", args, err)
}
return s
}
func (t *T) RunCLI(args ...string) (string, error) {
t.ensureCanRun()
cmd := t.buildCmd(args...)
var stderr, stdout bytes.Buffer
cmd.Stderr = &stderr
cmd.Stdout = &stdout
t.Log("launching maddy", cmd.Args)
if err := cmd.Run(); err != nil {
t.Log("Stderr:", stderr.String())
t.Fatal("Test configuration failed:", err)
}
t.Log("Stderr:", stderr.String())
return stdout.String(), nil
}
// Run completes the configuration of test environment and starts the test server.
//
// T.Close should be called by the end of test to release any resources and
// shutdown the server.
//
// The parameter waitListeners specifies the amount of listeners the server is
// supposed to configure. Run() will block before all of them are up.
func (t *T) Run(waitListeners int) {
t.ensureCanRun()
cmd := t.buildCmd("run")
// Capture maddy log and redirect it.
logOut, err := cmd.StderrPipe()
if err != nil {
t.Fatal("Test configuration failed:", err)
}
t.Log("launching maddy", cmd.Args)
if err := cmd.Start(); err != nil {
t.Fatal("Test configuration failed:", err)
}
// Log scanning goroutine checks for the "listening" messages and sends 'true'
// on the channel each time.
listeningMsg := make(chan bool)
go func() {
defer logOut.Close()
defer close(listeningMsg)
scnr := bufio.NewScanner(logOut)
for scnr.Scan() {
line := scnr.Text()
if strings.Contains(line, "listening on") {
listeningMsg <- true
line += " (test runner>listener wait trigger<)"
}
t.Log("maddy:", line)
}
if err := scnr.Err(); err != nil {
t.Log("stderr I/O error:", err)
}
}()
for i := 0; i < waitListeners; i++ {
if !<-listeningMsg {
t.Fatal("Log ended before all expected listeners are up. Start-up error?")
}
}
t.servProc = cmd
t.Cleanup(t.killServer)
}
func (t *T) StateDir() string {
return filepath.Join(t.testDir, "statedir")
}
func (t *T) RuntimeDir() string {
return filepath.Join(t.testDir, "statedir")
}
func (t *T) killServer() {
if err := t.servProc.Process.Signal(os.Interrupt); err != nil {
t.Log("Unable to kill the server process:", err)
os.RemoveAll(t.testDir)
return // Return, as now it is pointless to wait for it.
}
go func() {
time.Sleep(5 * time.Second)
if t.servProc != nil {
t.Log("Killing possibly hung server process")
t.servProc.Process.Kill() //nolint:errcheck
}
}()
if err := t.servProc.Wait(); err != nil {
t.Error("The server did not stop cleanly, deadlock?")
}
t.servProc = nil
if err := os.RemoveAll(t.testDir); err != nil {
t.Log("Failed to remove test directory:", err)
}
t.testDir = ""
}
func (t *T) Close() {
t.Log("close is no-op")
}
// Printf implements Logger interfaces used by some libraries.
func (t *T) Printf(f string, a ...interface{}) {
t.Logf(f, a...)
}
// Conn6 connects to the server listener at the specified named port using IPv6 loopback.
func (t *T) Conn6(portName string) Conn {
port := t.ports[portName]
if port == 0 {
panic("tests: connection for the unused port name is requested")
}
conn, err := net.Dial("tcp6", "[::1]:"+strconv.Itoa(int(port)))
if err != nil {
t.Fatal("Could not connect, is server listening?", err)
}
return Conn{
T: t,
WriteTimeout: 1 * time.Second,
ReadTimeout: 15 * time.Second,
Conn: conn,
Scanner: bufio.NewScanner(conn),
}
}
// Conn4 connects to the server listener at the specified named port using one
// of 127.0.0.0/8 addresses as a source.
func (t *T) Conn4(sourceIP, portName string) Conn {
port := t.ports[portName]
if port == 0 {
panic("tests: connection for the unused port name is requested")
}
localIP := net.ParseIP(sourceIP)
if localIP == nil {
panic("tests: invalid localIP argument")
}
if localIP.To4() == nil {
panic("tests: only IPv4 addresses are allowed")
}
conn, err := net.DialTCP("tcp4", &net.TCPAddr{
IP: localIP,
Port: 0,
}, &net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
Port: int(port),
})
if err != nil {
t.Fatal("Could not connect, is server listening?", err)
}
return Conn{
T: t,
WriteTimeout: 1 * time.Second,
ReadTimeout: 15 * time.Second,
Conn: conn,
Scanner: bufio.NewScanner(conn),
}
}
var (
DefaultSourceIP = net.IPv4(127, 109, 97, 100)
DefaultSourceIPRev = "100.97.109.127"
)
func (t *T) ConnUnnamed(port uint16) Conn {
conn, err := net.DialTCP("tcp4", &net.TCPAddr{
IP: DefaultSourceIP,
Port: 0,
}, &net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
Port: int(port),
})
if err != nil {
t.Fatal("Could not connect, is server listening?", err)
}
return Conn{
T: t,
WriteTimeout: 1 * time.Second,
ReadTimeout: 15 * time.Second,
Conn: conn,
Scanner: bufio.NewScanner(conn),
}
}
func (t *T) Conn(portName string) Conn {
port := t.ports[portName]
if port == 0 {
panic("tests: connection for the unused port name is requested")
}
return t.ConnUnnamed(port)
}
func (t *T) Subtest(name string, f func(t *T)) {
t.T.Run(name, func(subTT *testing.T) {
subT := *t
subT.T = subTT
f(&subT)
})
}
func init() {
flag.StringVar(&TestBinary, "integration.executable", "./maddy", "executable to test")
flag.StringVar(&CoverageOut, "integration.coverprofile", "", "write coverage stats to file (requires special maddy executable)")
flag.BoolVar(&DebugLog, "integration.debug", false, "pass -debug to maddy executable")
}