package pop3 import ( "bufio" "context" "net" "strings" "testing" "time" "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" "gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit" ) func startRateLimitedServer(t *testing.T, tenant string, limiter *ratelimit.Limiter) (addr string, stop func()) { t.Helper() auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123", "bob": "geheim456"}} store := newFakeMailboxStore() srv := NewServerWithGuardTLSLoggerAndRateLimit(auth, store, protoguard.DefaultConfig(), nil, nil, tenant, limiter) 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 } } // attemptPass führt USER+PASS über eine NEUE Verbindung aus und liefert // die PASS-Antwortzeile. func attemptPass(t *testing.T, addr, user, pass string) string { t.Helper() 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 " + user + "\r\n")) _, _ = reader.ReadString('\n') _, _ = conn.Write([]byte("PASS " + pass + "\r\n")) resp, err := reader.ReadString('\n') if err != nil { t.Fatalf("PASS antwort lesen: %v", err) } return resp } // TestRateLimit_LoadExceedingLimitGetsRejectedWithRetryHint ist die // geforderte Pflichtprüfung 1 (ING-09): Lasttest bestätigt greifendes // Limit bei Überschreitung — reale, gleichzeitige Anmeldeversuche über // den Burst hinaus. func TestRateLimit_LoadExceedingLimitGetsRejectedWithRetryHint(t *testing.T) { limiter := ratelimit.NewLimiter(ratelimit.StaticConfig{ Default: ratelimit.Config{Burst: 5, RefillEvery: time.Hour}, // Refill irrelevant für diesen Test }) addr, stop := startRateLimitedServer(t, "mandant-a", limiter) defer stop() const attempts = 20 var accepted, rejected int for i := 0; i < attempts; i++ { resp := attemptPass(t, addr, "alice", "geheim123") switch { case strings.HasPrefix(resp, "+OK"): accepted++ case strings.HasPrefix(resp, "-ERR") && strings.Contains(resp, "rate limit"): rejected++ default: t.Fatalf("unerwartete antwort: %q", resp) } } if accepted != 5 { t.Fatalf("erwartete genau 5 akzeptierte versuche (burst), habe %d", accepted) } if rejected != attempts-5 { t.Fatalf("erwartete %d abgelehnte versuche, habe %d", attempts-5, rejected) } } // TestRateLimit_LegitUsageBelowThresholdUnaffected ist die geforderte // Pflichtprüfung 2 (ING-09): legitime Nutzung unterhalb der Schwelle // bleibt unbeeinträchtigt. func TestRateLimit_LegitUsageBelowThresholdUnaffected(t *testing.T) { limiter := ratelimit.NewLimiter(ratelimit.StaticConfig{ Default: ratelimit.Config{Burst: 10, RefillEvery: time.Second}, }) addr, stop := startRateLimitedServer(t, "mandant-a", limiter) defer stop() for i := 0; i < 3; i++ { resp := attemptPass(t, addr, "alice", "geheim123") if !strings.HasPrefix(resp, "+OK") { t.Fatalf("versuch %d unterhalb der schwelle wurde abgelehnt: %q", i+1, resp) } } } // TestRateLimit_PerTenantIndependentAndEffective ist die geforderte // Pflichtprüfung 3 (ING-09): Limit ist je Mandant getrennt // konfigurierbar und wirksam — zwei Serverinstanzen (Mandant A/B) mit // UNTERSCHIEDLICHEM Burst, gegen DENSELBEN Limiter (realistisch: ein // zentraler Limiter-Prozess, mehrere Mandanten-Server). func TestRateLimit_PerTenantIndependentAndEffective(t *testing.T) { limiter := ratelimit.NewLimiter(ratelimit.StaticConfig{ Default: ratelimit.Config{Burst: 2, RefillEvery: time.Hour}, PerTenant: map[string]ratelimit.Config{ "mandant-grosszuegig": {Burst: 8, RefillEvery: time.Hour}, }, }) addrKnapp, stopKnapp := startRateLimitedServer(t, "mandant-knapp", limiter) defer stopKnapp() addrGross, stopGross := startRateLimitedServer(t, "mandant-grosszuegig", limiter) defer stopGross() var acceptedKnapp int for i := 0; i < 10; i++ { if strings.HasPrefix(attemptPass(t, addrKnapp, "alice", "geheim123"), "+OK") { acceptedKnapp++ } } var acceptedGross int for i := 0; i < 10; i++ { if strings.HasPrefix(attemptPass(t, addrGross, "alice", "geheim123"), "+OK") { acceptedGross++ } } if acceptedKnapp != 2 { t.Fatalf("mandant-knapp: erwartete 2 akzeptierte versuche (eigener burst), habe %d", acceptedKnapp) } if acceptedGross != 8 { t.Fatalf("mandant-grosszuegig: erwartete 8 akzeptierte versuche (eigener, größerer burst), habe %d", acceptedGross) } }