Files
nexarch/mail/internal/smtp/arc09_quota_test.go
T
sysops 1825387603 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.
2026-09-02 23:47:32 +02:00

156 lines
4.7 KiB
Go

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)
}
}