feat(mail): ING-10 Ingestion-Testsuite — Tenant-Scoping-Tests, mimeparse-Lücke geschlossen

Kein neues Produktionspaket, Audit- und Test-Kachel über die fünf
Ingestion-Module (IMAP, POP3, SMTP, MIME, Folder-State). Zwei konkrete
Lücken geschlossen:

Neuer tenant_scoping_test.go in allen fünf Paketen: je zwei simulierte
Mandanten mit ABSICHTLICH identischen Schlüsseln (Benutzername,
Postfachname) — der Realfall, in dem ein fehlendes Scoping-Prädikat am
ehesten eine echte Vermischung zeigen würde, statt trivial durch
unterschiedliche Schlüssel zu bestehen. IMAP/POP3: zwei unabhängige
Serverinstanzen mit je eigenem Store. SMTP: zwei Serverinstanzen,
gleichzeitig mit vielen Nachrichten bedient. mimeparse: paralleles
Parsen vieler "Mandanten"-Nachrichten (das Paket hat keinen
Datenbankzugriff — Tenant-Scoping bedeutet hier: kein geteilter
veränderlicher Zustand). folderstate: echte Postgres-Instanz,
NextUID/Rebuild für Mandant A dürfen Mandant Bs Zustand nachweislich
nicht verändern.

mimeparse.ParseTolerant (IMP-02) war zu 0% Zeilenabdeckung vollständig
ungetestet — genau der aus known-issues-archivmail.md #4 bekannte
Fehler (kritische Ingestion-Logik ohne Tests). Neue tolerant_test.go:
ein fehlerhafter Teil reißt die übrigen nicht mit, Gesamtgrößenlimit
über alle Teile hinweg, strukturell kaputte Multipart-Hülle liefert
weiterhin einen echten Fehler, Nicht-Multipart-Pfad. Abdeckung
mimeparse 44,0% -> 76,7%.

Testabdeckungsbericht für alle fünf Module dokumentiert, CI-Lauf auf
frischem Checkout ohne externe Live-Postfächer verifiziert grün.
Pflichtprüfung 3 (Stichprobenreview durch zweite Person) ist durch
eine einzelne Sitzung strukturell nicht erfüllbar und bleibt offen —
im Prüfprotokoll dokumentiert, Nutzer-Review ausstehend.

