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.
111 lines
3.5 KiB
Go
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")
|
|
}
|