Files
selfpost/test/e2e/main_test.go
T
mixandClaude Opus 5.5 f2ff8b4fea test-email: a test message from Server › Health
Decided by the owner on 2026-10-13 (out of preflight); the sender is his
choice of 2026-10-16 ("Да, адрес годится"); mockups e92769f, e3b2501,
8f0ccf9, the route guard 75f97c9.

Card 09 on Health, in the right stack's static end (the left stack is
polled whole): one recipient and Send. HandleTestMessage (global role)
takes one address (validate.AccountEmail - no list, nothing that reads as
an option), at most ten an hour counted from the send log, writes the
message's send-log row first (store.InsertTestMessage: queued, the
hostname as domain, no application, no queue id), awaits its Message-ID
(logtail.AwaitMessageID) and hands it to sendmail as notifications go
(notify.Mailer.SendWithID: postmaster@ the hostname, signed by the local
socket with the key of the server's own name). The browser lands on the
row; the tailer gives it its queue id from cleanup's message-id= line
(src/cleanup/cleanup_message.c:639) and from then on its delivery lines
its status. Postfix refusing the hand-over removes the row; sendmail out
of time keeps it (postdrop may have queued it). The sweep closes a row
whose id was never seen once it is no longer awaited
(CloseUnmatchedTestMessages); an id awaited past ten minutes is not taken.

Reviewed for security before the commit (2026-10-17): no high or medium;
its three low findings are fixed in this commit (the unmatched row's
close, the awaited id's age, the timeout keeping the row) and the
security.md bullet says how the queue id is matched.

Tests: TestTestMessage (refusals, the row and the page, Postfix refusing,
the hourly ten), TestTestMessageTimeoutKeepsTheRow, TestTestMessageRows,
TestCloseUnmatchedTestMessages, TestAwaitedMessageID,
TestRunGivesTheTestMessageItsQueueID, TestHealthTestMessageCard; e2e sends
one to the sink: the row is sent with a long queue id, the sink has it
signed d=mail.e2e.test. go vet + go test (26 packages) and make e2e on the
test server for this tree.

Docs: help (word for word with the mockup), guide § Overview and Health,
architecture § Log tailer, security.md, CHANGELOG [Unreleased] § Added,
roadmap row done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:42:21 +03:00

438 lines
13 KiB
Go

// Package e2e is the hermetic container e2e gate: it drives the shipped
// deploy/docker-compose.yml (plus a test-only override) exactly as an
// administrator and their applications would, so the class of failure unit
// tests cannot see — broken container wiring — has one place to be caught
// before an image is published.
//
// It is a separate module on purpose (see ../../docs/development.md):
// `go test ./...` in the main module never pulls this in, and its test-only
// dependencies (DKIM verification) never enter the shipped binaries' build
// graph.
package e2e
import (
"fmt"
"os"
"regexp"
"strings"
"testing"
"time"
)
var statusCellPattern = regexp.MustCompile(`<span class="tag[^"]*">(queued|sent|deferred|bounced|rejected)</span>`)
// h is the single shared stand for the whole ordered scenario in TestE2E.
// TestHostnameGate does not use it — it spins its own disposable container.
var h *stack
func TestMain(m *testing.M) {
s, err := newStack()
if err != nil {
fmt.Fprintln(os.Stderr, "e2e: build stack:", err)
os.Exit(1)
}
h = s
if err := prepareStage(s); err != nil {
fmt.Fprintln(os.Stderr, "e2e: prepare stage:", err)
os.Exit(1)
}
if err := s.build(); err != nil {
fmt.Fprintln(os.Stderr, "e2e: build:", err)
os.Exit(1)
}
if err := s.up(); err != nil {
fmt.Fprintln(os.Stderr, "e2e: up:", err)
s.down()
os.Exit(1)
}
code := m.Run()
if code != 0 {
for _, svc := range []string{"selfpost", "coredns", "sink"} {
fmt.Fprintf(os.Stderr, "\n==== logs: %s ====\n%s\n", svc, s.logs(svc))
}
}
// Panel/postfix-owned files under the /data bind mount outlive the
// container; reclaim ownership while selfpost is still up so a later
// prepareStage RemoveAll (or a local re-run) is not stuck on EACCES.
s.reclaimData()
s.down()
os.Exit(code)
}
// The one administrator of the run.
const (
adminUser = "e2eadmin"
adminPassword = "correct-horse-battery-staple"
)
// scenario carries state forward between the ordered subtests of TestE2E:
// each later step needs something an earlier one produced (the domain id, the
// DKIM record, the application credentials, an observed client IP).
type scenario struct {
panel *panelClient
domainID string
dkimName string
dkimValue string
dkimZoneRelName string // dkimName without the trailing ".e2e.test."
appLogin string
appPassword string
l2AppLogin string
l2AppPassword string
clientIP string
zoneRecords []txtRecord
// deliveryPage is the panel page of the first delivered message.
deliveryPage string
}
func TestE2E(t *testing.T) {
sc := &scenario{}
// Ordered scenario: each step needs state from earlier ones. A failed
// subtest must stop the rest — otherwise sc.panel stays nil and the next
// step panics, masking the real failure (as seen on the v1.0.0 release CI).
run := func(name string, fn func(*testing.T)) {
if t.Failed() {
return
}
t.Run(name, fn)
}
run("startup_processes_running", func(t *testing.T) {
if err := checkSupervisorProcesses(h); err != nil {
t.Fatal(err)
}
if err := checkDKIMTree(h); err != nil {
t.Fatal(err)
}
if err := checkPostfixHardening(h); err != nil {
t.Fatal(err)
}
if err := checkInboundRelayOff(h); err != nil {
t.Fatal(err)
}
if err := checkCitedPackageVersions(h); err != nil {
t.Fatal(err)
}
if err := checkPort25ConfigCombinations(h); err != nil {
t.Fatal(err)
}
if err := checkLogrotateConfigMode(h); err != nil {
t.Fatal(err)
}
if err := checkLogrotateRotation(h); err != nil {
t.Fatal(err)
}
if err := waitForPanelReady(); err != nil {
t.Fatal(err)
}
})
// The image is pushed as the release, so it must say it is the release:
// the footer, -version and the backup/restore version check all read this
// stamp. Release images were once stamped "e2e" because this stand rebuilt
// them with its own VERSION (audit 2026-10-12, H1).
run("image_stamped_with_version", func(t *testing.T) {
want := expectedVersion()
for _, bin := range []string{"panel", "selfpost-backup"} {
out, err := h.execIn("selfpost", bin, "-version")
if err != nil {
t.Fatalf("%s -version: %v", bin, err)
}
if got := strings.TrimSpace(out); got != want {
t.Fatalf("%s -version = %q, want %q", bin, got, want)
}
}
})
run("renewed_certificate_picked_up", func(t *testing.T) {
if err := checkCertRenewalPickedUp(h); err != nil {
t.Fatal(err)
}
})
run("setup_and_login", func(t *testing.T) {
token, err := readSetupToken(h)
if err != nil {
t.Fatalf("read setup token: %v", err)
}
p, err := newPanelClient()
if err != nil {
t.Fatal(err)
}
if err := p.setup(token, adminUser, adminPassword); err != nil {
t.Fatalf("setup: %v", err)
}
if err := p.login(adminUser, adminPassword); err != nil {
t.Fatalf("login: %v", err)
}
sc.panel = p
})
run("health_shows_what_is_served", func(t *testing.T) {
testHealthShowsWhatIsServed(t, sc)
})
run("form_post_with_the_browsers_origin", func(t *testing.T) {
testFormPostWithTheBrowsersOrigin(t, sc)
})
run("inbound_ui_absent_when_disabled", func(t *testing.T) {
resp, body, err := sc.panel.get("/overview")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
if strings.Contains(body, `href="/inbound/domains"`) {
t.Fatal("status page shows Inbound nav while INBOUND_RELAY_ENABLE is off")
}
resp, _, err = sc.panel.get("/inbound/domains")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 404 {
t.Fatalf("GET /inbound/domains = %d, want 404 with inbound relay off", resp.StatusCode)
}
})
// The panel runs postqueue as its own user. That works only while the queue's
// public/ directory belongs to Postfix's setgid group, which the entrypoint
// has to restore after its recursive chown of the queue tree.
run("queue_readable_by_the_panel", func(t *testing.T) {
resp, body, err := sc.panel.get("/outbound/queue")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("GET /outbound/queue = %d", resp.StatusCode)
}
if strings.Contains(body, "Could not read the mail queue") {
t.Fatal("the panel could not run postqueue -p (queue directory permissions)")
}
if !strings.Contains(body, "Mail queue is empty") {
t.Fatalf("the queue page does not show postqueue's listing of an empty queue:\n%s", body)
}
})
run("add_domain_and_publish_dkim", func(t *testing.T) {
id, err := sc.panel.addDomain(senderDomain)
if err != nil {
t.Fatalf("add domain: %v", err)
}
sc.domainID = id
name, value, err := sc.panel.dkimRecord(id)
if err != nil {
t.Fatalf("read dkim record: %v", err)
}
sc.dkimName, sc.dkimValue = name, value
relName, ok := trimZoneSuffix(name)
if !ok {
t.Fatalf("dkim record name %q is not under e2e.test", name)
}
sc.dkimZoneRelName = relName
records, err := publishTXT(h.stageDir, sc.zoneRecords, txtRecord{name: relName, value: value})
if err != nil {
t.Fatalf("publish dkim record to fake zone: %v", err)
}
sc.zoneRecords = records
})
run("add_application", func(t *testing.T) {
login, password, err := sc.panel.addApplication(sc.domainID, "app1", "wildcard", "")
if err != nil {
t.Fatalf("add application: %v", err)
}
if err := waitForSenderMap(login, password, "alerts@"+senderDomain); err != nil {
t.Fatal(err)
}
sc.appLogin, sc.appPassword = login, password
})
run("send_verify_dkim_and_status", func(t *testing.T) {
token := uniqueToken("positive")
res := attemptSend(sendAttempt{
authLogin: sc.appLogin, authPassword: sc.appPassword,
from: "alerts@" + senderDomain, to: recipient,
subject: "e2e " + token, body: "hello from selfpost e2e",
})
if !res.ok() {
t.Fatalf("positive-path send failed: dial=%v auth=%v mail=%v rcpt=%v data=%v",
res.dialErr, res.authErr, res.mailErr, res.rcptErr, res.dataErr)
}
file, raw, err := findSinkFile(h.stageDir, token, 30*time.Second)
if err != nil {
t.Fatalf("message never reached the sink: %v", err)
}
if err := verifyDKIM(raw, senderDomain); err != nil {
t.Fatalf("DKIM did not verify against the record the panel published: %v", err)
}
if err := checkPanelSignature(h, file, raw, senderDomain); err != nil {
t.Fatalf("the panel's signer: %v\n%s", err, raw)
}
if err := checkReceivedHidden(raw); err != nil {
t.Fatalf("port 465 should hide the application's session: %v", err)
}
if err := waitFor("send-log row to reach status=sent", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
rows, err := sc.panel.sendLogRows(senderDomain, "")
if err != nil {
return false, err
}
if containsCell(rows, "sent") {
return true, nil
}
return false, fmt.Errorf("send log not yet at status=sent (still %s)", firstStatusCell(rows))
}); err != nil {
t.Fatal(err)
}
// The only row so far; its page is looked at again once the log has
// been rotated (delivery_log_survives_rotation).
page, err := firstDeliveryPage(sc)
if err != nil {
t.Fatal(err)
}
sc.deliveryPage = page
})
run("signature_over_awkward_headers", func(t *testing.T) {
testSignatureOverAwkwardHeaders(t, sc)
})
run("notifications_set_up", func(t *testing.T) {
testNotificationsSetUp(t, sc)
})
run("negative_level2_ratelimit_via_panel", func(t *testing.T) {
testLevel2RateLimit(t, sc)
})
run("rate_limit_refusal_is_notified", func(t *testing.T) {
testRateLimitRefusalIsNotified(t, sc)
})
run("negative_sender_login_mismatch", func(t *testing.T) {
testSenderLoginMismatch(t, sc)
})
run("negative_no_auth_rejected", func(t *testing.T) {
testNoAuthRejected(t, sc)
})
run("negative_foreign_relay_rejected", func(t *testing.T) {
testForeignRelayRejected(t, sc)
})
run("sender_check_holds_the_from_line", func(t *testing.T) {
testSenderCheck(t, sc)
})
run("sender_check_holds_the_sender_line", func(t *testing.T) {
testSenderLine(t, sc)
})
run("passed_from_lines_signed_for_their_domain_only", func(t *testing.T) {
testSignedForItsDomainOnly(t, sc)
})
run("refusals_show_postfixs_line", func(t *testing.T) {
testRefusalsShowPostfixsLine(t, sc)
})
run("refusal_recovered_from_the_log", func(t *testing.T) {
testRefusalRecoveredFromTheLog(t, sc)
})
run("login_in_other_capitals_does_not_authenticate", func(t *testing.T) {
testLoginCaseMatters(t, sc)
})
run("panel_down_defers_mail", func(t *testing.T) {
testPanelDownDefersMail(t, sc)
})
run("panel_hung_mid_message_is_counted", func(t *testing.T) {
testPanelHungMidMessage(t, sc)
})
run("session_survives_restart", func(t *testing.T) {
testSessionSurvivesRestart(t, sc)
})
run("sasldb_changed_by_rename", func(t *testing.T) {
testSASLDBChangedByRename(t, sc)
})
run("supervision_stops_postfix_and_gives_up", func(t *testing.T) {
testSupervision(t, sc)
})
run("negative_level1_ratelimit", func(t *testing.T) {
testLevel1RateLimit(t, sc)
})
// Rotates mail.log, so it comes after the steps that read what they need
// from the current file.
run("delivery_log_survives_rotation", func(t *testing.T) {
testDeliveryLogSurvivesRotation(t, sc)
})
// From here on the installation is no longer the one the run started: it
// is restored from its own backup, then switched to accept mail on port 25.
run("recipients_as_postfix_logs_them", func(t *testing.T) {
testRecipientsAsPostfixLogsThem(t, sc)
})
run("address_literal_is_not_local", func(t *testing.T) {
testAddressLiteralIsNotLocal(t, sc)
})
run("expired_message_is_closed", func(t *testing.T) {
testExpiredMessageIsClosed(t, sc)
})
run("test_message_reaches_the_sink", func(t *testing.T) {
testTestMessage(t, sc)
})
run("full_backup_restore", func(t *testing.T) {
testFullBackupRestore(t, sc)
})
run("certbot_layout_followed", func(t *testing.T) {
testCertbotLayout(t, sc)
})
run("enable_port25_features", func(t *testing.T) {
testEnablePort25Features(t, sc)
})
run("dmarc_report_reaches_the_panel", func(t *testing.T) {
testDMARCReportReachesThePanel(t, sc)
})
run("dmarc_junk_is_dropped", func(t *testing.T) {
testDMARCJunkIsDropped(t, sc)
})
run("inbound_relay_delivers_and_refuses", func(t *testing.T) {
testInboundRelay(t, sc)
})
run("inbound_bare_newline_is_one_message", func(t *testing.T) {
testInboundBareNewline(t, sc)
})
}
// trimZoneSuffix strips the "e2e.test" suffix a fully-qualified DKIM record
// name carries, returning the name relative to the zone db.zone's $ORIGIN
// expects (see dns_zone.go).
func trimZoneSuffix(fqdn string) (string, bool) {
const suffix = ".e2e.test"
if len(fqdn) > len(suffix) && fqdn[len(fqdn)-len(suffix):] == suffix {
return fqdn[:len(fqdn)-len(suffix)], true
}
return "", false
}
var tokenCounter int
// uniqueToken returns a short, human-readable, monotonically distinct marker
// safe to embed in a Subject header and grep for in the sink's dump
// directory.
func uniqueToken(label string) string {
tokenCounter++
return fmt.Sprintf("%s-%d-%d", label, time.Now().UnixNano(), tokenCounter)
}
func containsCell(html, needle string) bool {
return strings.Contains(html, `<span class="tag`) && strings.Contains(html, `">`+needle+`</span>`)
}
func firstStatusCell(html string) string {
m := statusCellPattern.FindStringSubmatch(html)
if m == nil {
return "(no rows yet)"
}
return m[1]
}