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() }