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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user