package imap import ( "bufio" "context" "errors" "net" "strconv" "strings" "sync" "testing" "time" ) // fakeAuthenticator ist ein In-Memory-Authenticator für Tests — echte // Anbindung an Benutzerverwaltung ist Sache späterer Kacheln (IMP-01 u. a.). 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 ist ein In-Memory-Postfachspeicher für Tests. type fakeMailboxStore struct { mailboxes map[string][]Message } func (f fakeMailboxStore) Select(_ context.Context, mailboxName string) (int, bool, error) { msgs, ok := f.mailboxes[mailboxName] return len(msgs), ok, nil } func (f fakeMailboxStore) Fetch(_ context.Context, mailboxName string, seqNumbers []uint32) ([]Message, error) { msgs, ok := f.mailboxes[mailboxName] if !ok { return nil, errors.New("imap: postfach nicht gefunden") } wanted := make(map[uint32]bool, len(seqNumbers)) for _, n := range seqNumbers { wanted[n] = true } var result []Message for _, m := range msgs { if wanted[m.SequenceNumber] { result = append(result, m) } } return result, nil } func startTestServer(t *testing.T) (addr string, stop func()) { t.Helper() auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}} store := fakeMailboxStore{mailboxes: map[string][]Message{ "INBOX": { {SequenceNumber: 1, Flags: []string{"\\Seen"}}, {SequenceNumber: 2, Flags: []string{}}, }, }} 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(), func() { cancel() <-done } } // imapClient ist ein minimaler Testclient (Zeile senden, Antwort lesen) // — bewusst kein voller IMAP-Parser, nur genug, um Server-Antworten zu // prüfen. type imapClient struct { conn net.Conn reader *bufio.Reader tagN int } func dial(t *testing.T, addr string) *imapClient { t.Helper() conn, err := net.DialTimeout("tcp", addr, 2*time.Second) if err != nil { t.Fatalf("dial: %v", err) } c := &imapClient{conn: conn, reader: bufio.NewReader(conn)} c.readLine(t) // Begrüßung return c } func (c *imapClient) 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") } // sendTagged sendet ein Kommando mit neuem Tag und liest Zeilen, bis die // getaggte Abschlusszeile kommt — liefert alle Zeilen (inkl. Abschluss). func (c *imapClient) sendTagged(t *testing.T, command string) (tag string, lines []string) { t.Helper() c.tagN++ tag = "A" + strconv.Itoa(c.tagN) _, err := c.conn.Write([]byte(tag + " " + command + "\r\n")) if err != nil { t.Fatalf("kommando senden: %v", err) } for { line := c.readLine(t) lines = append(lines, line) if strings.HasPrefix(line, tag+" ") { return tag, lines } } } func (c *imapClient) close() { _ = c.conn.Close() } // TestSession_StateTransitionsAndForbiddenTransitions ist die geforderte // Pflichtprüfung 2: automatisierter Test deckt alle drei // Zustandsübergänge UND deren verbotene Übergänge ab. func TestSession_StateTransitionsAndForbiddenTransitions(t *testing.T) { addr, stop := startTestServer(t) defer stop() c := dial(t, addr) defer c.close() // Verbotener Übergang: SELECT/FETCH in NotAuthenticated. _, lines := c.sendTagged(t, `SELECT INBOX`) if !strings.Contains(lines[len(lines)-1], "BAD") { t.Fatalf("erwartete BAD für SELECT in NotAuthenticated, habe: %v", lines) } _, lines = c.sendTagged(t, `FETCH 1 (FLAGS)`) if !strings.Contains(lines[len(lines)-1], "BAD") { t.Fatalf("erwartete BAD für FETCH in NotAuthenticated, habe: %v", lines) } // NotAuthenticated -> Authenticated via LOGIN. _, lines = c.sendTagged(t, `LOGIN alice geheim123`) if !strings.Contains(lines[len(lines)-1], "OK") { t.Fatalf("erwartete OK für LOGIN, habe: %v", lines) } // Verbotener Übergang: erneutes LOGIN in Authenticated. _, lines = c.sendTagged(t, `LOGIN alice geheim123`) if !strings.Contains(lines[len(lines)-1], "BAD") { t.Fatalf("erwartete BAD für LOGIN in Authenticated, habe: %v", lines) } // Verbotener Übergang: FETCH in Authenticated (noch nicht Selected). _, lines = c.sendTagged(t, `FETCH 1 (FLAGS)`) if !strings.Contains(lines[len(lines)-1], "BAD") { t.Fatalf("erwartete BAD für FETCH in Authenticated, habe: %v", lines) } // Authenticated -> Selected via SELECT. _, lines = c.sendTagged(t, `SELECT INBOX`) if !strings.Contains(lines[len(lines)-1], "OK") { t.Fatalf("erwartete OK für SELECT, habe: %v", lines) } // In Selected sind SELECT (erneut) und FETCH erlaubt. _, lines = c.sendTagged(t, `FETCH 1:2 (FLAGS)`) if !strings.Contains(lines[len(lines)-1], "OK") { t.Fatalf("erwartete OK für FETCH in Selected, habe: %v", lines) } } // TestCommands_AllBaseCommandsAnswered ist die geforderte // Pflichtprüfung/AC2: Grundbefehle sind implementiert und beantwortet. func TestCommands_AllBaseCommandsAnswered(t *testing.T) { addr, stop := startTestServer(t) defer stop() c := dial(t, addr) defer c.close() _, lines := c.sendTagged(t, "CAPABILITY") if !containsSubstring(lines, "IMAP4rev1") { t.Fatalf("CAPABILITY: erwartete IMAP4rev1 in antwort, habe: %v", lines) } _, lines = c.sendTagged(t, "LOGIN alice geheim123") if !strings.Contains(lines[len(lines)-1], "OK") { t.Fatalf("LOGIN fehlgeschlagen: %v", lines) } _, lines = c.sendTagged(t, "SELECT INBOX") if !containsSubstring(lines, "2 EXISTS") { t.Fatalf("SELECT: erwartete '2 EXISTS', habe: %v", lines) } _, lines = c.sendTagged(t, "FETCH 1 (FLAGS)") if !containsSubstring(lines, "FETCH (FLAGS") { t.Fatalf("FETCH: erwartete FLAGS-Antwort, habe: %v", lines) } tag, lines := c.sendTagged(t, "LOGOUT") if !containsSubstring(lines, "BYE") { t.Fatalf("LOGOUT: erwartete BYE, habe: %v", lines) } if !strings.HasPrefix(lines[len(lines)-1], tag+" OK") { t.Fatalf("LOGOUT: erwartete getaggtes OK, habe: %v", lines) } } // TestSession_InvalidCommandKeepsConnectionOpen ist die geforderte // Pflichtprüfung/AC3: ungültige Kommandosequenzen werden mit korrektem // Fehlercode abgelehnt, NICHT mit Verbindungsabbruch. func TestSession_InvalidCommandKeepsConnectionOpen(t *testing.T) { addr, stop := startTestServer(t) defer stop() c := dial(t, addr) defer c.close() _, lines := c.sendTagged(t, "FRIMBULATOR") if !strings.Contains(lines[len(lines)-1], "BAD") { t.Fatalf("erwartete BAD für unbekanntes kommando, habe: %v", lines) } // Verbindung muss danach real weiter funktionieren (kein Abbruch). _, lines = c.sendTagged(t, "CAPABILITY") if !strings.Contains(lines[len(lines)-1], "OK") { t.Fatalf("erwartete funktionierende verbindung nach ungültigem kommando, habe: %v", lines) } } // TestSession_MalformedLineDoesNotDisconnect deckt zusätzlich eine // syntaktisch fehlerhafte Zeile (kein Tag/Kommando erkennbar) ab. func TestSession_MalformedLineDoesNotDisconnect(t *testing.T) { addr, stop := startTestServer(t) defer stop() c := dial(t, addr) defer c.close() _, err := c.conn.Write([]byte("\"unterminated\r\n")) if err != nil { t.Fatalf("senden: %v", err) } line := c.readLine(t) if !strings.HasPrefix(line, "* BAD") { t.Fatalf("erwartete '* BAD' für fehlerhafte zeile, habe: %q", line) } _, lines := c.sendTagged(t, "CAPABILITY") if !strings.Contains(lines[len(lines)-1], "OK") { t.Fatalf("erwartete funktionierende verbindung nach fehlerhafter zeile, habe: %v", lines) } } func containsSubstring(lines []string, sub string) bool { for _, l := range lines { if strings.Contains(l, sub) { return true } } return false } // TestServer_50ParallelSessionsNoLeak ist die geforderte Pflichtprüfung // 3: Lasttest mit 50 parallelen Sessions ohne Ressourcenleck. func TestServer_50ParallelSessionsNoLeak(t *testing.T) { addr, stop := startTestServer(t) defer stop() const sessions = 50 var wg sync.WaitGroup errCh := make(chan error, sessions) for i := 0; i < sessions; i++ { wg.Add(1) go func() { defer wg.Done() conn, err := net.DialTimeout("tcp", addr, 3*time.Second) if err != nil { errCh <- err return } defer func() { _ = conn.Close() }() c := &imapClient{conn: conn, reader: bufio.NewReader(conn)} c.readLine(t) c.sendTagged(t, "LOGIN alice geheim123") c.sendTagged(t, "SELECT INBOX") c.sendTagged(t, "FETCH 1:2 (FLAGS)") c.sendTagged(t, "LOGOUT") }() } wg.Wait() close(errCh) for err := range errCh { t.Errorf("parallele sitzung fehlgeschlagen: %v", err) } }