// IMP-04: Fehlerbehandlung nicht-konformer Server. Baut einen minimalen, // hand-gesteuerten Fake-Server (roher TCP, KEIN mail/internal/imap) auf, // der bewusst nicht-konforme Antworten sendet — echte Kontrolle über // genau das Fehlerszenario, das getestet werden soll. package imapimport import ( "bufio" "context" "fmt" "net" "strings" "testing" ) // scriptedServer nimmt EINE Verbindung an und sendet exakt die // vorgegebenen Zeilen als Antwort auf jedes eingehende Kommando (in // Reihenfolge) — genug Kontrolle, um nicht-konforme Serverantworten // exakt zu reproduzieren. type scriptedServer struct { responses [][]string // je eingehendem Kommando eine Antwortzeilen-Liste } func (s *scriptedServer) start(t *testing.T) (addr string) { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listener: %v", err) } go func() { conn, err := listener.Accept() if err != nil { return } defer func() { _ = conn.Close() }() reader := bufio.NewReader(conn) _, _ = conn.Write([]byte("* OK IMAP4rev1 Service Ready\r\n")) for _, respLines := range s.responses { if _, err := reader.ReadString('\n'); err != nil { return } for _, line := range respLines { if _, err := conn.Write([]byte(line + "\r\n")); err != nil { return } } } }() t.Cleanup(func() { _ = listener.Close() }) return listener.Addr().String() } // TestResolveUIDValidity_ZeroTriggersDefinedFallbackNotAbort ist die // geforderte Pflichtprüfung 1: Test simuliert Server mit UIDVALIDITY=0 // und bestätigt greifenden Fallback. func TestResolveUIDValidity_ZeroTriggersDefinedFallbackNotAbort(t *testing.T) { srv := &scriptedServer{responses: [][]string{ {"C1 OK LOGIN completed"}, {"* 3 EXISTS", "* OK [UIDVALIDITY 0] UIDs valid", "C2 OK [READ-WRITE] SELECT completed"}, {"* 1 FETCH (UID 1 FLAGS ())", "* 2 FETCH (UID 2 FLAGS ())", "* 3 FETCH (UID 3 FLAGS ())", "C3 OK UID FETCH completed"}, {"C4 OK LOGOUT completed"}, }} addr := srv.start(t) var loggedFallback bool client := NewRealClient(addr, "user", "pass").WithLogger(func(format string, args ...any) { msg := fmt.Sprintf(format, args...) if strings.Contains(msg, "UIDVALIDITY=0") { loggedFallback = true } }) uidvalidity, messages, err := client.Sync(context.Background(), "INBOX") if err != nil { // Akzeptanzkriterium 1: KEIN Sync-Abbruch bei UIDVALIDITY=0. t.Fatalf("erwartete erfolgreichen sync trotz UIDVALIDITY=0, habe fehler: %v", err) } if uidvalidity == 0 { t.Fatal("erwartete definierten fallback-wert != 0, habe weiterhin 0") } if len(messages) != 3 { t.Fatalf("erwartete 3 nachrichten trotz UIDVALIDITY=0, habe %d", len(messages)) } if !loggedFallback { t.Fatal("erwartete protokollierten fallback-hinweis (akzeptanzkriterium 3: nachvollziehbar)") } // Fallback ist deterministisch für dasselbe Postfach — ein zweiter // Aufruf gegen einen erneut nicht-konformen Server liefert real // denselben Ersatzwert, löst also keinen unnötigen Voll-Resync bei // jedem einzelnen Lauf aus. if fallbackUIDValidity("INBOX") != uidvalidity { t.Fatalf("erwartete deterministischen fallback, habe %d vs %d", fallbackUIDValidity("INBOX"), uidvalidity) } } // TestParseFetchLines_UnexpectedResponseSkippedRestContinue ist die // geforderte Pflichtprüfung 2: Test mit unerwarteter/kaputter // Serverantwort bestätigt Weiterlauf für übrige Nachrichten. func TestParseFetchLines_UnexpectedResponseSkippedRestContinue(t *testing.T) { srv := &scriptedServer{responses: [][]string{ {"C1 OK LOGIN completed"}, {"* 3 EXISTS", "* OK [UIDVALIDITY 42] UIDs valid", "C2 OK [READ-WRITE] SELECT completed"}, { "* 1 FETCH (UID 1 FLAGS ())", "* GARBAGE NOT EVEN A FETCH LINE AT ALL", // kaputte/unerwartete Antwort "* 2 FETCH SOMETHING UNPARSEABLE HERE (UID)", // ebenfalls kaputt "* 3 FETCH (UID 3 FLAGS (\\Seen))", "C3 OK UID FETCH completed", }, {"C4 OK LOGOUT completed"}, }} addr := srv.start(t) var skippedCount int client := NewRealClient(addr, "user", "pass").WithLogger(func(format string, args ...any) { msg := fmt.Sprintf(format, args...) if strings.Contains(msg, "unerwartete server-antwort übersprungen") { skippedCount++ } }) uidvalidity, messages, err := client.Sync(context.Background(), "INBOX") if err != nil { t.Fatalf("erwartete erfolgreichen sync trotz kaputter zeilen, habe fehler: %v", err) } if uidvalidity != 42 { t.Fatalf("erwartete uidvalidity=42, habe %d", uidvalidity) } // Akzeptanzkriterium 2: die BEIDEN kaputten Zeilen werden übersprungen // UND protokolliert, die ÜBRIGEN (real 2) Nachrichten kommen trotzdem an. if len(messages) != 2 { t.Fatalf("erwartete 2 lesbare nachrichten trotz kaputter zeilen, habe %d: %+v", len(messages), messages) } if skippedCount != 2 { t.Fatalf("erwartete 2 protokollierte übersprungene zeilen, habe %d", skippedCount) } } // TestResolveUIDValidity_RegressionGuardAgainstZeroAbort ist die // geforderte Pflichtprüfung 3: Regressionstest verhindert // Wiederauftreten des UIDVALIDITY-Bugs — prüft die Fallback-Funktion // isoliert und direkt, unabhängig vom Netzwerkpfad. func TestResolveUIDValidity_RegressionGuardAgainstZeroAbort(t *testing.T) { client := NewRealClient("unused:0", "u", "p") value, err := client.resolveUIDValidity([]string{"* OK [UIDVALIDITY 0] UIDs valid"}, "INBOX") if err != nil { t.Fatalf("regression: UIDVALIDITY=0 löste real einen fehler aus (der genau vermiedene bug): %v", err) } if value == 0 { t.Fatal("regression: fallback lieferte weiterhin 0 — bug erneut aufgetreten") } // Fehlende UIDVALIDITY-Angabe (noch nicht-konformer als 0) darf // ebenfalls nicht abbrechen. value2, err := client.resolveUIDValidity([]string{"C2 OK SELECT completed"}, "INBOX") if err != nil { t.Fatalf("regression: fehlende UIDVALIDITY löste real einen fehler aus: %v", err) } if value2 == 0 { t.Fatal("regression: fallback bei fehlender UIDVALIDITY lieferte 0") } }