Defensive Fehlerbehandlung für nicht-RFC-konforme Mailserver beim Import, mit dokumentierten Fallback-Pfaden statt Abbruch. - client_real.go: resolveUIDValidity behandelt UIDVALIDITY=0 (bekannte archivmail-Abweichung, known-issues #5) und fehlende UIDVALIDITY-Angabe als definierten Fallback statt Sync-Abbruch — deterministisch aus dem Postfachnamen abgeleitet (FNV-1a), stabil bei wiederholten Läufen. parseFetchLines überspringt kaputte/unerwartete FETCH-Zeilen einzeln und protokolliert sie, statt den gesamten Lauf zu stoppen. Neuer Logger/WithLogger für nachvollziehbares Support-Logging. - Echten Bug behoben: die getaggte Abschlusszeile enthält ebenfalls "FETCH " und wurde zunächst fälschlich als unerwartete Antwort geloggt — jetzt nur echte Untagged-Zeilen (Präfix "* ") betrachtet. Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-04-PRUEFPROTOKOLL.md): 1. TestResolveUIDValidity_ZeroTriggersDefinedFallbackNotAbort: Server meldet real UIDVALIDITY=0, Sync liefert real Fallback statt Fehler. 2. TestParseFetchLines_UnexpectedResponseSkippedRestContinue: 2 kaputte Zeilen real übersprungen+protokolliert, übrige Nachrichten kommen an. 3. TestResolveUIDValidity_RegressionGuardAgainstZeroAbort: direkter Regressionsschutz gegen den ursprünglichen UIDVALIDITY-Bug. Kein Umbau: imap/folderstate/scheduler.go unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
165 lines
5.9 KiB
Go
165 lines
5.9 KiB
Go
// 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")
|
|
}
|
|
}
|