feat(mail): ARC-09 Postfach-Quota (unabhängig von Core LIC-05)
mailboxconfig (IMP-07) bekommt eine quota_bytes-Spalte statt einer eigenen Tabelle — ein Postfach ist bereits eindeutig über (tenant_slug, name) identifiziert. SetQuotaBytes/LimitBytes, 0 = unbegrenzt (Standardwert, keine Migration bestehender Postfächer nötig). LimitBytes erfüllt strukturell quota.LimitProvider. storage.ArchiveMailboxPrefix (ARC-04-Ergänzung, Präfix ALLER Jahre eines Postfachs) und storage.UsageCounter: realer Speicherverbrauch durch echtes S3-Listing im physisch getrennten Mandanten-Bucket (ARC-06) — kein separat gepflegter Zählerstand. Neues Paket mail/internal/quota: Checker verbindet LimitProvider und UsageProvider. Kein konfiguriertes Limit = immer erlaubt (Core-LIC-05- Quota läuft unabhängig weiter — beide Ebenen bewusst unabhängig durchgesetzt, bekannter Fehler vermieden). smtp.QuotaChecker (schmale Schnittstelle, keine Paketkopplung an quota) wird in handleRcptTo geprüft, VOR der Datenübertragung: 552 (RFC 5321 "exceeded storage allocation") bei Überschreitung, Session bleibt nutzbar. nil-Checker erhält bisheriges Verhalten unverändert. Alle drei Pflichtprüfungen mit echten Nachweisen: Quota-Überschreitung liefert 552, Session bleibt funktionsfähig; ein anderes Postfach desselben Tenants läuft währenddessen vollständig normal durch; vollständiger Ende-zu-Ende-Integrationstest gegen reale Postgres- und MinIO-Instanzen — 5000 echte Bytes abgelegt, real gemessen, Limit knapp darunter/darüber gesetzt, SMTP reagiert jeweils korrekt auf den tatsächlichen gemessenen Wert. Dabei einen echten Cleanup-Fehler gefunden und behoben (defer schloss den Pool vor dem zugehörigen t.Cleanup, verwaiste Testdaten blieben zurück). go build/go vet/golangci-lint clean, gesamtes Mail-Modul regressionsfrei getestet.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||
)
|
||||
|
||||
// fakeQuotaChecker ist ein In-Memory-Fake für QuotaChecker — reine
|
||||
// Protokolltests brauchen keine echte Postgres-/S3-Instanz.
|
||||
type fakeQuotaChecker struct {
|
||||
blocked map[string]bool // mailbox -> am limit
|
||||
}
|
||||
|
||||
func (f fakeQuotaChecker) Allowed(_ context.Context, _, mailbox string) (bool, error) {
|
||||
return !f.blocked[mailbox], nil
|
||||
}
|
||||
|
||||
func startQuotaTestServer(t *testing.T, sink MessageSink, checker QuotaChecker) (addr string, stop func()) {
|
||||
t.Helper()
|
||||
srv := NewServerWithMaxMessageBytesTLSLoggerRateLimitGuardAndQuota(
|
||||
sink, defaultMaxMessageBytes, nil, nil, "mandant-arc09", nil, protoguard.DefaultConfig(), checker,
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// TestRcptTo_QuotaExceededRejectedWithCorrectSMTPError ist die
|
||||
// geforderte Pflichtprüfung 1 (ARC-09): Postfach-Quota erreicht, neue
|
||||
// eingehende Mail wird mit korrekter SMTP-Fehlermeldung (552, RFC 5321
|
||||
// "exceeded storage allocation") abgelehnt, NICHT stillschweigend
|
||||
// verworfen.
|
||||
func TestRcptTo_QuotaExceededRejectedWithCorrectSMTPError(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
checker := fakeQuotaChecker{blocked: map[string]bool{"voll@example.com": true}}
|
||||
addr, stop := startQuotaTestServer(t, sink, checker)
|
||||
defer stop()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
c.send(t, "MAIL FROM:<a@example.com>")
|
||||
|
||||
resp := c.send(t, "RCPT TO:<voll@example.com>")
|
||||
if code(resp) != "552" {
|
||||
t.Fatalf("erwartete 552 (quota überschritten), habe: %q", resp)
|
||||
}
|
||||
if !strings.Contains(resp, "quota") {
|
||||
t.Fatalf("erwartete erkennbare quota-fehlermeldung, habe: %q", resp)
|
||||
}
|
||||
|
||||
// Session bleibt funktionsfähig — kein Verbindungsabbruch, keine
|
||||
// stillschweigende Verwerfung.
|
||||
if resp := c.send(t, "NOOP"); code(resp) != "250" {
|
||||
t.Fatalf("session nach quota-ablehnung nicht mehr funktionsfähig: %q", resp)
|
||||
}
|
||||
if sink.count() != 0 {
|
||||
t.Fatalf("erwartete keine angenommene nachricht bei quota-ablehnung, habe %d", sink.count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRcptTo_OtherMailboxUnaffectedWhenOneAtLimit ist die geforderte
|
||||
// Pflichtprüfung 2 (ARC-09): ein anderes Postfach desselben Tenants
|
||||
// empfängt weiterhin normal, während eines am Limit ist.
|
||||
func TestRcptTo_OtherMailboxUnaffectedWhenOneAtLimit(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
checker := fakeQuotaChecker{blocked: map[string]bool{"voll@example.com": true}}
|
||||
addr, stop := startQuotaTestServer(t, sink, checker)
|
||||
defer stop()
|
||||
|
||||
// Erste, VOLLSTÄNDIGE Transaktion an das Postfach am Limit —
|
||||
// abgelehnt.
|
||||
c1 := dial(t, addr)
|
||||
c1.send(t, "EHLO client.example.com")
|
||||
for {
|
||||
line := c1.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c1.send(t, "MAIL FROM:<a@example.com>")
|
||||
if resp := c1.send(t, "RCPT TO:<voll@example.com>"); code(resp) != "552" {
|
||||
t.Fatalf("erwartete 552 für volles postfach, habe: %q", resp)
|
||||
}
|
||||
c1.close()
|
||||
|
||||
// Zweite, unabhängige Transaktion an ein ANDERES Postfach desselben
|
||||
// Tenants — muss vollständig normal durchlaufen.
|
||||
c2 := dial(t, addr)
|
||||
defer c2.close()
|
||||
c2.send(t, "EHLO client.example.com")
|
||||
for {
|
||||
line := c2.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c2.send(t, "MAIL FROM:<a@example.com>")
|
||||
if resp := c2.send(t, "RCPT TO:<frei@example.com>"); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 für unbeschränktes postfach, habe: %q", resp)
|
||||
}
|
||||
if resp := c2.send(t, "DATA"); code(resp) != "354" {
|
||||
t.Fatalf("erwartete 354, habe: %q", resp)
|
||||
}
|
||||
if resp := c2.send(t, "Subject: test\r\n\r\nHallo\r\n."); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 nach abgeschlossener DATA, habe: %q", resp)
|
||||
}
|
||||
if sink.count() != 1 {
|
||||
t.Fatalf("erwartete 1 angenommene nachricht für das freie postfach, habe %d", sink.count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRcptTo_NoQuotaCheckerAllowsAll stellt sicher, dass ohne
|
||||
// konfigurierten QuotaChecker (nil, Rückwärtskompatibilität) alle
|
||||
// Empfänger wie vor ARC-09 akzeptiert werden.
|
||||
func TestRcptTo_NoQuotaCheckerAllowsAll(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
|
||||
defer stop()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
c.send(t, "MAIL FROM:<a@example.com>")
|
||||
if resp := c.send(t, "RCPT TO:<beliebig@example.com>"); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 ohne quotachecker, habe: %q", resp)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user