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.
135 lines
3.8 KiB
Go
135 lines
3.8 KiB
Go
package smtp
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit"
|
|
)
|
|
|
|
func startRateLimitedServer(t *testing.T, sink MessageSink, tenant string, limiter *ratelimit.Limiter) (addr string, stop func()) {
|
|
t.Helper()
|
|
srv := NewServerWithMaxMessageBytesTLSLoggerAndRateLimit(sink, defaultMaxMessageBytes, 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
|
|
}
|
|
}
|
|
|
|
// attemptMailFrom führt EHLO+MAIL FROM über eine NEUE Verbindung aus
|
|
// und liefert die MAIL FROM-Antwortzeile.
|
|
func attemptMailFrom(t *testing.T, addr, from string) string {
|
|
t.Helper()
|
|
c := dial(t, addr)
|
|
defer c.close()
|
|
c.send(t, "EHLO client.example.com")
|
|
for {
|
|
line := c.readLine(t)
|
|
if strings.HasPrefix(line, "250 ") {
|
|
break
|
|
}
|
|
}
|
|
return c.send(t, "MAIL FROM:<"+from+">")
|
|
}
|
|
|
|
// 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},
|
|
})
|
|
sink := &fakeSink{}
|
|
addr, stop := startRateLimitedServer(t, sink, "mandant-a", limiter)
|
|
defer stop()
|
|
|
|
const attempts = 20
|
|
var accepted, rejected int
|
|
for i := 0; i < attempts; i++ {
|
|
resp := attemptMailFrom(t, addr, "immer-gleicher-absender@example.com")
|
|
switch {
|
|
case code(resp) == "250":
|
|
accepted++
|
|
case code(resp) == "451" && 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).
|
|
func TestRateLimit_LegitUsageBelowThresholdUnaffected(t *testing.T) {
|
|
limiter := ratelimit.NewLimiter(ratelimit.StaticConfig{
|
|
Default: ratelimit.Config{Burst: 10, RefillEvery: time.Second},
|
|
})
|
|
sink := &fakeSink{}
|
|
addr, stop := startRateLimitedServer(t, sink, "mandant-a", limiter)
|
|
defer stop()
|
|
|
|
for i := 0; i < 3; i++ {
|
|
resp := attemptMailFrom(t, addr, "legitim@example.com")
|
|
if code(resp) != "250" {
|
|
t.Fatalf("versuch %d unterhalb der schwelle wurde abgelehnt: %q", i+1, resp)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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},
|
|
},
|
|
})
|
|
sinkKnapp := &fakeSink{}
|
|
addrKnapp, stopKnapp := startRateLimitedServer(t, sinkKnapp, "mandant-knapp", limiter)
|
|
defer stopKnapp()
|
|
sinkGross := &fakeSink{}
|
|
addrGross, stopGross := startRateLimitedServer(t, sinkGross, "mandant-grosszuegig", limiter)
|
|
defer stopGross()
|
|
|
|
var acceptedKnapp int
|
|
for i := 0; i < 10; i++ {
|
|
if code(attemptMailFrom(t, addrKnapp, "absender@example.com")) == "250" {
|
|
acceptedKnapp++
|
|
}
|
|
}
|
|
var acceptedGross int
|
|
for i := 0; i < 10; i++ {
|
|
if code(attemptMailFrom(t, addrGross, "absender@example.com")) == "250" {
|
|
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)
|
|
}
|
|
}
|