Files
nexarch/mail/internal/pop3/tenant_scoping_test.go
T
sysops 12c9037121 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.
2026-09-01 09:35:45 +02:00

111 lines
3.5 KiB
Go

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