go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~29 Pakete)
regressionsfrei getestet.
This commit is contained in:
sysops
2026-09-01 09:35:45 +02:00
parent 7c892ed10a
commit 12c9037121
7 changed files with 632 additions and 0 deletions
@@ -0,0 +1,89 @@
package folderstate
import (
"context"
"testing"
)
// TestTenantScoping_NeverReturnsOrMutatesOtherTenantsFolderState ist die
// geforderte Pflichtprüfung (ING-10, Akzeptanzkriterium 2): Tenant-
// Scoping für den Folder-State-Ingestion-Pfad. Zwei Mandanten mit
// IDENTISCHEM Postfachnamen "INBOX" — der Realfall, in dem ein fehlendes
// tenant_slug-Prädikat sofort eine Vermischung zeigen würde.
func TestTenantScoping_NeverReturnsOrMutatesOtherTenantsFolderState(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenantA := "mandant-ing10-scoping-a"
tenantB := "mandant-ing10-scoping-b"
t.Cleanup(func() {
_, _ = store.pool.Exec(context.Background(), `DELETE FROM mail_folder_state WHERE tenant_slug LIKE 'mandant-ing10-%'`)
_, _ = store.pool.Exec(context.Background(), `DELETE FROM mail_folder_state_events WHERE tenant_slug LIKE 'mandant-ing10-%'`)
})
stateA, err := store.GetOrCreate(ctx, tenantA, "INBOX")
if err != nil {
t.Fatalf("GetOrCreate mandant a: %v", err)
}
stateB, err := store.GetOrCreate(ctx, tenantB, "INBOX")
if err != nil {
t.Fatalf("GetOrCreate mandant b: %v", err)
}
if stateA.UIDValidity == stateB.UIDValidity {
// Extrem unwahrscheinlich (beide UIDVALIDITY sind
// Unix-Zeitstempel), aber falls doch: kein Blocker für den
// eigentlichen Isolationstest, nur ein Hinweis für den Leser.
t.Logf("hinweis: beide mandanten haben zufällig dieselbe uidvalidity bekommen (%d)", stateA.UIDValidity)
}
// UIDs für Mandant A vergeben — dürfen Mandant Bs Zustand NICHT
// verändern.
for i := 0; i < 5; i++ {
if _, err := store.NextUID(ctx, tenantA, "INBOX"); err != nil {
t.Fatalf("NextUID mandant a: %v", err)
}
}
afterA, err := store.CurrentState(ctx, tenantA, "INBOX")
if err != nil {
t.Fatalf("CurrentState mandant a: %v", err)
}
stillB, err := store.CurrentState(ctx, tenantB, "INBOX")
if err != nil {
t.Fatalf("CurrentState mandant b: %v", err)
}
if afterA.UIDNext != stateA.UIDNext+5 {
t.Fatalf("mandant a: erwartete UIDNext %d, habe %d", stateA.UIDNext+5, afterA.UIDNext)
}
if stillB.UIDNext != stateB.UIDNext {
t.Fatalf("mandantenvermischung: mandant b's UIDNext hat sich durch mandant a's NextUID-Aufrufe verändert (%d -> %d)", stateB.UIDNext, stillB.UIDNext)
}
// Rebuild für Mandant B darf Mandant As Zustand nicht berühren.
rebuiltB, err := store.Rebuild(ctx, tenantB, "INBOX")
if err != nil {
t.Fatalf("Rebuild mandant b: %v", err)
}
if rebuiltB.UIDValidity == stateB.UIDValidity {
t.Fatalf("Rebuild mandant b hat UIDVALIDITY nicht geändert")
}
unchangedA, err := store.CurrentState(ctx, tenantA, "INBOX")
if err != nil {
t.Fatalf("CurrentState mandant a nach Rebuild b: %v", err)
}
if unchangedA.UIDValidity != afterA.UIDValidity {
t.Fatalf("mandantenvermischung: mandant a's UIDVALIDITY hat sich durch mandant b's Rebuild verändert")
}
// Events sind ebenfalls strikt je Mandant getrennt.
eventsA, err := store.Events(ctx, tenantA, "INBOX")
if err != nil {
t.Fatalf("Events mandant a: %v", err)
}
for _, e := range eventsA {
if e.EventType == EventRebuilt {
t.Fatalf("mandant a hat mandant b's Rebuild-Event gesehen: %+v", e)
}
}
}
+71
View File
@@ -0,0 +1,71 @@
package imap
import (
"context"
"net"
"strings"
"testing"
)
// TestTenantScoping_IsolatedStoresNeverLeakAcrossServers ist die
// geforderte Pflichtprüfung (ING-10, Akzeptanzkriterium 2): Tenant-
// Scoping für den IMAP-Ingestion-Pfad. Zwei vollständig unabhängige
// Server-Instanzen (Mandant A/B) mit identischem Benutzernamen/Passwort
// und identischem Postfachnamen "INBOX", aber unterschiedlichem Inhalt
// (als Flag codiert, damit ein FETCH ihn sichtbar macht) — Bug würde
// sich hier als Vermischung der Flags zeigen.
func TestTenantScoping_IsolatedStoresNeverLeakAcrossServers(t *testing.T) {
auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
storeA := fakeMailboxStore{mailboxes: map[string][]Message{
"INBOX": {{SequenceNumber: 1, UID: 1, Flags: []string{"Mandant-A-Marker"}}},
}}
storeB := fakeMailboxStore{mailboxes: map[string][]Message{
"INBOX": {{SequenceNumber: 1, UID: 1, Flags: []string{"Mandant-B-Marker"}}},
}}
addrA, stopA := startIMAPServer(t, NewServer(auth, storeA))
defer stopA()
addrB, stopB := startIMAPServer(t, NewServer(auth, storeB))
defer stopB()
fetchA := fetchInboxFlags(t, addrA)
fetchB := fetchInboxFlags(t, addrB)
if !strings.Contains(fetchA, "Mandant-A-Marker") {
t.Fatalf("mandant A hat nicht seine eigenen daten bekommen: %q", fetchA)
}
if !strings.Contains(fetchB, "Mandant-B-Marker") {
t.Fatalf("mandant B hat nicht seine eigenen daten bekommen: %q", fetchB)
}
if strings.Contains(fetchA, "Mandant-B-Marker") || strings.Contains(fetchB, "Mandant-A-Marker") {
t.Fatalf("mandantenvermischung: A=%q B=%q", fetchA, fetchB)
}
}
func startIMAPServer(t *testing.T, srv *Server) (addr string, stop func()) {
t.Helper()
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
}
}
func fetchInboxFlags(t *testing.T, addr string) string {
t.Helper()
c := dial(t, addr)
defer c.close()
c.sendTagged(t, "LOGIN alice geheim123")
c.sendTagged(t, "SELECT INBOX")
_, lines := c.sendTagged(t, "FETCH 1 (FLAGS)")
return strings.Join(lines, "\n")
}
@@ -0,0 +1,62 @@
package mimeparse
import (
"fmt"
"strings"
"sync"
"testing"
)
// TestTenantScoping_ConcurrentParsesNeverMixContent ist die geforderte
// Pflichtprüfung (ING-10, Akzeptanzkriterium 2): Tenant-Scoping für den
// MIME-Ingestion-Pfad. mimeparse hält keinerlei Mandanten-Bezug oder
// Datenbankzugriff (reine Parsing-Funktion auf einem übergebenen
// io.Reader) — Tenant-Scoping bedeutet hier konkret: KEIN
// paketweiter, mandantenübergreifend geteilter veränderlicher Zustand,
// der bei gleichzeitigem Parsen mehrerer Mandanten-Nachrichten zu einer
// Vermischung führen könnte. Viele "Mandanten"-Nachrichten werden
// parallel geparst; jedes Ergebnis darf ausschließlich seinen eigenen
// Inhalt enthalten.
func TestTenantScoping_ConcurrentParsesNeverMixContent(t *testing.T) {
const tenants = 50
var wg sync.WaitGroup
errs := make(chan error, tenants)
for i := 0; i < tenants; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
marker := fmt.Sprintf("Mandant-%02d-Geheiminhalt", n)
raw := "Content-Type: text/plain; charset=utf-8\r\n\r\n" + marker
msg, err := Parse(strings.NewReader(raw), 1<<20)
if err != nil {
errs <- fmt.Errorf("mandant %d: parse fehlgeschlagen: %w", n, err)
return
}
if len(msg.Parts) != 1 {
errs <- fmt.Errorf("mandant %d: erwartete 1 teil, habe %d", n, len(msg.Parts))
return
}
content := string(msg.Parts[0].Content)
if !strings.Contains(content, marker) {
errs <- fmt.Errorf("mandant %d: eigener inhalt fehlt: %q", n, content)
return
}
for j := 0; j < tenants; j++ {
if j == n {
continue
}
fremderMarker := fmt.Sprintf("Mandant-%02d-Geheiminhalt", j)
if strings.Contains(content, fremderMarker) {
errs <- fmt.Errorf("mandant %d: fremder inhalt gefunden (mandant %d): %q", n, j, content)
return
}
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
}
+110
View File
@@ -0,0 +1,110 @@
package mimeparse
import (
"errors"
"strings"
"testing"
)
// TestParseTolerant_SingleBrokenPartDoesNotAbortWholeMessage ist die
// geforderte Pflichtprüfung/Lücke (ING-10): ParseTolerant war bislang
// vollständig ungetestet (0% Abdeckung) — genau der aus
// known-issues-archivmail.md #4 bekannte Fehler (kritische
// Ingestion-Logik ohne Tests). Ein Anhang, der die Größenbegrenzung
// überschreitet, darf die übrigen Teile NICHT mit sich reißen
// (Akzeptanzkriterium 3 des ursprünglichen Tickets IMP-02).
func TestParseTolerant_SingleBrokenPartDoesNotAbortWholeMessage(t *testing.T) {
raw := "From: a@example.com\r\n" +
"Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" +
"--b\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n\r\n" +
"Guter Teil\r\n" +
"--b\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Disposition: attachment; filename=\"zu-gross.bin\"\r\n\r\n" +
strings.Repeat("x", 1000) + "\r\n" +
"--b\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n\r\n" +
"Zweiter guter Teil\r\n" +
"--b--\r\n"
msg, partErrors, err := ParseTolerant(strings.NewReader(raw), 100, defaultMaxSize)
if err != nil {
t.Fatalf("ParseTolerant: unerwarteter gesamtfehler: %v", err)
}
if len(partErrors) != 1 {
t.Fatalf("erwartete genau 1 teilfehler (überdimensionierter anhang), habe %d: %+v", len(partErrors), partErrors)
}
if len(msg.Parts) != 2 {
t.Fatalf("erwartete 2 verarbeitete teile trotz des fehlerhaften anhangs, habe %d", len(msg.Parts))
}
if string(msg.Parts[0].Content) != "Guter Teil" || string(msg.Parts[1].Content) != "Zweiter guter Teil" {
t.Fatalf("unerwarteter inhalt der verbleibenden teile: %+v", msg.Parts)
}
}
// TestParseTolerant_TotalSizeBudgetEnforcedAcrossParts ist
// Akzeptanzkriterium 2 des ursprünglichen Tickets IMP-02: ein
// Gesamtgrößenlimit über ALLE Teile hinweg, zusätzlich zum
// Je-Anhang-Limit.
func TestParseTolerant_TotalSizeBudgetEnforcedAcrossParts(t *testing.T) {
raw := "From: a@example.com\r\n" +
"Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" +
"--b\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Disposition: attachment; filename=\"a.bin\"\r\n\r\n" +
strings.Repeat("x", 60) + "\r\n" +
"--b\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Disposition: attachment; filename=\"b.bin\"\r\n\r\n" +
strings.Repeat("y", 60) + "\r\n" +
"--b--\r\n"
// Je-Anhang-Limit großzügig (100), Gesamtlimit knapp (80) — der
// zweite Anhang muss am GESAMTLIMIT scheitern, nicht am
// Je-Anhang-Limit.
msg, partErrors, err := ParseTolerant(strings.NewReader(raw), 100, 80)
if err != nil {
t.Fatalf("ParseTolerant: unerwarteter gesamtfehler: %v", err)
}
if len(msg.Parts) != 1 {
t.Fatalf("erwartete genau 1 teil innerhalb des gesamtbudgets, habe %d", len(msg.Parts))
}
if len(partErrors) != 1 || !errors.Is(partErrors[0].Err, ErrMessageTooLarge) {
t.Fatalf("erwartete genau 1 ErrMessageTooLarge-teilfehler, habe: %+v", partErrors)
}
}
// TestParseTolerant_StructurallyBrokenMultipartStillFails belegt: nur
// eine strukturell unlesbare Hülle (fehlende Boundary) liefert
// weiterhin einen echten Gesamtfehler — kein Teil-für-Teil-Fallback
// möglich, wie im Code dokumentiert.
func TestParseTolerant_StructurallyBrokenMultipartStillFails(t *testing.T) {
raw := "From: a@example.com\r\n" +
"Content-Type: multipart/mixed\r\n\r\n" + // keine boundary=... angegeben
"irgendwas"
_, _, err := ParseTolerant(strings.NewReader(raw), 100, defaultMaxSize)
if err == nil {
t.Fatalf("erwartete fehler bei multipart ohne boundary")
}
}
// TestParseTolerant_NonMultipartSinglePart deckt den Nicht-Multipart-
// Pfad von ParseTolerant ab (bislang ebenfalls ungetestet).
func TestParseTolerant_NonMultipartSinglePart(t *testing.T) {
raw := "From: a@example.com\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n\r\n" +
"Einfache Nachricht ohne Multipart"
msg, partErrors, err := ParseTolerant(strings.NewReader(raw), defaultMaxSize, defaultMaxSize)
if err != nil {
t.Fatalf("ParseTolerant: %v", err)
}
if len(partErrors) != 0 {
t.Fatalf("unerwartete teilfehler: %+v", partErrors)
}
if len(msg.Parts) != 1 || string(msg.Parts[0].Content) != "Einfache Nachricht ohne Multipart" {
t.Fatalf("unerwartetes ergebnis: %+v", msg.Parts)
}
}
+110
View File
@@ -0,0 +1,110 @@
package pop3
import (
"bufio"
"context"
"net"
"strings"
"testing"
"time"
)
// tenantScopedMailboxStore ist ein In-Memory-Postfachspeicher EINES
// Mandanten — bewusst eine eigene, unabhängige Instanz je Mandant statt
// eines gemeinsamen Stores mit tenant-Parameter, um die
// Pflichtprüfung realistisch nachzustellen: der POP3-Server bekommt
// beim Aufbau NUR den Store des eigenen Mandanten injiziert und hat
// strukturell keinen Zugriff auf den eines anderen (Akzeptanzkriterium
// 2, ING-10).
func newTenantScopedStore(tenant string) *fakeMailboxStore {
return &fakeMailboxStore{messages: map[string]map[int]string{
"alice": {1: "Geheime Nachricht von Mandant " + tenant},
}}
}
// TestTenantScoping_IsolatedStoresNeverLeakAcrossServers ist die
// geforderte Pflichtprüfung (ING-10, Akzeptanzkriterium 2): Tenant-
// Scoping für den POP3-Ingestion-Pfad. Zwei vollständig unabhängige
// Server-Instanzen (Mandant A/B) mit IDENTISCHEM Benutzernamen "alice"
// und IDENTISCHEM Passwort, aber unterschiedlichem Postfachinhalt —
// der Klartext-Realfall, in dem ein Bug am ehesten eine Vermischung
// zeigen würde.
func TestTenantScoping_IsolatedStoresNeverLeakAcrossServers(t *testing.T) {
authA := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
authB := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
storeA := newTenantScopedStore("A")
storeB := newTenantScopedStore("B")
addrA, stopA := startPOP3Server(t, NewServer(authA, storeA))
defer stopA()
addrB, stopB := startPOP3Server(t, NewServer(authB, storeB))
defer stopB()
contentFromA := retrieveFirstMessage(t, addrA, "alice", "geheim123")
contentFromB := retrieveFirstMessage(t, addrB, "alice", "geheim123")
if !strings.Contains(contentFromA, "Mandant A") {
t.Fatalf("mandant A hat nicht seine eigene nachricht bekommen: %q", contentFromA)
}
if !strings.Contains(contentFromB, "Mandant B") {
t.Fatalf("mandant B hat nicht seine eigene nachricht bekommen: %q", contentFromB)
}
if strings.Contains(contentFromA, "Mandant B") || strings.Contains(contentFromB, "Mandant A") {
t.Fatalf("mandantenvermischung: A=%q B=%q", contentFromA, contentFromB)
}
}
func startPOP3Server(t *testing.T, srv *Server) (addr string, stop func()) {
t.Helper()
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
}
}
func retrieveFirstMessage(t *testing.T, addr, username, password 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 " + username + "\r\n"))
_, _ = reader.ReadString('\n')
_, _ = conn.Write([]byte("PASS " + password + "\r\n"))
resp, _ := reader.ReadString('\n')
if !strings.HasPrefix(resp, "+OK") {
t.Fatalf("anmeldung fehlgeschlagen: %q", resp)
}
_, _ = conn.Write([]byte("RETR 1\r\n"))
status, _ := reader.ReadString('\n')
if !strings.HasPrefix(status, "+OK") {
t.Fatalf("RETR fehlgeschlagen: %q", status)
}
var lines []string
for {
line, _ := reader.ReadString('\n')
line = strings.TrimRight(line, "\r\n")
if line == "." {
break
}
lines = append(lines, line)
}
_, _ = conn.Write([]byte("QUIT\r\n"))
_, _ = reader.ReadString('\n')
return strings.Join(lines, "\n")
}
+72
View File
@@ -0,0 +1,72 @@
package smtp
import (
"strings"
"sync"
"testing"
)
// TestTenantScoping_ConcurrentServersNeverMixMessages ist die
// geforderte Pflichtprüfung (ING-10, Akzeptanzkriterium 2): Tenant-
// Scoping für den SMTP-Ingestion-Pfad. Zwei vollständig unabhängige
// Server-Instanzen (Mandant A/B), GLEICHZEITIG mit vielen Nachrichten
// bedient — jede Instanz bekommt nur ihren eigenen Sink injiziert.
// Eine Vermischung würde sich hier als falscher Nachrichteninhalt beim
// jeweils anderen Sink zeigen.
func TestTenantScoping_ConcurrentServersNeverMixMessages(t *testing.T) {
sinkA := &fakeSink{}
sinkB := &fakeSink{}
addrA, stopA := startTestServer(t, sinkA, defaultMaxMessageBytes)
defer stopA()
addrB, stopB := startTestServer(t, sinkB, defaultMaxMessageBytes)
defer stopB()
const perTenant = 20
var wg sync.WaitGroup
for i := 0; i < perTenant; i++ {
wg.Add(2)
go func(n int) {
defer wg.Done()
sendTenantMessage(t, addrA, "Mandant-A")
}(i)
go func(n int) {
defer wg.Done()
sendTenantMessage(t, addrB, "Mandant-B")
}(i)
}
wg.Wait()
if sinkA.count() != perTenant {
t.Fatalf("mandant A: erwartete %d nachrichten, habe %d", perTenant, sinkA.count())
}
if sinkB.count() != perTenant {
t.Fatalf("mandant B: erwartete %d nachrichten, habe %d", perTenant, sinkB.count())
}
for _, m := range sinkA.accepted {
if !strings.Contains(string(m.raw), "Mandant-A") || strings.Contains(string(m.raw), "Mandant-B") {
t.Fatalf("mandant A hat fremden/vermischten inhalt bekommen: %q", m.raw)
}
}
for _, m := range sinkB.accepted {
if !strings.Contains(string(m.raw), "Mandant-B") || strings.Contains(string(m.raw), "Mandant-A") {
t.Fatalf("mandant B hat fremden/vermischten inhalt bekommen: %q", m.raw)
}
}
}
func sendTenantMessage(t *testing.T, addr, marker 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
}
}
c.send(t, "MAIL FROM:<a@example.com>")
c.send(t, "RCPT TO:<b@example.com>")
c.send(t, "DATA")
c.send(t, "Subject: "+marker+"\r\n\r\nInhalt von "+marker+"\r\n.")
}