package main import ( "bufio" "context" "errors" "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "syscall" "testing" "time" ) // TestMain lets TestSIGTERMDrainsQueuedSigninMail run this binary as // the real process: with CALLS_TEST_RUN_MAIN=1 it is main() and nothing // else, so os.Args are the activation flags the test passes. func TestMain(m *testing.M) { if os.Getenv("CALLS_TEST_RUN_MAIN") == "1" { main() os.Exit(0) } os.Exit(m.Run()) } // TestSIGTERMDrainsQueuedSigninMail: SMTP runs after the response, in // the sign-in mail queue, so Server.Shutdown no longer waits for it. // A link that was already answered ?sent=1 must still go out when the // process is stopped: main has to wait for the queue's bounded drain // before it closes the database and exits (Astra on oficina/meet #25). // // The relay holds the message at DATA until after SIGTERM has arrived, // then accepts it well inside the drain window. The process must exit // cleanly, and only after the relay has the message. func TestSIGTERMDrainsQueuedSigninMail(t *testing.T) { relay := newHeldRelay(t) p := startCalls(t, relay.port) // force=1: an emailed link without asking anyone whether // example.test runs Keymail. form := url.Values{"address": {"paul@example.test"}, "force": {"1"}} req, _ := http.NewRequest(http.MethodPost, p.origin+"/signin", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", p.origin) req.Header.Set("Sec-Fetch-Site", "same-origin") res, err := p.client.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusSeeOther || !strings.Contains(res.Header.Get("Location"), "sent=1") { t.Fatalf("POST /signin: %d %q, want 303 ?sent=1", res.StatusCode, res.Header.Get("Location")) } select { case <-relay.atData: case <-time.After(10 * time.Second): t.Fatal("the queued link never reached the relay") } if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil { t.Fatal(err) } // Long enough that a process which does not wait has gone (Astra's // probe saw it exit in 4 ms), short against the 30 s drain window. select { case err := <-p.exited: t.Fatalf("process exited (%v) while its acknowledged sign-in link was still with the relay", err) case <-time.After(500 * time.Millisecond): } relay.release() p.waitClean(t) if !relay.accepted() { t.Fatal("process exited without the relay accepting the sign-in link") } } // TestSIGTERMDeliversAnInFlightSigninsLink: Server.Shutdown lets a // request it had already accepted finish, so that request's link must // still be queued and sent, not refused by a queue that SIGTERM closed // while the handler was still running (Astra on oficina/meet #25, round // 2: the request answered ?sent=1 and no mail went out). // // The POST's headers and all but the last byte of its body are sent // before SIGTERM, so the handler is in flight, still reading the form. // The last byte follows once the process has logged its shutdown. func TestSIGTERMDeliversAnInFlightSigninsLink(t *testing.T) { relay := newHeldRelay(t) relay.release() // an ordinary relay: this test is about intake p := startCalls(t, relay.port) body := url.Values{"address": {"paul@example.test"}, "force": {"1"}}.Encode() conn, err := net.Dial("tcp", p.addr) if err != nil { t.Fatal(err) } defer conn.Close() head := "POST /signin HTTP/1.1\r\nHost: " + p.addr + "\r\nOrigin: " + p.origin + "\r\nSec-Fetch-Site: same-origin\r\nContent-Type: application/x-www-form-urlencoded\r\n" + "Content-Length: " + strconv.Itoa(len(body)) + "\r\nConnection: close\r\n\r\n" if _, err := conn.Write([]byte(head + body[:len(body)-1])); err != nil { t.Fatal(err) } // Let the server start the handler on what it has. time.Sleep(200 * time.Millisecond) if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil { t.Fatal(err) } until := time.Now().Add(10 * time.Second) for !strings.Contains(p.out.String(), "rastrillo: shutting down") { if time.Now().After(until) { t.Fatal("the process never began shutting down") } time.Sleep(10 * time.Millisecond) } time.Sleep(300 * time.Millisecond) if _, err := conn.Write([]byte(body[len(body)-1:])); err != nil { t.Fatal(err) } _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) res, err := http.ReadResponse(bufio.NewReader(conn), nil) if err != nil { t.Fatalf("the in-flight POST got no answer: %v", err) } res.Body.Close() if res.StatusCode != http.StatusSeeOther || !strings.Contains(res.Header.Get("Location"), "sent=1") { t.Fatalf("in-flight POST /signin: %d %q, want 303 ?sent=1", res.StatusCode, res.Header.Get("Location")) } p.waitClean(t) if !relay.accepted() { t.Fatal("the in-flight request was answered ?sent=1, but the process exited without sending its link") } } // TestSIGTERMDrainsMailWhenHTTPShutdownTimesOut: rastrillo.Serve // returns an error when its ten-second Server.Shutdown deadline passes // with a request still running, and main used to os.Exit(1) on it, // skipping the deferred drain. An unrelated slow request then cost an // already-acknowledged link (Astra on oficina/meet #25, round 3). The // drain, and the database close after it, must run on that path too. // // One sign-in is answered ?sent=1 and held at the relay's DATA; a second // POST never finishes its body, so HTTP shutdown times out. The relay // recovers after the HTTP deadline but well inside the mail drain // window. The process must still be alive then, deliver the link, and // exit 1 for the failed shutdown. func TestSIGTERMDrainsMailWhenHTTPShutdownTimesOut(t *testing.T) { if testing.Short() { t.Skip("waits out rastrillo's ten-second shutdown deadline") } relay := newHeldRelay(t) p := startCalls(t, relay.port) form := url.Values{"address": {"paul@example.test"}, "force": {"1"}} req, _ := http.NewRequest(http.MethodPost, p.origin+"/signin", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", p.origin) req.Header.Set("Sec-Fetch-Site", "same-origin") res, err := p.client.Do(req) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusSeeOther || !strings.Contains(res.Header.Get("Location"), "sent=1") { t.Fatalf("POST /signin: %d %q, want 303 ?sent=1", res.StatusCode, res.Header.Get("Location")) } select { case <-relay.atData: case <-time.After(10 * time.Second): t.Fatal("the queued link never reached the relay") } // A request that never finishes: HTTP shutdown cannot complete. stalled, err := net.Dial("tcp", p.addr) if err != nil { t.Fatal(err) } defer stalled.Close() body := url.Values{"address": {"other@example.test"}, "force": {"1"}}.Encode() if _, err := stalled.Write([]byte("POST /signin HTTP/1.1\r\nHost: " + p.addr + "\r\nOrigin: " + p.origin + "\r\nSec-Fetch-Site: same-origin\r\nContent-Type: application/x-www-form-urlencoded\r\n" + "Content-Length: " + strconv.Itoa(len(body)) + "\r\n\r\n" + body[:len(body)-1])); err != nil { t.Fatal(err) } time.Sleep(200 * time.Millisecond) if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil { t.Fatal(err) } signalled := time.Now() select { case err := <-p.exited: t.Fatalf("process exited (%v) %v after SIGTERM, with an acknowledged link still at the relay", err, time.Since(signalled).Round(time.Millisecond)) case <-time.After(11500 * time.Millisecond): } relay.release() select { case err := <-p.exited: var exit *exec.ExitError if !errors.As(err, &exit) || exit.ExitCode() != 1 { t.Fatalf("process exit: %v, want status 1 for the failed HTTP shutdown", err) } case <-time.After(20 * time.Second): t.Fatal("process did not exit after the relay accepted the message") } if !relay.accepted() { t.Fatal("process exited without the relay accepting the acknowledged sign-in link") } if !strings.Contains(p.out.String(), "context deadline exceeded") { t.Fatal("HTTP shutdown did not time out; the test did not exercise the failed-shutdown path") } } // proc is a Calls process started by startCalls. type proc struct { addr, origin string cmd *exec.Cmd exited chan error out *lockedBuffer client *http.Client } // startCalls runs this test binary as main() in development, mailing // through the relay on relayPort, and waits until it serves. func startCalls(t *testing.T, relayPort string) *proc { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } addr := ln.Addr().String() ln.Close() ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) t.Cleanup(cancel) dir := t.TempDir() cmd := exec.CommandContext(ctx, os.Args[0], "-env", "development", "-addr", addr, "-db", filepath.Join(dir, "calls.db")) cmd.Dir = dir for _, e := range os.Environ() { if strings.HasPrefix(e, "CALLS_") || strings.HasPrefix(e, "CARLOS_") || strings.HasPrefix(e, "HOME_") { continue } cmd.Env = append(cmd.Env, e) } cmd.Env = append(cmd.Env, "CALLS_TEST_RUN_MAIN=1", "CALLS_SMTP_HOST=127.0.0.1", "CALLS_SMTP_PORT="+relayPort, "CALLS_SMTP_FROM=calls@example.test", "CALLS_ALLOWED_ADDRESSES=paul@example.test", ) out := &lockedBuffer{} cmd.Stdout, cmd.Stderr = out, out if err := cmd.Start(); err != nil { t.Fatal(err) } p := &proc{addr: addr, origin: "http://" + addr, cmd: cmd, exited: make(chan error, 1), out: out, client: &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} go func() { p.exited <- cmd.Wait() }() t.Cleanup(func() { _ = cmd.Process.Kill() if t.Failed() { t.Logf("process output:\n%s", out.String()) } }) up := time.Now().Add(20 * time.Second) for { res, err := p.client.Get(p.origin + "/healthz") if err == nil { res.Body.Close() return p } if time.Now().After(up) { t.Fatalf("server never came up: %v", err) } time.Sleep(20 * time.Millisecond) } } // waitClean waits for the process to exit, and requires it to exit 0. func (p *proc) waitClean(t *testing.T) { t.Helper() select { case err := <-p.exited: if err != nil { t.Fatalf("process exit: %v", err) } case <-time.After(20 * time.Second): t.Fatal("process did not exit") } } // lockedBuffer is the process's output, readable while it is written. type lockedBuffer struct { mu sync.Mutex b strings.Builder } func (l *lockedBuffer) Write(p []byte) (int, error) { l.mu.Lock() defer l.mu.Unlock() return l.b.Write(p) } func (l *lockedBuffer) String() string { l.mu.Lock() defer l.mu.Unlock() return l.b.String() } // heldRelay is a plaintext SMTP server on loopback (no STARTTLS, no // AUTH, which mail.SMTP accepts when no credentials are set) that holds // the first message at DATA until release. type heldRelay struct { port string atData chan struct{} hold chan struct{} once sync.Once mu sync.Mutex gotMail bool } func newHeldRelay(t *testing.T) *heldRelay { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } t.Cleanup(func() { ln.Close() }) _, port, _ := net.SplitHostPort(ln.Addr().String()) r := &heldRelay{port: port, atData: make(chan struct{}), hold: make(chan struct{})} t.Cleanup(r.release) go func() { var signalled sync.Once for { c, err := ln.Accept() if err != nil { return } go r.serve(c, &signalled) } }() return r } func (r *heldRelay) release() { r.once.Do(func() { close(r.hold) }) } func (r *heldRelay) accepted() bool { r.mu.Lock() defer r.mu.Unlock() return r.gotMail } func (r *heldRelay) serve(c net.Conn, signalled *sync.Once) { defer c.Close() br := bufio.NewReader(c) say := func(s string) { _, _ = c.Write([]byte(s + "\r\n")) } say("220 relay.test ESMTP") for { line, err := br.ReadString('\n') if err != nil { return } cmd := strings.ToUpper(strings.TrimSpace(line)) switch { case strings.HasPrefix(cmd, "EHLO"): say("250-relay.test") say("250 8BITMIME") case strings.HasPrefix(cmd, "HELO"), strings.HasPrefix(cmd, "MAIL"), strings.HasPrefix(cmd, "RCPT"), strings.HasPrefix(cmd, "RSET"), strings.HasPrefix(cmd, "NOOP"): say("250 OK") case cmd == "DATA": signalled.Do(func() { close(r.atData) }) <-r.hold say("354 go ahead") for { l, err := br.ReadString('\n') if err != nil { return } if l == ".\r\n" || l == ".\n" { break } } r.mu.Lock() r.gotMail = true r.mu.Unlock() say("250 queued") case cmd == "QUIT": say("221 bye") return default: say("502 unrecognised") } } }