Neues Paket mail/internal/ratelimit: Token-Bucket je (Mandant, Quelle)-Schlüssel. ConfigProvider/StaticConfig liefern Burst/Nachfüllrate je Mandant mit Fallback auf eine Default-Konfiguration (Akzeptanzkriterium 2/3: begrenzt, konfigurierbar, je Mandant getrennt). Limiter.Allow liefert bei Ablehnung eine konkrete positive Wartezeit statt nur false — Grundlage für die geforderte definierte Ablehnung MIT Wartezeit-Hinweis statt Verbindungsabbruch (Akzeptanzkriterium 3). IMAP LOGIN und POP3 PASS begrenzen Anmeldeversuche pro (Mandant, Quell-IP). SMTP MAIL FROM begrenzt die Annahmerate pro (Mandant, Absenderadresse+Quell-IP). Bei Überschreitung: POP3 -ERR, IMAP NO, SMTP 451 (temporärer Fehlercode) mit Wartezeit in Sekunden, Verbindung bleibt in allen drei Fällen offen und weiter nutzbar. loginLimiter/ acceptLimiter sind optional (nil = deaktiviert, Rückwärtskompatibilität zu ING-01..ING-08) — neue Konstruktoren NewServerWithGuardTLSLoggerAndRateLimit (IMAP/POP3) und NewServerWithMaxMessageBytesTLSLoggerAndRateLimit (SMTP). Jeder Server bekommt eine tenantID, konsistent mit dem in ING-10 etablierten Muster "ein Server je Mandant". Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt, jeweils in IMAP, POP3 und SMTP einzeln: 20 reale Versuche bei Burst=5 -> exakt 5 akzeptiert, 15 mit Wartezeit-Hinweis abgelehnt, keine Verbindung getrennt; legitime Nutzung unterhalb der Schwelle unbeeinträchtigt; zwei Mandanten mit unterschiedlichem Burst über denselben Limiter unabhängig und exakt wirksam (2 vs. 8 akzeptierte Versuche). go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~31 Pakete) regressionsfrei getestet — insbesondere QA-07-Lasttests bleiben grün, da Rate-Limiting standardmäßig deaktiviert ist.
146 lines
4.6 KiB
Go
146 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|