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.
130 lines
3.8 KiB
Go
130 lines
3.8 KiB
Go
package imap
|
|
|
|
import (
|
|
"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"}}
|
|
store := fakeMailboxStore{mailboxes: map[string][]Message{
|
|
"INBOX": {{SequenceNumber: 1, UID: 1, Flags: []string{}}},
|
|
}}
|
|
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
|
|
}
|
|
}
|
|
|
|
// attemptLogin führt LOGIN über eine NEUE Verbindung aus und liefert
|
|
// die Abschlusszeile.
|
|
func attemptLogin(t *testing.T, addr string) string {
|
|
t.Helper()
|
|
c := dial(t, addr)
|
|
defer c.close()
|
|
_, lines := c.sendTagged(t, "LOGIN alice geheim123")
|
|
return lines[len(lines)-1]
|
|
}
|
|
|
|
// TestRateLimit_LoadExceedingLimitGetsRejectedWithRetryHint ist die
|
|
// geforderte Pflichtprüfung 1 (ING-09).
|
|
func TestRateLimit_LoadExceedingLimitGetsRejectedWithRetryHint(t *testing.T) {
|
|
limiter := ratelimit.NewLimiter(ratelimit.StaticConfig{
|
|
Default: ratelimit.Config{Burst: 5, RefillEvery: time.Hour},
|
|
})
|
|
addr, stop := startRateLimitedServer(t, "mandant-a", limiter)
|
|
defer stop()
|
|
|
|
const attempts = 20
|
|
var accepted, rejected int
|
|
for i := 0; i < attempts; i++ {
|
|
last := attemptLogin(t, addr)
|
|
switch {
|
|
case strings.Contains(last, "OK"):
|
|
accepted++
|
|
case strings.Contains(last, "NO") && strings.Contains(last, "rate limit"):
|
|
rejected++
|
|
default:
|
|
t.Fatalf("unerwartete abschlussantwort: %q", last)
|
|
}
|
|
}
|
|
|
|
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).
|
|
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++ {
|
|
last := attemptLogin(t, addr)
|
|
if !strings.Contains(last, "OK") {
|
|
t.Fatalf("versuch %d unterhalb der schwelle wurde abgelehnt: %q", i+1, last)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRateLimit_PerTenantIndependentAndEffective ist die geforderte
|
|
// Pflichtprüfung 3 (ING-09).
|
|
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.Contains(attemptLogin(t, addrKnapp), "OK") {
|
|
acceptedKnapp++
|
|
}
|
|
}
|
|
var acceptedGross int
|
|
for i := 0; i < 10; i++ {
|
|
if strings.Contains(attemptLogin(t, addrGross), "OK") {
|
|
acceptedGross++
|
|
}
|
|
}
|
|
|
|
if acceptedKnapp != 2 {
|
|
t.Fatalf("mandant-knapp: erwartete 2 akzeptierte versuche, habe %d", acceptedKnapp)
|
|
}
|
|
if acceptedGross != 8 {
|
|
t.Fatalf("mandant-grosszuegig: erwartete 8 akzeptierte versuche, habe %d", acceptedGross)
|
|
}
|
|
}
|