package imap import ( "context" "net" "runtime" "strings" "testing" "time" "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" ) func startTestServerWithGuardConfig(t *testing.T, guardCfg protoguard.Config) (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 := 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(), 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++ { c := dial(t, addr) c.sendTagged(t, `LOGIN alice geheim123`) c.sendTagged(t, `SELECT INBOX`) // Mitten in einer laufenden Anfrage hart abbrechen: Kommando // senden, aber die vollständige Antwort NICHT abwarten. _, err := c.conn.Write([]byte("A99 FETCH 1:2 (FLAGS)\r\n")) if err != nil { t.Fatalf("kommando senden: %v", err) } _ = c.conn.Close() } deadline := time.Now().Add(3 * time.Second) for { runtime.GC() current := runtime.NumGoroutine() if current <= baseline+2 { 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{ phaseNotAuthenticated: 100 * time.Millisecond, phaseSelected: 100 * time.Millisecond, }, DefaultTimeout: 5 * time.Second, } t.Run("not_authenticated", func(t *testing.T) { addr, stop := startTestServerWithGuardConfig(t, cfg) defer stop() c := dial(t, addr) defer c.close() _ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) _, err := c.reader.ReadString('\n') if err == nil { t.Fatalf("erwartete Verbindungsende durch NotAuthenticated-Timeout") } }) t.Run("selected", func(t *testing.T) { addr, stop := startTestServerWithGuardConfig(t, cfg) defer stop() c := dial(t, addr) defer c.close() c.sendTagged(t, `LOGIN alice geheim123`) c.sendTagged(t, `SELECT INBOX`) // jetzt Selected, nichts weiter senden _ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) _, err := c.reader.ReadString('\n') if err == nil { t.Fatalf("erwartete Verbindungsende durch Selected-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() c := dial(t, addr) defer c.close() var attemptDurations []time.Duration for i := 0; i < 3; i++ { start := time.Now() _, lines := c.sendTagged(t, `LOGIN alice falsch`) last := lines[len(lines)-1] if !strings.Contains(last, "NO") { t.Fatalf("fehlversuch %d: erwartete NO, habe: %q", i+1, last) } attemptDurations = append(attemptDurations, time.Since(start)) } if attemptDurations[1] <= attemptDurations[0] { t.Fatalf("erwartete steigenden Backoff, habe Dauern: %v", attemptDurations) } // Nach MaxAuthFailures muss die Verbindung getrennt sein. _ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) if _, err := c.conn.Write([]byte("A99 LOGIN alice geheim123\r\n")); err == nil { _, err = c.reader.ReadString('\n') if err == nil { t.Fatalf("erwartete Verbindungstrennung nach %d Fehlversuchen", cfg.MaxAuthFailures) } } }