Add E2E test suite and fix pipe buffer aliasing bug#106
Conversation
The pipe stored slices referencing the reusable read buffer for sMailAddr, rMailAddr, sServerName, and rServerName. Subsequent reads overwrote the buffer, corrupting the extracted metadata passed to AfterConn hooks. Copy the byte slices to avoid aliasing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add message capture capability to test SMTP server (ReceivedMessage, OnMessage callback) and create comprehensive E2E test suite covering message relay integrity, metadata extraction, hook callbacks, STARTTLS stripping, and multiple email relay. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
WaitForServerListen probe connections generate AfterConn hook calls with "unknown" addresses. Use findConnCall to search for the conn call matching the expected MailFrom instead of relying on index order. Also fix errcheck for s.Quit() in sendEmail. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a critical buffer aliasing bug in pipe metadata extraction and adds comprehensive E2E test infrastructure to verify SMTP proxy functionality. The aliasing bug caused data corruption when the reusable read buffer was overwritten by subsequent reads. The new test suite validates message relay integrity, metadata extraction, hook callbacks, STARTTLS stripping, and multi-email handling.
Changes:
- Fixed buffer aliasing bug by copying metadata slices (sMailAddr, rMailAddr, sServerName, rServerName) instead of referencing the reusable buffer
- Added message capture capability to test SMTP server with ReceivedMessage struct and OnMessage callback
- Enhanced integration test to verify message content instead of just checking for errors
- Added comprehensive E2E test suite with 5 subtests covering relay, metadata, hooks, STARTTLS, and multi-email scenarios
- Updated CI workflows to use Go versions 1.24.x, 1.25.x, 1.26.x (which do not exist yet)
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| pipe.go | Fixed buffer aliasing by copying extracted metadata slices instead of storing direct references to reusable buffer |
| smtp.go | Added ReceivedMessage struct, OnMessage callback, and message capture logic for test SMTP server |
| integration_test.go | Enhanced test to verify actual message content (MailFrom, Subject) instead of only checking for errors |
| e2e_test.go | Added comprehensive E2E test suite with 5 subtests and helper infrastructure (testHook, testEnv, port allocation) |
| Makefile | Added make e2e target and updated make integration to run both TestIntegration and TestE2E |
| .github/workflows/test.yml | Updated Go versions to 1.24.x, 1.25.x, 1.26.x (non-existent versions) |
| .github/workflows/integration.yml | Updated Go versions to 1.24.x, 1.25.x, 1.26.x (non-existent versions) |
| .github/workflows/build.yml | Updated Go versions to 1.24.x, 1.25.x, 1.26.x (non-existent versions) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| default: | ||
| if !c.data { | ||
| if c.data { | ||
| c.dataBody.WriteString(v + "\n") |
There was a problem hiding this comment.
The data capture logic writes v + "\n" instead of preserving the original line ending. Since the incoming line was already read with ReadString('\n') and then trimmed, the original CRLF line ending is lost and replaced with just LF. This could cause issues if downstream code expects proper CRLF line endings in SMTP messages. Consider preserving the original line ending or consistently using CRLF for SMTP compliance.
| c.dataBody.WriteString(v + "\n") | |
| c.dataBody.WriteString(v + crlf) |
| func TestE2E(t *testing.T) { | ||
| if testing.Short() { | ||
| t.SkipNow() | ||
| } | ||
|
|
||
| env := setupTestEnv(t) | ||
|
|
||
| t.Run("MessageRelayIntegrity", func(t *testing.T) { | ||
| from := "sender@example.test" | ||
| to := "receiver@example.local" | ||
| subject := "Integrity Test" | ||
| body := "Hello, this is a relay integrity test." | ||
|
|
||
| env.sendEmail(t, from, to, subject, body) | ||
| msg := env.waitForMessage(t, 5*time.Second) | ||
|
|
||
| if msg.MailFrom != from { | ||
| t.Errorf("MailFrom = %q, want %q", msg.MailFrom, from) | ||
| } | ||
| if len(msg.RcptTo) != 1 || msg.RcptTo[0] != to { | ||
| t.Errorf("RcptTo = %v, want [%q]", msg.RcptTo, to) | ||
| } | ||
|
|
||
| data := string(msg.Data) | ||
| if !strings.Contains(data, "Subject: "+subject) { | ||
| t.Errorf("Data does not contain expected Subject header:\n%s", data[:min(len(data), 300)]) | ||
| } | ||
| if !strings.Contains(data, body) { | ||
| t.Errorf("Data does not contain expected body:\n%s", data[:min(len(data), 300)]) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("MetadataExtraction", func(t *testing.T) { | ||
| // Find the conn call matching the email we sent (probe connections have "unknown") | ||
| emailConn := env.hook.findConnCall("sender@example.test", 5*time.Second) | ||
| if emailConn == nil { | ||
| t.Fatal("timed out waiting for AfterConn hook call with expected MailFrom") | ||
| } | ||
|
|
||
| if string(emailConn.MailFrom) != "sender@example.test" { | ||
| t.Errorf("AfterConn MailFrom = %q, want %q", emailConn.MailFrom, "sender@example.test") | ||
| } | ||
| if string(emailConn.MailTo) != "receiver@example.local" { | ||
| t.Errorf("AfterConn MailTo = %q, want %q", emailConn.MailTo, "receiver@example.local") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("HookCallbacks", func(t *testing.T) { | ||
| comms := env.hook.getCommCalls() | ||
| if len(comms) == 0 { | ||
| t.Fatal("no AfterComm calls recorded") | ||
| } | ||
|
|
||
| directions := make(map[Direction]bool) | ||
| for _, c := range comms { | ||
| directions[c.Direction] = true | ||
| if c.ConnID == "" { | ||
| t.Error("AfterComm has empty ConnID") | ||
| } | ||
| if c.OccurredAt.IsZero() { | ||
| t.Error("AfterComm has zero OccurredAt") | ||
| } | ||
| } | ||
|
|
||
| for _, d := range []Direction{onPxy, srcToDst} { | ||
| if !directions[d] { | ||
| t.Errorf("AfterComm not called with Direction %q", d) | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| t.Run("StartTLSStripping", func(t *testing.T) { | ||
| comms := env.hook.getCommCalls() | ||
|
|
||
| var dstToPxyHasStartTLS bool | ||
| var pxyToSrcHasStartTLS bool | ||
| for _, c := range comms { | ||
| dataStr := string(c.Data) | ||
| if c.Direction == dstToPxy && strings.Contains(dataStr, "STARTTLS") { | ||
| dstToPxyHasStartTLS = true | ||
| } | ||
| if c.Direction == pxyToSrc && strings.Contains(dataStr, "STARTTLS") { | ||
| pxyToSrcHasStartTLS = true | ||
| } | ||
| } | ||
|
|
||
| if !dstToPxyHasStartTLS { | ||
| t.Error("expected STARTTLS in dstToPxy communication, but not found") | ||
| } | ||
| if pxyToSrcHasStartTLS { | ||
| t.Error("STARTTLS should be stripped from pxyToSrc communication, but was found") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("MultipleEmails", func(t *testing.T) { | ||
| connCountBefore := len(env.hook.getConnCalls()) | ||
|
|
||
| env.sendEmail(t, "user2@example.test", "dest2@example.local", "Second Email", "Body of second email") | ||
| msg2 := env.waitForMessage(t, 5*time.Second) | ||
| if msg2.MailFrom != "user2@example.test" { | ||
| t.Errorf("2nd email MailFrom = %q, want %q", msg2.MailFrom, "user2@example.test") | ||
| } | ||
| if !strings.Contains(string(msg2.Data), "Body of second email") { | ||
| t.Error("2nd email Data does not contain expected body") | ||
| } | ||
|
|
||
| env.sendEmail(t, "user3@example.test", "dest3@example.local", "Third Email", "Body of third email") | ||
| msg3 := env.waitForMessage(t, 5*time.Second) | ||
| if msg3.MailFrom != "user3@example.test" { | ||
| t.Errorf("3rd email MailFrom = %q, want %q", msg3.MailFrom, "user3@example.test") | ||
| } | ||
| if !strings.Contains(string(msg3.Data), "Body of third email") { | ||
| t.Error("3rd email Data does not contain expected body") | ||
| } | ||
|
|
||
| if !env.hook.waitForConnCalls(connCountBefore+2, 5*time.Second) { | ||
| t.Error("timed out waiting for AfterConn hook calls for multiple emails") | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
The subtests in TestE2E are not isolated - they all share the same testEnv instance and accumulated hook data. This means subtests depend on the execution order and cannot be run independently with go test -run TestE2E/MetadataExtraction. The MetadataExtraction subtest relies on finding data from MessageRelayIntegrity, and HookCallbacks/StartTLSStripping subtests examine all accumulated communications. Consider either: (1) making each subtest independent by setting up fresh environments, or (2) acknowledging this dependency by not using separate subtests (use a single sequential test), or (3) documenting that subtests must run in order.
Summary
sMailAddr,rMailAddr,sServerName,rServerNamewere stored as slices referencing the reusable read buffer, causing data corruption when subsequent reads overwrote the buffer. Now properly copied.ReceivedMessagestruct andOnMessagecallback enable tests to inspect relayed messages (envelope metadata + body).MailFromandSubjectheader of received messages instead of only checking for no error.TestE2E) with 5 subtests:MessageRelayIntegrity,MetadataExtraction,HookCallbacks,StartTLSStripping,MultipleEmails.make e2eand updatedmake integrationto run bothTestIntegrationandTestE2E.Test plan
go test -shortpasses (existing unit tests)go test -run TestIntegrationpasses with message verificationgo test -run TestE2Epasses all 5 subtestsgo test -run 'TestIntegration|TestE2E'passes together🤖 Generated with Claude Code