c8b067a686
Separate test/e2e Go module drives the shipped deploy/docker-compose.yml (plus a test-only override: self-signed cert, low ports, isolated compose project) against a fake DNS zone (CoreDNS) and an smtp-sink MX, exactly as an administrator and their applications would over HTTP/SMTP — covering the class of failure unit tests can't see (container wiring). Positive path: setup -> login -> domain -> DKIM record published into the fake zone -> application -> SMTP AUTH send -> DKIM verified against the DNS-published key -> send-log queued->sent. Negative: no-AUTH/unauthenticated relay, sender/login mismatch, L1 (anvil) and L2 (panel) rate limits, journal-milter fail-open, SELFPOST_HOSTNAME gate, session survives docker restart. release.yml moves off qemu to a native per-arch build (amd64/arm64), each gated by this suite before its tag is pushed and merged into the version manifest. Verified green on selfpost.mixfed.ru via `make e2e`; go vet/gofmt clean in both modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
4.2 KiB
Go
143 lines
4.2 KiB
Go
package e2e
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"net/smtp"
|
|
"time"
|
|
)
|
|
|
|
// smtpsAddr is where compose.override.yml publishes the primary implicit-TLS
|
|
// submission port (465 in the shipped compose, remapped here to stay off the
|
|
// dev server's real ports).
|
|
const smtpsAddr = "127.0.0.1:20465"
|
|
|
|
// sendAttempt is one SMTP transaction against the stand. An empty authLogin
|
|
// means "connect without AUTH" (negative checks 1/3); from/to are full
|
|
// mailbox addresses.
|
|
type sendAttempt struct {
|
|
authLogin, authPassword string
|
|
from, to string
|
|
subject, body string
|
|
}
|
|
|
|
// sendResult records the error (if any) at each stage the transaction
|
|
// reached. Only the fields up to and including the first failure are set —
|
|
// the rest are nil because that stage was never attempted, not because it
|
|
// succeeded silently.
|
|
type sendResult struct {
|
|
dialErr error
|
|
authErr error
|
|
mailErr error
|
|
rcptErr error
|
|
dataErr error
|
|
}
|
|
|
|
// ok reports whether the message was fully accepted (DATA closed cleanly).
|
|
func (r sendResult) ok() bool {
|
|
return r.dialErr == nil && r.authErr == nil && r.mailErr == nil && r.rcptErr == nil && r.dataErr == nil
|
|
}
|
|
|
|
// firstErr returns whichever stage failed first, or nil if none did.
|
|
func (r sendResult) firstErr() error {
|
|
for _, e := range []error{r.dialErr, r.authErr, r.mailErr, r.rcptErr, r.dataErr} {
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// attemptSend drives one SMTP transaction over the implicit-TLS port, exactly
|
|
// as an application client library would: TLS connect, EHLO, optional AUTH
|
|
// PLAIN, MAIL/RCPT/DATA. The certificate is the stand's throwaway self-signed
|
|
// one (see stage.go), so verification is skipped — Postfix's
|
|
// smtpd_tls_security_level is "may" here just like production, this is purely
|
|
// about the test client trusting an unknown CA.
|
|
func attemptSend(a sendAttempt) sendResult {
|
|
var res sendResult
|
|
|
|
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
|
conn, err := tls.DialWithDialer(dialer, "tcp", smtpsAddr, &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
ServerName: selfpostHostname,
|
|
})
|
|
if err != nil {
|
|
res.dialErr = fmt.Errorf("dial %s: %w", smtpsAddr, err)
|
|
return res
|
|
}
|
|
c, err := smtp.NewClient(conn, selfpostHostname)
|
|
if err != nil {
|
|
res.dialErr = fmt.Errorf("smtp handshake: %w", err)
|
|
_ = conn.Close()
|
|
return res
|
|
}
|
|
defer c.Close()
|
|
|
|
if err := c.Hello("e2e-client.e2e.test"); err != nil {
|
|
res.dialErr = err
|
|
return res
|
|
}
|
|
|
|
if a.authLogin != "" {
|
|
auth := smtp.PlainAuth("", a.authLogin, a.authPassword, selfpostHostname)
|
|
if err := c.Auth(auth); err != nil {
|
|
res.authErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
}
|
|
|
|
if err := c.Mail(a.from); err != nil {
|
|
res.mailErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
if err := c.Rcpt(a.to); err != nil {
|
|
res.rcptErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
w, err := c.Data()
|
|
if err != nil {
|
|
res.dataErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
msg := buildMessage(a)
|
|
if _, err := w.Write([]byte(msg)); err != nil {
|
|
res.dataErr = err
|
|
} else if err := w.Close(); err != nil {
|
|
res.dataErr = err
|
|
}
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
|
|
// waitForSMTPSReady polls the implicit-TLS port until a bare TCP connect
|
|
// succeeds. Used after a container (re)start: the panel's HTTP port and
|
|
// Postfix's smtpd come up independently (postfix-wrapper.sh additionally
|
|
// waits on both milter sockets before starting Postfix at all), so a caller
|
|
// that only waited for the panel could still race a not-yet-listening smtpd.
|
|
func waitForSMTPSReady(timeout time.Duration) error {
|
|
return waitFor("smtps port to accept connections", timeout, 300*time.Millisecond, func() (bool, error) {
|
|
conn, err := net.DialTimeout("tcp", smtpsAddr, 2*time.Second)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
_ = conn.Close()
|
|
return true, nil
|
|
})
|
|
}
|
|
|
|
// buildMessage renders a minimal, valid RFC 5322 message. subject is expected
|
|
// to carry a unique token so the harness can find this exact message in the
|
|
// sink-MX's dump directory afterwards.
|
|
func buildMessage(a sendAttempt) string {
|
|
return fmt.Sprintf(
|
|
"From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n",
|
|
a.from, a.to, a.subject, time.Now().UTC().Format(time.RFC1123Z), a.body,
|
|
)
|
|
}
|