package smtp import ( "bufio" "context" "net" "runtime" "strings" "sync" "testing" "time" ) // fakeSink zeichnet angenommene Nachrichten im Prozessspeicher auf. type fakeSink struct { mu sync.Mutex accepted []acceptedMessage fail bool } type acceptedMessage struct { envelope Envelope raw []byte } func (f *fakeSink) Accept(_ context.Context, envelope Envelope, raw []byte) error { f.mu.Lock() defer f.mu.Unlock() if f.fail { return errFakeSinkRejects } cp := make([]byte, len(raw)) copy(cp, raw) f.accepted = append(f.accepted, acceptedMessage{envelope: envelope, raw: cp}) return nil } func (f *fakeSink) count() int { f.mu.Lock() defer f.mu.Unlock() return len(f.accepted) } type sinkError string func (e sinkError) Error() string { return string(e) } const errFakeSinkRejects sinkError = "fake sink lehnt ab" func startTestServer(t *testing.T, sink MessageSink, maxMessageBytes int64) (addr string, stop func()) { t.Helper() srv := NewServerWithMaxMessageBytes(sink, maxMessageBytes) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listener: %v", err) } ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { _ = srv.Serve(ctx, listener) close(done) }() return listener.Addr().String(), func() { cancel() <-done } } type smtpClient struct { conn net.Conn reader *bufio.Reader } func dial(t *testing.T, addr string) *smtpClient { t.Helper() conn, err := net.DialTimeout("tcp", addr, 2*time.Second) if err != nil { t.Fatalf("dial: %v", err) } c := &smtpClient{conn: conn, reader: bufio.NewReader(conn)} c.readLine(t) // 220 Begrüßung return c } func (c *smtpClient) readLine(t *testing.T) string { t.Helper() _ = c.conn.SetReadDeadline(time.Now().Add(3 * time.Second)) line, err := c.reader.ReadString('\n') if err != nil { t.Fatalf("antwort lesen: %v", err) } return strings.TrimRight(line, "\r\n") } func (c *smtpClient) send(t *testing.T, cmd string) string { t.Helper() if _, err := c.conn.Write([]byte(cmd + "\r\n")); err != nil { t.Fatalf("kommando senden: %v", err) } return c.readLine(t) } func (c *smtpClient) close() { _ = c.conn.Close() } func code(line string) string { if len(line) < 3 { return line } return line[:3] } // TestSession_EnvelopeMustBeBuiltBeforeData ist die geforderte // Zustandsmaschinen-Abdeckung für Akzeptanzkriterium 1: Envelope // (HELO -> MAIL FROM -> RCPT TO) wird SCHRITTWEISE validiert, DATA ist // erst nach vollständigem, gültigem Envelope erlaubt. func TestSession_EnvelopeMustBeBuiltBeforeData(t *testing.T) { sink := &fakeSink{} addr, stop := startTestServer(t, sink, defaultMaxMessageBytes) defer stop() c := dial(t, addr) defer c.close() // MAIL FROM vor HELO -> 503. if resp := c.send(t, "MAIL FROM:"); code(resp) != "503" { t.Fatalf("erwartete 503 für MAIL FROM vor HELO, habe: %s", resp) } if resp := c.send(t, "EHLO client.example.com"); code(resp) != "250" { t.Fatalf("erwartete 250 für EHLO, habe: %s", resp) } // Mehrzeilige EHLO-Antwort vollständig lesen. for { line := c.readLine(t) if strings.HasPrefix(line, "250 ") { break } } // RCPT TO vor MAIL FROM -> 503. if resp := c.send(t, "RCPT TO:"); code(resp) != "503" { t.Fatalf("erwartete 503 für RCPT TO vor MAIL FROM, habe: %s", resp) } // DATA vor RCPT TO -> 503. if resp := c.send(t, "DATA"); code(resp) != "503" { t.Fatalf("erwartete 503 für DATA ohne RCPT TO, habe: %s", resp) } if resp := c.send(t, "MAIL FROM:"); code(resp) != "250" { t.Fatalf("erwartete 250 für MAIL FROM, habe: %s", resp) } if resp := c.send(t, "RCPT TO:"); code(resp) != "250" { t.Fatalf("erwartete 250 für RCPT TO, habe: %s", resp) } if resp := c.send(t, "DATA"); code(resp) != "354" { t.Fatalf("erwartete 354 für DATA nach vollständigem Envelope, habe: %s", resp) } if resp := c.send(t, "Subject: test\r\n\r\nHallo\r\n."); code(resp) != "250" { t.Fatalf("erwartete 250 nach abgeschlossener DATA, habe: %s", resp) } if sink.count() != 1 { t.Fatalf("erwartete 1 angenommene nachricht, habe %d", sink.count()) } } // TestData_MessageSizeCheckedBeforeAcceptance ist die geforderte // Pflichtprüfung für Akzeptanzkriterium 1 (Größenanteil): eine // Nachricht über der konfigurierten Höchstgröße wird sauber // zurückgewiesen, der Sink bekommt sie NICHT. func TestData_MessageSizeCheckedBeforeAcceptance(t *testing.T) { sink := &fakeSink{} const tinyLimit = 32 // Bytes addr, stop := startTestServer(t, sink, tinyLimit) defer stop() c := dial(t, addr) defer c.close() c.send(t, "EHLO client.example.com") for { line := c.readLine(t) if strings.HasPrefix(line, "250 ") { break } } c.send(t, "MAIL FROM:") c.send(t, "RCPT TO:") if resp := c.send(t, "DATA"); code(resp) != "354" { t.Fatalf("erwartete 354, habe: %s", resp) } // Die überlange Zeile überschreitet das Limit bereits selbst — der // Server antwortet SOFORT mit 552, OHNE auf die Abschlusszeile "." // zu warten (drainUntilDot liest sie erst danach weg, damit das // Protokoll synchron bleibt). Deshalb hier NICHT auf eine Antwort // zur ersten Zeile warten, sondern erst die Abschlusszeile senden // und dann einmal lesen. longBody := strings.Repeat("x", 200) if _, err := c.conn.Write([]byte(longBody + "\r\n")); err != nil { t.Fatalf("kommando senden: %v", err) } resp := c.send(t, ".") if code(resp) != "552" { t.Fatalf("erwartete 552 (nachricht zu groß), habe: %s", resp) } if sink.count() != 0 { t.Fatalf("sink hätte die zu große nachricht nicht bekommen dürfen, habe %d", sink.count()) } // Verbindung muss danach weiter benutzbar sein (kein Absturz/Hänger). if resp := c.send(t, "NOOP"); code(resp) != "250" { t.Fatalf("session nach größenfehler nicht mehr funktionsfähig: %s", resp) } } // TestRcptTo_InvalidRecipientCleanError ist die geforderte // Akzeptanzkriterium-3-Prüfung: ungültige Empfängerdaten führen zu // sauberer SMTP-Fehlermeldung statt Absturz. func TestRcptTo_InvalidRecipientCleanError(t *testing.T) { sink := &fakeSink{} addr, stop := startTestServer(t, sink, defaultMaxMessageBytes) defer stop() c := dial(t, addr) defer c.close() c.send(t, "EHLO client.example.com") for { line := c.readLine(t) if strings.HasPrefix(line, "250 ") { break } } c.send(t, "MAIL FROM:") if resp := c.send(t, "RCPT TO:"); code(resp) != "553" { t.Fatalf("erwartete 553 für ungültigen empfänger, habe: %s", resp) } // Verbindung bleibt nutzbar — kein Absturz, kein Verbindungsabbruch. if resp := c.send(t, "RCPT TO:"); code(resp) != "250" { t.Fatalf("erwartete 250 für gültigen empfänger nach vorherigem fehler, habe: %s", resp) } } // TestMailFrom_InvalidSenderCleanError deckt Akzeptanzkriterium 3 auch // für den Absender ab. func TestMailFrom_InvalidSenderCleanError(t *testing.T) { sink := &fakeSink{} addr, stop := startTestServer(t, sink, defaultMaxMessageBytes) defer stop() c := dial(t, addr) defer c.close() c.send(t, "EHLO client.example.com") for { line := c.readLine(t) if strings.HasPrefix(line, "250 ") { break } } if resp := c.send(t, "MAIL FROM:"); code(resp) != "553" { t.Fatalf("erwartete 553 für ungültigen absender, habe: %s", resp) } if resp := c.send(t, "NOOP"); code(resp) != "250" { t.Fatalf("session nach ungültigem absender nicht mehr funktionsfähig: %s", resp) } } // TestServer_ConcurrentConnectionsNoLeak ist die geforderte // Pflichtprüfung 3: Lasttest mit gleichzeitigen Verbindungen ohne // Verbindungsleck. func TestServer_ConcurrentConnectionsNoLeak(t *testing.T) { sink := &fakeSink{} addr, stop := startTestServer(t, sink, defaultMaxMessageBytes) defer stop() runtime.GC() baseline := runtime.NumGoroutine() const concurrency = 50 var wg sync.WaitGroup for i := 0; i < concurrency; i++ { wg.Add(1) go func() { defer wg.Done() conn, err := net.DialTimeout("tcp", addr, 3*time.Second) if err != nil { t.Errorf("dial: %v", err) return } defer func() { _ = conn.Close() }() c := &smtpClient{conn: conn, reader: bufio.NewReader(conn)} c.readLine(t) c.send(t, "EHLO client.example.com") for { line := c.readLine(t) if strings.HasPrefix(line, "250 ") { break } } c.send(t, "MAIL FROM:") c.send(t, "RCPT TO:") c.send(t, "DATA") c.send(t, "Subject: last\r\n\r\nHallo\r\n.") c.send(t, "QUIT") }() } wg.Wait() if sink.count() != concurrency { t.Fatalf("erwartete %d angenommene nachrichten, habe %d", concurrency, sink.count()) } deadline := time.Now().Add(3 * time.Second) for { runtime.GC() current := runtime.NumGoroutine() if current <= baseline+2 { return } if time.Now().After(deadline) { t.Fatalf("verbindungs-/goroutine-leck nach lasttest: baseline=%d, aktuell=%d", baseline, current) } time.Sleep(50 * time.Millisecond) } }