package pop3 import ( "bufio" "context" "net" "runtime" "strings" "testing" "time" "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" ) func startTestServerWithGuardConfig(t *testing.T, guardCfg protoguard.Config) (addr string, store *fakeMailboxStore, stop func()) { t.Helper() auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}} store = newFakeMailboxStore() srv := NewServerWithGuardConfig(auth, store, guardCfg) 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 } } // TestGuard_ChaosHardCutDuringTransferNoLeak ist die geforderte // Pflichtprüfung 1 (ING-07): Verbindung wird während aktiver // Übertragung hart gekappt, danach kein Ressourcenleck. func TestGuard_ChaosHardCutDuringTransferNoLeak(t *testing.T) { addr, _, stop := startTestServerWithGuardConfig(t, protoguard.DefaultConfig()) defer stop() runtime.GC() baseline := runtime.NumGoroutine() const rounds = 30 for i := 0; i < rounds; i++ { conn, err := net.DialTimeout("tcp", addr, 2*time.Second) if err != nil { t.Fatalf("dial: %v", err) } reader := bufio.NewReader(conn) _, _ = reader.ReadString('\n') // Begrüßung _, _ = conn.Write([]byte("USER alice\r\n")) _, _ = reader.ReadString('\n') _, _ = conn.Write([]byte("PASS geheim123\r\n")) _, _ = reader.ReadString('\n') // Mitten in der Multiline-Übertragung (RETR-Antwortkopf gelesen, // Datenzeilen NICHT vollständig konsumiert) hart abbrechen. _, _ = conn.Write([]byte("RETR 1\r\n")) _, _ = reader.ReadString('\n') // nur die "+OK ... octets"-Kopfzeile _ = conn.Close() } // Server braucht kurz, um die abgebrochenen Sessions abzuräumen. deadline := time.Now().Add(3 * time.Second) for { runtime.GC() current := runtime.NumGoroutine() if current <= baseline+2 { // kleine Toleranz für Laufzeit-Jitter return } if time.Now().After(deadline) { t.Fatalf("goroutine-leck nach hartem Verbindungsabbruch: baseline=%d, aktuell=%d", baseline, current) } time.Sleep(50 * time.Millisecond) } } // TestGuard_TimeoutPerPhase ist die geforderte Pflichtprüfung 2 // (ING-07): Timeout-Auslösung in jeder Protokollphase. func TestGuard_TimeoutPerPhase(t *testing.T) { cfg := protoguard.Config{ PhaseTimeout: map[protoguard.Phase]time.Duration{ phaseAuthorization: 100 * time.Millisecond, phaseTransaction: 100 * time.Millisecond, }, DefaultTimeout: 5 * time.Second, } t.Run("authorization", func(t *testing.T) { addr, _, stop := startTestServerWithGuardConfig(t, cfg) defer stop() conn, err := net.DialTimeout("tcp", addr, 2*time.Second) if err != nil { t.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() reader := bufio.NewReader(conn) _, _ = reader.ReadString('\n') // Begrüßung, aber nichts weiter senden _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) _, err = reader.ReadString('\n') if err == nil { t.Fatalf("erwartete Verbindungsende durch Authorization-Timeout") } }) t.Run("transaction", func(t *testing.T) { addr, _, stop := startTestServerWithGuardConfig(t, cfg) defer stop() conn, err := net.DialTimeout("tcp", addr, 2*time.Second) if err != nil { t.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() reader := bufio.NewReader(conn) _, _ = reader.ReadString('\n') _, _ = conn.Write([]byte("USER alice\r\n")) _, _ = reader.ReadString('\n') _, _ = conn.Write([]byte("PASS geheim123\r\n")) _, _ = reader.ReadString('\n') // jetzt in Transaction, nichts weiter senden _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) _, err = reader.ReadString('\n') if err == nil { t.Fatalf("erwartete Verbindungsende durch Transaction-Timeout") } }) } // TestGuard_BackoffOnRepeatedAuthFailures ist die geforderte // Pflichtprüfung 3 (ING-07): Backoff-Verhalten bei wiederholten // Fehlversuchen statt Dauerschleife. func TestGuard_BackoffOnRepeatedAuthFailures(t *testing.T) { cfg := protoguard.Config{ DefaultTimeout: 5 * time.Second, MaxAuthFailures: 3, BackoffBase: 50 * time.Millisecond, BackoffMax: 500 * time.Millisecond, } addr, _, stop := startTestServerWithGuardConfig(t, cfg) defer stop() conn, err := net.DialTimeout("tcp", addr, 2*time.Second) if err != nil { t.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() reader := bufio.NewReader(conn) _, _ = reader.ReadString('\n') var attemptDurations []time.Duration for i := 0; i < 3; i++ { _, _ = conn.Write([]byte("USER alice\r\n")) _, _ = reader.ReadString('\n') start := time.Now() _, _ = conn.Write([]byte("PASS falsch\r\n")) _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) resp, err := reader.ReadString('\n') if err != nil { if i < 2 { t.Fatalf("fehlversuch %d: unerwarteter Verbindungsabbruch: %v", i+1, err) } // dritter Fehlversuch: Trennung nach der Antwort ist erlaubt. } else if !strings.Contains(resp, "-ERR") { t.Fatalf("fehlversuch %d: erwartete -ERR, habe: %q", i+1, resp) } attemptDurations = append(attemptDurations, time.Since(start)) } // Backoff steigt: der zweite Fehlversuch muss spürbar länger dauern // als der erste (Verdopplung statt konstanter/keiner Wartezeit). if attemptDurations[1] <= attemptDurations[0] { t.Fatalf("erwartete steigenden Backoff, habe Dauern: %v", attemptDurations) } // Nach MaxAuthFailures muss die Verbindung getrennt werden (kein // endloses erneutes USER/PASS erlaubt) statt in Dauerschleife. _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) if _, err := conn.Write([]byte("USER alice\r\n")); err == nil { _, err = reader.ReadString('\n') if err == nil { t.Fatalf("erwartete Verbindungstrennung nach %d Fehlversuchen", cfg.MaxAuthFailures) } } }