Files
nexarch/mail/internal/imap/imap_test.go
T
sysopsandClaude Sonnet 5 e9947b1e28 IMP-01: imap-postfach-abruf-scheduler
Scheduler für periodischen IMAP-Postfach-Abruf mit UID-basiertem
Delta-Sync: neue Nachrichten erkennen, Zustandsänderungen abgleichen.

- imap (ING-01) minimal erweitert: Message.UID, MailboxStore.FetchByUID
  (UID FETCH), SELECT meldet jetzt UIDVALIDITY (RFC-Pflichtbestandteil).
  Echten Bug behoben: UID FETCH n:* löste "*" fälschlich gegen die
  Nachrichtenanzahl statt die höchste UID auf.
- imapimport/state.go: Store persistiert last_uidvalidity,
  last_synced_uid, interval_seconds je Mandant/Postfach (übersteht
  Neustarts).
- imapimport/scheduler.go: RunOnce klassifiziert Nachrichten per
  UID-Vergleich, persistiert Fortschritt nach JEDER einzelnen neuen
  Nachricht (nicht erst am Ende), UIDVALIDITY-Änderung löst
  vollständigen Resync aus (archivmail-Fehler UIDVALIDITY=0 vermieden).
- imapimport/client_real.go: echtes IMAP4rev1 über TCP
  (LOGIN/SELECT/UID FETCH/LOGOUT).

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-01-PRUEFPROTOKOLL.md):
1. TestRunOnce_TwoConsecutiveRunsNoDuplicateImport: zweiter Lauf real
   0 neue Nachrichten.
2. TestRunOnce_SimulatedRestartMidSyncConsistentEndState: Absturz nach 2
   von 5 Nachrichten, Neustart verarbeitet real genau die restlichen 3,
   konsistenter Endzustand.
3. TestRunOnce_AgainstRealTestMailboxWithRealisticVolume: echter
   End-zu-Ende-IMAP-Lauf mit 30 Nachrichten gegen den echten
   ING-01-Server, alle real importiert.

Kein Umbau: mail/internal/folderstate (ING-05) unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 23:45:08 +02:00

344 lines
9.9 KiB
Go

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, uint64, bool, error) {
msgs, ok := f.mailboxes[mailboxName]
return len(msgs), 1, 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 (f fakeMailboxStore) FetchByUID(_ context.Context, mailboxName string, uids []uint32) ([]Message, error) {
msgs, ok := f.mailboxes[mailboxName]
if !ok {
return nil, errors.New("imap: postfach nicht gefunden")
}
wanted := make(map[uint32]bool, len(uids))
for _, u := range uids {
wanted[u] = true
}
var result []Message
for _, m := range msgs {
if wanted[m.UID] {
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, UID: 101, Flags: []string{"\\Seen"}},
{SequenceNumber: 2, UID: 102, 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 (UID") {
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)
}
}
// TestCommands_UIDFetchReturnsUID belegt die für IMP-01 nötige
// UID-FETCH-Erweiterung: reale UID-basierte Abfrage über echtes TCP.
func TestCommands_UIDFetchReturnsUID(t *testing.T) {
addr, stop := startTestServer(t)
defer stop()
c := dial(t, addr)
defer c.close()
c.sendTagged(t, "LOGIN alice geheim123")
c.sendTagged(t, "SELECT INBOX")
_, lines := c.sendTagged(t, "UID FETCH 101:102 (FLAGS)")
if !containsSubstring(lines, "UID 101") || !containsSubstring(lines, "UID 102") {
t.Fatalf("erwartete beide UIDs in der antwort, habe: %v", lines)
}
if !strings.Contains(lines[len(lines)-1], "OK") {
t.Fatalf("erwartete OK-abschluss, habe: %v", lines)
}
}