Vollständiger POP3-Server von Grund auf implementiert, analog zum bestehenden IMAP-Server (ING-01): TCP-Listener mit einer Goroutine pro Verbindung, CRLF/Byte-Stuffing-sichere Response-Writer, Zustandsmaschine (Authorization/Transaction/Update), Kommandos USER, PASS, STAT, LIST, RETR, DELE, QUIT. Zentrale Designentscheidungen: - USER antwortet immer +OK (RFC-konform), Prüfung erst bei PASS - Fehlgeschlagene Anmeldung liefert für unbekannten Benutzer und falsches Passwort denselben generischen Text (keine Informationspreisgabe, Akzeptanzkriterium 3) - DELE markiert Nachrichten nur sitzungslokal; store.Delete wird strukturell ausschließlich in QUIT (Transaction -> Update) aufgerufen, wodurch ein Verbindungsabbruch ohne QUIT nichts endgültig löscht (Pflichtprüfung 3) Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt: Zustandsübergangs-Tests gegen realen TCP-Server, manuelle Session mit Python-Standardbibliothek poplib (echtes Transkript im Prüfprotokoll), automatisierter Test für DELE-ohne-QUIT. Zusätzlich: 20 parallele reale Sessions (Akzeptanzkriterium 1), vollständiger RETR+DELE+QUIT-Zyklus (Akzeptanzkriterium 2). go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~24 Pakete) regressionsfrei getestet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
302 lines
8.3 KiB
Go
302 lines
8.3 KiB
Go
package pop3
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type fakeAuthenticator struct {
|
|
users map[string]string
|
|
}
|
|
|
|
func (f fakeAuthenticator) Authenticate(_ context.Context, username, password string) (bool, error) {
|
|
want, ok := f.users[username]
|
|
return ok && want == password, nil
|
|
}
|
|
|
|
// fakeMailboxStore hält Nachrichten im Prozessspeicher — Delete entfernt
|
|
// sie erst bei tatsächlichem Aufruf (durch handleQuit im Update-Zustand).
|
|
type fakeMailboxStore struct {
|
|
mu sync.Mutex
|
|
messages map[string]map[int]string // username -> nummer -> inhalt
|
|
}
|
|
|
|
func newFakeMailboxStore() *fakeMailboxStore {
|
|
return &fakeMailboxStore{messages: map[string]map[int]string{
|
|
"alice": {1: "Erste Testnachricht\nmit zwei Zeilen", 2: "Zweite Testnachricht"},
|
|
}}
|
|
}
|
|
|
|
func (f *fakeMailboxStore) List(_ context.Context, username string) ([]Message, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
msgs := f.messages[username]
|
|
result := make([]Message, 0, len(msgs))
|
|
for n, content := range msgs {
|
|
result = append(result, Message{Number: n, Size: int64(len(content))})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (f *fakeMailboxStore) Retrieve(_ context.Context, username string, number int) ([]byte, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
content, ok := f.messages[username][number]
|
|
if !ok {
|
|
return nil, errors.New("keine solche nachricht")
|
|
}
|
|
return []byte(content), nil
|
|
}
|
|
|
|
func (f *fakeMailboxStore) Delete(_ context.Context, username string, numbers []int) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, n := range numbers {
|
|
delete(f.messages[username], n)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeMailboxStore) count(username string) int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.messages[username])
|
|
}
|
|
|
|
func startTestServer(t *testing.T) (addr string, store *fakeMailboxStore, stop func()) {
|
|
t.Helper()
|
|
auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
|
|
store = newFakeMailboxStore()
|
|
srv := NewServer(auth, store)
|
|
|
|
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(), store, func() {
|
|
cancel()
|
|
<-done
|
|
}
|
|
}
|
|
|
|
type pop3Client struct {
|
|
conn net.Conn
|
|
reader *bufio.Reader
|
|
}
|
|
|
|
func dial(t *testing.T, addr string) *pop3Client {
|
|
t.Helper()
|
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
c := &pop3Client{conn: conn, reader: bufio.NewReader(conn)}
|
|
c.readLine(t) // Begrüßung
|
|
return c
|
|
}
|
|
|
|
func (c *pop3Client) 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")
|
|
}
|
|
|
|
// send sendet EIN Kommando und liest EINE Antwortzeile (Statuszeile).
|
|
func (c *pop3Client) 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)
|
|
}
|
|
|
|
// sendMultiline sendet ein Kommando und liest bis zur "."-Abschlusszeile.
|
|
func (c *pop3Client) sendMultiline(t *testing.T, cmd string) (status string, dataLines []string) {
|
|
t.Helper()
|
|
status = c.send(t, cmd)
|
|
if !strings.HasPrefix(status, "+OK") {
|
|
return status, nil
|
|
}
|
|
for {
|
|
line := c.readLine(t)
|
|
if line == "." {
|
|
return status, dataLines
|
|
}
|
|
dataLines = append(dataLines, line)
|
|
}
|
|
}
|
|
|
|
func (c *pop3Client) close() { _ = c.conn.Close() }
|
|
|
|
func loginAsAlice(t *testing.T, c *pop3Client) {
|
|
t.Helper()
|
|
if resp := c.send(t, "USER alice"); !strings.HasPrefix(resp, "+OK") {
|
|
t.Fatalf("USER: %s", resp)
|
|
}
|
|
if resp := c.send(t, "PASS geheim123"); !strings.HasPrefix(resp, "+OK") {
|
|
t.Fatalf("PASS: %s", resp)
|
|
}
|
|
}
|
|
|
|
// TestSession_StateTransitions ist die geforderte Pflichtprüfung 1:
|
|
// automatisierter Test für jede Zustandsübergangs-Regel.
|
|
func TestSession_StateTransitions(t *testing.T) {
|
|
addr, _, stop := startTestServer(t)
|
|
defer stop()
|
|
c := dial(t, addr)
|
|
defer c.close()
|
|
|
|
// Verbotener Übergang: STAT/RETR/DELE in Authorization.
|
|
if resp := c.send(t, "STAT"); !strings.HasPrefix(resp, "-ERR") {
|
|
t.Fatalf("erwartete -ERR für STAT in Authorization, habe: %s", resp)
|
|
}
|
|
if resp := c.send(t, "RETR 1"); !strings.HasPrefix(resp, "-ERR") {
|
|
t.Fatalf("erwartete -ERR für RETR in Authorization, habe: %s", resp)
|
|
}
|
|
|
|
// PASS ohne vorheriges USER.
|
|
if resp := c.send(t, "PASS irgendwas"); !strings.HasPrefix(resp, "-ERR") {
|
|
t.Fatalf("erwartete -ERR für PASS ohne USER, habe: %s", resp)
|
|
}
|
|
|
|
// Authorization -> Transaction.
|
|
loginAsAlice(t, c)
|
|
|
|
// Verbotener Übergang: USER/PASS erneut in Transaction.
|
|
if resp := c.send(t, "USER alice"); !strings.HasPrefix(resp, "-ERR") {
|
|
t.Fatalf("erwartete -ERR für USER in Transaction, habe: %s", resp)
|
|
}
|
|
|
|
// In Transaction erlaubt: STAT.
|
|
if resp := c.send(t, "STAT"); !strings.HasPrefix(resp, "+OK") {
|
|
t.Fatalf("erwartete +OK für STAT in Transaction, habe: %s", resp)
|
|
}
|
|
|
|
// Transaction -> (Update, real durchlaufen) -> Verbindungsende.
|
|
if resp := c.send(t, "QUIT"); !strings.HasPrefix(resp, "+OK") {
|
|
t.Fatalf("erwartete +OK für QUIT, habe: %s", resp)
|
|
}
|
|
}
|
|
|
|
// TestCommands_RetrDeleFullCycle deckt Akzeptanzkriterium 2 ab: RETR
|
|
// liefert vollständige Nachrichten, DELE + QUIT löscht endgültig.
|
|
func TestCommands_RetrDeleFullCycle(t *testing.T) {
|
|
addr, store, stop := startTestServer(t)
|
|
defer stop()
|
|
c := dial(t, addr)
|
|
defer c.close()
|
|
loginAsAlice(t, c)
|
|
|
|
status, lines := c.sendMultiline(t, "RETR 1")
|
|
if !strings.HasPrefix(status, "+OK") {
|
|
t.Fatalf("RETR: %s", status)
|
|
}
|
|
full := strings.Join(lines, "\n")
|
|
if full != "Erste Testnachricht\nmit zwei Zeilen" {
|
|
t.Fatalf("RETR lieferte keine vollständige nachricht, habe: %q", full)
|
|
}
|
|
|
|
if resp := c.send(t, "DELE 1"); !strings.HasPrefix(resp, "+OK") {
|
|
t.Fatalf("DELE: %s", resp)
|
|
}
|
|
if resp := c.send(t, "QUIT"); !strings.HasPrefix(resp, "+OK") {
|
|
t.Fatalf("QUIT: %s", resp)
|
|
}
|
|
|
|
if store.count("alice") != 1 {
|
|
t.Fatalf("erwartete 1 verbleibende nachricht nach DELE+QUIT, habe %d", store.count("alice"))
|
|
}
|
|
}
|
|
|
|
// TestCommands_DeleWithoutQuitDeletesNothing ist die geforderte
|
|
// Pflichtprüfung 3: DELE ohne anschließendes QUIT löscht nichts
|
|
// endgültig.
|
|
func TestCommands_DeleWithoutQuitDeletesNothing(t *testing.T) {
|
|
addr, store, stop := startTestServer(t)
|
|
defer stop()
|
|
c := dial(t, addr)
|
|
loginAsAlice(t, c)
|
|
|
|
if resp := c.send(t, "DELE 1"); !strings.HasPrefix(resp, "+OK") {
|
|
t.Fatalf("DELE: %s", resp)
|
|
}
|
|
|
|
// Verbindung OHNE QUIT abrupt schließen.
|
|
c.close()
|
|
time.Sleep(100 * time.Millisecond) // server real verarbeiten lassen
|
|
|
|
if store.count("alice") != 2 {
|
|
t.Fatalf("erwartete weiterhin 2 nachrichten (kein QUIT, keine endgültige löschung), habe %d", store.count("alice"))
|
|
}
|
|
}
|
|
|
|
// TestPass_RejectsWithoutInformationLeak ist die geforderte
|
|
// Akzeptanzkriterium-3-Prüfung: fehlerhafte Anmeldeversuche ohne
|
|
// Informationspreisgabe.
|
|
func TestPass_RejectsWithoutInformationLeak(t *testing.T) {
|
|
addr, _, stop := startTestServer(t)
|
|
defer stop()
|
|
|
|
c1 := dial(t, addr)
|
|
defer c1.close()
|
|
c1.send(t, "USER unbekannter_nutzer")
|
|
respUnknownUser := c1.send(t, "PASS irgendwas")
|
|
|
|
c2 := dial(t, addr)
|
|
defer c2.close()
|
|
c2.send(t, "USER alice")
|
|
respWrongPassword := c2.send(t, "PASS falschespasswort")
|
|
|
|
if respUnknownUser != respWrongPassword {
|
|
t.Fatalf("unterschiedliche fehlermeldungen verraten, ob der nutzer existiert: %q vs %q", respUnknownUser, respWrongPassword)
|
|
}
|
|
if !strings.HasPrefix(respUnknownUser, "-ERR") {
|
|
t.Fatalf("erwartete -ERR, habe: %s", respUnknownUser)
|
|
}
|
|
}
|
|
|
|
// TestServer_ManyParallelSessions belegt Robustheit unter Last (Vorbild
|
|
// ING-01) — kein expliziter Lasttest im Ticket gefordert, aber sinnvolle
|
|
// Ergänzung zur Zustandsmaschinen-Testabdeckung.
|
|
func TestServer_ManyParallelSessions(t *testing.T) {
|
|
addr, _, stop := startTestServer(t)
|
|
defer stop()
|
|
|
|
const sessions = 20
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < sessions; i++ {
|
|
wg.Add(1)
|
|
go func(n int) {
|
|
defer wg.Done()
|
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
|
if err != nil {
|
|
t.Errorf("dial %d: %v", n, err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
c := &pop3Client{conn: conn, reader: bufio.NewReader(conn)}
|
|
c.readLine(t)
|
|
loginAsAlice(t, c)
|
|
c.send(t, "STAT")
|
|
c.send(t, "QUIT")
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
}
|