1/*2Maddy Mail Server - Composable all-in-one email server.3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors45This program is free software: you can redistribute it and/or modify6it under the terms of the GNU General Public License as published by7the Free Software Foundation, either version 3 of the License, or8(at your option) any later version.910This program is distributed in the hope that it will be useful,11but WITHOUT ANY WARRANTY; without even the implied warranty of12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the13GNU General Public License for more details.1415You should have received a copy of the GNU General Public License16along with this program. If not, see <https://www.gnu.org/licenses/>.17*/1819// Package tests provides the framework for integration testing of maddy.20//21// The packages core object is tests.T object that encapsulates all test22// state. It runs the server using test-provided configuration file and acts as23// a proxy for all interactions with the server.24package tests2526import (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"4142 "github.com/foxcpp/go-mockdns"43)4445var (46 TestBinary = "./maddy"47 CoverageOut string48 DebugLog bool49)5051type T struct {52 *testing.T5354 testDir string55 cfg string5657 dnsServ *mockdns.Server58 env []string59 ports map[string]uint1660 portsRev map[uint16]string6162 servProc *exec.Cmd63}6465func NewT(t *testing.T) *T {66 return &T{67 T: t,68 ports: map[string]uint16{},69 portsRev: map[uint16]string{},70 }71}7273// Config sets the configuration to use for the server. It must be called74// before Run.75func (t *T) Config(cfg string) {76 t.Helper()7778 if t.servProc != nil {79 panic("tests: Config called after Run")80 }8182 t.cfg = cfg83}8485// 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 the88// mockdns server respond with NXDOMAIN to all queries.89func (t *T) DNS(zones map[string]mockdns.Zone) {90 t.Helper()9192 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 }9899 if t.dnsServ != nil {100 t.Log("NOTE: Multiple DNS calls, replacing the server instance...")101 t.dnsServ.Close()102 }103104 dnsServ, err := mockdns.NewServer(zones, false)105 if err != nil {106 t.Fatal("Test configuration failed:", err)107 }108 dnsServ.Log = t109 t.dnsServ = dnsServ110}111112// Port allocates the random TCP port for use by test. It will made accessible113// in the configuration via environment variables with name in the form114// TEST_PORT_name.115//116// If there is a port with name remote_smtp, it will be passed as the value for117// the -debug.smtpport parameter.118func (t *T) Port(name string) uint16 {119 if port := t.ports[name]; port != 0 {120 return port121 }122123 // TODO: Try to bind on port to test its usability.124 port := rand.Int31n(45536) + 20000125 t.ports[name] = uint16(port)126 t.portsRev[uint16(port)] = name127 return uint16(port)128}129130func (t *T) Env(kv string) {131 t.env = append(t.env, kv)132}133134func (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 will140 // respond with NXDOMAIN to all queries to avoid accidentally leaking141 // any DNS queries to the real world.142 t.Log("NOTE: Explicit DNS(nil) is recommended.")143 t.DNS(nil)144145 t.Cleanup(func() {146 // Shutdown the DNS server after maddy to make sure it will not spend time147 // 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 = nil152 })153 }154155 // 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 = testDir162 t.Log("using", t.testDir)163164 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 }170171 t.Cleanup(func() {172 if !t.Failed() {173 return174 }175176 t.Log("removing", t.testDir)177 os.RemoveAll(t.testDir)178 t.testDir = ""179 })180 }181182 configPreable := "state_dir " + filepath.Join(t.testDir, "statedir") + "\n" +183 "runtime_dir " + filepath.Join(t.testDir, "runtimedir") + "\n\n"184185 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}190191func (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 }197198 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"}202203 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 }209210 args = append(args, additionalArgs...)211212 cmd := exec.Command(TestBinary, args...)213214 pwd, err := os.Getwd()215 if err != nil {216 t.Fatal("Test configuration failed:", err)217 }218219 // 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...)230231 return cmd232}233234func (t *T) MustRunCLIGroup(args ...[]string) {235 t.ensureCanRun()236237 wg := sync.WaitGroup{}238 for _, arg := range args {239 wg.Add(1)240 go func() {241 defer wg.Done()242243 _, 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}252253func (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 s259}260261func (t *T) RunCLI(args ...string) (string, error) {262 t.ensureCanRun()263 cmd := t.buildCmd(args...)264265 var stderr, stdout bytes.Buffer266 cmd.Stderr = &stderr267 cmd.Stdout = &stdout268269 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 }274275 t.Log("Stderr:", stderr.String())276277 return stdout.String(), nil278}279280// 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 and283// shutdown the server.284//285// The parameter waitListeners specifies the amount of listeners the server is286// 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")290291 // Capture maddy log and redirect it.292 logOut, err := cmd.StderrPipe()293 if err != nil {294 t.Fatal("Test configuration failed:", err)295 }296297 t.Log("launching maddy", cmd.Args)298 if err := cmd.Start(); err != nil {299 t.Fatal("Test configuration failed:", err)300 }301302 // Log scanning goroutine checks for the "listening" messages and sends 'true'303 // on the channel each time.304 listeningMsg := make(chan bool)305306 go func() {307 defer logOut.Close()308 defer close(listeningMsg)309 scnr := bufio.NewScanner(logOut)310 for scnr.Scan() {311 line := scnr.Text()312313 if strings.Contains(line, "listening on") {314 listeningMsg <- true315 line += " (test runner>listener wait trigger<)"316 }317318 t.Log("maddy:", line)319 }320 if err := scnr.Err(); err != nil {321 t.Log("stderr I/O error:", err)322 }323 }()324325 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 }330331 t.servProc = cmd332333 t.Cleanup(t.killServer)334}335336func (t *T) StateDir() string {337 return filepath.Join(t.testDir, "statedir")338}339340func (t *T) RuntimeDir() string {341 return filepath.Join(t.testDir, "statedir")342}343344func (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 }350351 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:errcheck356 }357 }()358359 if err := t.servProc.Wait(); err != nil {360 t.Error("The server did not stop cleanly, deadlock?")361 }362363 t.servProc = nil364365 if err := os.RemoveAll(t.testDir); err != nil {366 t.Log("Failed to remove test directory:", err)367 }368 t.testDir = ""369}370371func (t *T) Close() {372 t.Log("close is no-op")373}374375// Printf implements Logger interfaces used by some libraries.376func (t *T) Printf(f string, a ...interface{}) {377 t.Logf(f, a...)378}379380// 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 }386387 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 }391392 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}400401// Conn4 connects to the server listener at the specified named port using one402// 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 }408409 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 }416417 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 }427428 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}436437var (438 DefaultSourceIP = net.IPv4(127, 109, 97, 100)439 DefaultSourceIPRev = "100.97.109.127"440)441442func (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 }453454 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}462463func (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 }468469 return t.ConnUnnamed(port)470}471472func (t *T) Subtest(name string, f func(t *T)) {473 t.T.Run(name, func(subTT *testing.T) {474 subT := *t475 subT.T = subTT476 f(&subT)477 })478}479480func 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}