MAI-01: Postfach-Konfiguration neu anlegen #256

Merged
patrick merged 1 commits from feature/mai-01-postfach-konfiguration-neu-anlegen into main 2026-10-13 14:10:31 +02:00
5 changed files with 606 additions and 0 deletions

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
# MAI-01 – Prüfprotokoll: Postfach-Konfiguration neu anlegen
## Umfang
`mail/internal/mailboxmigration` übernimmt aktive Postfach-Konfigurationen
aus archivmail als Struktur (Server, Ordner, Zuordnung) nach `mail`
IMP-07 (`mail/internal/mailboxconfig`). Zugangsdaten werden nie aus dem
Altsystem kopiert: `LegacyMailbox` besitzt bewusst kein Passwort-Feld —
die Migration verlangt für jedes Postfach ein vom Betreiber bei der
Migration neu vergebenes Passwort (`NewCredential`), das vor der Anlage
erfolgreich gegen die neue Verbindung getestet wird.
## Substitution: synthetischer Testserver statt echtem archivmail
Diese Umgebung darf laut Auftrag keine Verbindung zu einem echten
archivmail-Server aufbauen (DSGVO-Vorgabe, Testdaten müssen synthetisch
sein). Als Ersatz für den IMAP-Zielserver wird der bereits im
Mail-Modul für IMP-01/IMP-04 verwendete **echte In-Process-IMAP4rev1-
Server** (`mail/internal/imap`, STARTTLS, Authentifizierung,
SELECT/FETCH) mit selbstsigniertem Testzertifikat gestartet — kein
Mock mit vorgetäuschtem Ergebnis, sondern ein waches Wire-Protokoll-Ende,
gegen das der Verbindungstest (`IMAPConnectionTester`, nutzt
`mail/internal/imapimport.RealClient`) einen echten LOGIN+SELECT+FETCH-
Zyklus durchführt. Alle Postfach-Strukturdaten (Host, Port, Username,
Tenant-Slug, Ordner) sind frei erfundene Platzhalterwerte
(`beispiel.example`), keine Daten aus einem echten Alt- oder Kundensystem.
Die Zieldatenbank ist eine eigens für diesen Prüflauf angelegte,
geworfene Postgres-Rolle/DB auf dem Testhost 192.168.1.204
(`mai01_test`/`mai01_testdb`), je Testfall in einem frischen Schema
(`mail/internal/testdb`-Konvention) — keine Berührung von Host 131 oder
archivdms-Daten.
## Prüfungen
### Prüfung 1 – Verbindungstest für jede migrierte Postfach-Konfiguration erfolgreich
`TestMigrate_StructureTakenOverCredentialsNewlyTested`
(`mail/internal/mailboxmigration/mailboxmigration_test.go`): Migration
eines Postfachs mit korrektem neu vergebenem Passwort gegen den echten
IMAP-Testserver — `Migrate()` läuft durch, die Konfiguration erscheint
in `mailboxconfig.Store.List`, das gespeicherte (entschlüsselte)
Passwort ist exakt das neu vergebene Testpasswort.
`TestMigrate_FailedConnectionTestBlocksCreation`: Gegenprobe — falsches
neu vergebenes Passwort lässt den Verbindungstest fehlschlagen,
`Migrate()` liefert einen Fehler, UND es wird keine Postfach-
Konfiguration angelegt (`List` liefert 0 Einträge). Bestätigt, dass der
Verbindungstest VOR der Anlage läuft, nicht nachträglich.
**Ergebnis: bestanden** (`go test ./internal/mailboxmigration/... -v`,
Host 192.168.1.204, `TEST_TENANT_DSN` gegen die geworfene `mai01_testdb`).
### Prüfung 2 – Stichprobenkontrolle: keine Zugangsdaten-Felder aus dem Altsystem befüllt
`TestMigrate_NoCredentialColumnsPopulatedFromLegacy`: echter Scan statt
Behauptung —
1. `information_schema.columns` der migrierten Tabelle `mail_mailboxes`
wird auf alle Spalten mit `%pass%`/`%credential%`/`%secret%` im Namen
abgefragt; erwartet werden GENAU `wrapped_password_dek` und
`encrypted_password` (beide Envelope-verschlüsselt, ARC-02-Muster) —
keine zusätzliche Klartext-Spalte.
2. Die migrierte Zeile wird gelesen und der rohe `encrypted_password`-
Chiffretext auf ein Alt-System-Sentinel (`ALTSYSTEM-WAERE-HIER`)
geprüft, das NIE übergeben wurde — Kontaminationsprobe.
3. Strukturell zusätzlich durch den Compiler erzwungen: `LegacyMailbox`
(der einzige Typ, über den archivmail-Daten in die Migration
gelangen) hat kein Passwort-/Zugangsdaten-Feld — ein Codepfad, der
ein Altsystem-Passwort kopiert, kann gar nicht existieren, ohne den
Typ zu ändern.
**Ergebnis: bestanden**, keine Zugangsdaten-Felder aus dem Altsystem in
der Migrationsdatenbank befüllt.
### Prüfung 3 – jede neu angelegte Verbindung vor Abschluss erfolgreich getestet
Abgedeckt durch Prüfung 1 (positiv: erfolgreicher Test vor Anlage) und
die Gegenprobe (negativ: fehlgeschlagener Test verhindert Anlage).
Zusätzlich `TestMigrate_MissingCredentialRefusesLegacyCopy`: ohne
überhaupt ein neu vergebenes Passwort bricht `Migrate()` ab
(`ErrMissingCredential`) — es gibt keinen impliziten Fallback, der ein
Postfach ohne Verbindungstest anlegen würde.
**Ergebnis: bestanden.**
## Build/Vet
`go build ./...` und `go vet ./...` im Modul `mail` laufen ohne
Fehler/Hinweise durch (Host 192.168.1.204). Bestehende Tests in
`mail/internal/mailboxconfig` (IMP-07) bleiben unverändert grün.
## Offene Punkte / Entscheidungen
- Die Zuordnung "welches neue Passwort gehört zu welchem Altsystem-
Postfach" (`NewCredential.LegacyID`) muss vom Betreiber bereitgestellt
werden — Umfang dieses Tickets ist die Migrationslogik, nicht die
Beschaffung/Verteilung neuer Zugangsdaten (liegt außerhalb der
Akzeptanzkriterien von MAI-01).
- Eine konkrete `LegacySource`-Implementierung gegen die reale
archivmail-Datenbank ist bewusst NICHT Teil dieses Tickets (keine
Verbindung zu echten Altsystemen laut Auftrag) — das Interface ist so
geschnitten, dass eine solche Implementierung ausschließlich
Struktur-Spalten selektieren kann (kein Zugangsdaten-Feld im
Rückgabetyp).
- Übernahme von Nachrichten/Anhängen (Objekt-Storage-Kopie,
Referenz-Rewrite) sowie `indexed_at`-Statusfelder sind laut
Produkt-DNA ausdrücklich außerhalb dieses Tickets (MAI-02).
@@ -0,0 +1,29 @@
package mailboxmigration
import (
"context"
"crypto/tls"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/imapimport"
)
// IMAPConnectionTester prüft eine Postfach-Verbindung über einen echten
// IMAP4rev1-Lauf (imapimport.RealClient: LOGIN+SELECT+FETCH gegen den
// Zielserver) statt über einen bloßen TCP-Connect-Check.
type IMAPConnectionTester struct {
TLSConfig *tls.Config
AllowPlaintext bool
}
func (t IMAPConnectionTester) TestConnection(ctx context.Context, host string, port int, username, password string) error {
addr := fmt.Sprintf("%s:%d", host, port)
client := imapimport.NewRealClient(addr, username, password)
if t.AllowPlaintext {
client = client.WithInsecureAllowPlaintextForTests()
} else {
client = client.WithTLSConfig(t.TLSConfig)
}
_, _, err := client.Sync(ctx, "INBOX")
return err
}
@@ -0,0 +1,125 @@
// Package mailboxmigration implementiert MAI-01: Postfach-Konfigurationen
// aus archivmail werden als Struktur (Server, Ordner, Zuordnung) nach
// mail IMP-07 übernommen. Zugangsdaten werden NIE aus dem Altsystem
// kopiert (Produkt-DNA) — LegacyMailbox hat deshalb bewusst kein
// Passwort-Feld, und jede migrierte Verbindung bekommt ein vom
// Betreiber bei der Migration neu vergebenes Passwort, das vor Abschluss
// erfolgreich getestet wird.
package mailboxmigration
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailboxconfig"
)
// LegacyMailbox ist eine Postfach-Konfiguration aus archivmail.
type LegacyMailbox struct {
LegacyID int64
TenantSlug string
Name string
IMAPHost string
IMAPPort int
IMAPUsername string
FolderSelection []string
IntervalSeconds int
Active bool
}
// LegacySource liefert die aktiven Postfach-Konfigurationen aus
// archivmail. Eine Implementierung darf keine Zugangsdaten-Spalte des
// Altsystems selektieren.
type LegacySource interface {
ActiveMailboxes(ctx context.Context) ([]LegacyMailbox, error)
}
// ConnectionTester prüft eine neu angelegte Verbindung, bevor die
// Migration eines Postfachs als abgeschlossen gilt (Akzeptanzkriterium 3).
type ConnectionTester interface {
TestConnection(ctx context.Context, host string, port int, username, password string) error
}
// ConfigStore ist der für die Migration benötigte Ausschnitt von
// mailboxconfig.Store.
type ConfigStore interface {
Create(ctx context.Context, tenantSlug string, in mailboxconfig.CreateInput) (int64, error)
}
// NewCredential ist ein vom Betreiber bei der Migration neu vergebenes
// Passwort für genau ein Postfach — niemals aus dem Altsystem übernommen.
type NewCredential struct {
LegacyID int64
Password string
}
// ErrMissingCredential wird geliefert, wenn für ein aktives
// Altsystem-Postfach kein neu vergebenes Passwort übergeben wurde.
var ErrMissingCredential = errors.New("mailboxmigration: kein neu vergebenes passwort für dieses postfach")
type MigratedMailbox struct {
LegacyID int64
NewID int64
Name string
}
type Result struct {
Migrated []MigratedMailbox
}
type Migrator struct {
source LegacySource
store ConfigStore
tester ConnectionTester
}
func NewMigrator(source LegacySource, store ConfigStore, tester ConnectionTester) *Migrator {
return &Migrator{source: source, store: store, tester: tester}
}
// Migrate übernimmt jede aktive Altsystem-Postfach-Struktur nach
// IMP-07 (Akzeptanzkriterium 1). Für jedes Postfach muss unter
// credentials ein neu vergebenes Passwort vorliegen; dieses wird vor dem
// Anlegen gegen die neue Verbindung getestet (Akzeptanzkriterium 3) und
// erst danach über mailboxconfig.Store.Create verschlüsselt gespeichert
// (Akzeptanzkriterium 2: keine Altsystem-Zugangsdaten, LegacyMailbox
// trägt gar kein Passwort-Feld).
func (m *Migrator) Migrate(ctx context.Context, credentials []NewCredential) (Result, error) {
credByLegacyID := make(map[int64]string, len(credentials))
for _, c := range credentials {
credByLegacyID[c.LegacyID] = c.Password
}
legacyBoxes, err := m.source.ActiveMailboxes(ctx)
if err != nil {
return Result{}, fmt.Errorf("mailboxmigration: aktive postfächer lesen: %w", err)
}
var res Result
for _, lb := range legacyBoxes {
password, ok := credByLegacyID[lb.LegacyID]
if !ok || password == "" {
return res, fmt.Errorf("%w: postfach %q (legacy-id %d)", ErrMissingCredential, lb.Name, lb.LegacyID)
}
if err := m.tester.TestConnection(ctx, lb.IMAPHost, lb.IMAPPort, lb.IMAPUsername, password); err != nil {
return res, fmt.Errorf("mailboxmigration: verbindungstest für postfach %q fehlgeschlagen: %w", lb.Name, err)
}
newID, err := m.store.Create(ctx, lb.TenantSlug, mailboxconfig.CreateInput{
Name: lb.Name,
IMAPHost: lb.IMAPHost,
IMAPPort: lb.IMAPPort,
IMAPUsername: lb.IMAPUsername,
Password: password,
FolderSelection: lb.FolderSelection,
IntervalSeconds: lb.IntervalSeconds,
})
if err != nil {
return res, fmt.Errorf("mailboxmigration: postfach %q anlegen: %w", lb.Name, err)
}
res.Migrated = append(res.Migrated, MigratedMailbox{LegacyID: lb.LegacyID, NewID: newID, Name: lb.Name})
}
return res, nil
}
@@ -0,0 +1,240 @@
// Integrationstest (MAI-01): echte Postgres-Instanz (TEST_TENANT_DSN,
// gleiche Konvention wie mailboxconfig/store_test.go) plus ein echter
// In-Process-IMAP4rev1-Testserver (mail/internal/imap, bereits im
// Mail-Modul für IMP-01/IMP-04 genutzt) als Ersatz für einen echten
// archivmail-Server, der in dieser Umgebung nicht zur Verfügung steht —
// bewusste Substitution, kein Verbindungstest mit Fake-Ergebnis.
package mailboxmigration
import (
"bytes"
"context"
"crypto/tls"
"net"
"os"
"testing"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/imap"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailboxconfig"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/testdb"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
"github.com/jackc/pgx/v5/pgxpool"
)
type fakeKEKProvider struct{}
func (fakeKEKProvider) TenantKEK(_ context.Context, _ string) ([]byte, error) {
return bytes.Repeat([]byte{0x7a}, crypto.KEKSize), nil
}
type testAuthenticator struct {
username, password string
}
func (a testAuthenticator) Authenticate(_ context.Context, username, password string) (bool, error) {
return username == a.username && password == a.password, nil
}
type emptyMailboxStore struct{}
func (emptyMailboxStore) Select(_ context.Context, mailboxName string) (int, uint64, bool, error) {
if mailboxName != "INBOX" {
return 0, 0, false, nil
}
return 0, 1, true, nil
}
func (emptyMailboxStore) Fetch(_ context.Context, _ string, _ []uint32) ([]imap.Message, error) {
return nil, nil
}
func (emptyMailboxStore) FetchByUID(_ context.Context, _ string, _ []uint32) ([]imap.Message, error) {
return nil, nil
}
// startTestIMAPServer startet den echten STARTTLS-fähigen IMAP-Testserver
// mit genau den neu vergebenen Zugangsdaten — stellvertretend für das neu
// angelegte, neu getestete Zielpostfach.
func startTestIMAPServer(t *testing.T, username, password string) (host string, port int, tlsConfig *tls.Config, stop func()) {
t.Helper()
cert, err := tlscert.GenerateSelfSigned("127.0.0.1")
if err != nil {
t.Fatalf("testzertifikat erzeugen: %v", err)
}
store := tlscert.NewStore(cert)
srv := imap.NewServerWithGuardAndTLSConfig(testAuthenticator{username, password}, emptyMailboxStore{}, protoguard.DefaultConfig(), store.Config())
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)
}()
tcpAddr := listener.Addr().(*net.TCPAddr)
return "127.0.0.1", tcpAddr.Port, &tls.Config{InsecureSkipVerify: true}, func() { //nolint:gosec // Testzertifikat
cancel()
<-done
}
}
func setupConfigStore(t *testing.T) *mailboxconfig.Store {
t.Helper()
store, _ := setupConfigStoreWithPool(t)
return store
}
func setupConfigStoreWithPool(t *testing.T) (*mailboxconfig.Store, *pgxpool.Pool) {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
pool := testdb.Pool(t, dsn)
store := mailboxconfig.NewStore(pool, crypto.NewService(fakeKEKProvider{}))
if err := store.EnsureSchema(context.Background()); err != nil {
t.Fatalf("schema: %v", err)
}
return store, pool
}
// TestMigrate_StructureTakenOverCredentialsNewlyTested ist die geforderte
// Prüfung aller drei Akzeptanzkriterien in einem Lauf: Struktur wird
// übernommen (AC1), das bei Migrate() übergebene Passwort ist NEU
// vergeben und nicht aus dem (absichtlich passwortlosen) LegacyMailbox
// übernommen (AC2), und die neue Verbindung wird vor Abschluss erfolgreich
// getestet (AC3) — ein falsches neu vergebenes Passwort lässt die
// Migration mit Fehler abbrechen, OHNE einen Datensatz anzulegen.
func TestMigrate_StructureTakenOverCredentialsNewlyTested(t *testing.T) {
configStore := setupConfigStore(t)
t.Cleanup(func() {
_, _ = configStore.List(context.Background(), "mandant-mai01")
})
host, port, tlsConfig, stop := startTestIMAPServer(t, "neuer-user@beispiel.example", "frisch-vergeben-123")
defer stop()
legacy := []LegacyMailbox{{
LegacyID: 1,
TenantSlug: "mandant-mai01",
Name: "postfach-eins",
IMAPHost: host,
IMAPPort: port,
IMAPUsername: "neuer-user@beispiel.example",
FolderSelection: []string{"INBOX"},
IntervalSeconds: 300,
Active: true,
}}
source := staticLegacySource(legacy)
tester := IMAPConnectionTester{TLSConfig: tlsConfig}
migrator := NewMigrator(source, configStore, tester)
res, err := migrator.Migrate(context.Background(), []NewCredential{
{LegacyID: 1, Password: "frisch-vergeben-123"},
})
if err != nil {
t.Fatalf("migrate: %v", err)
}
if len(res.Migrated) != 1 {
t.Fatalf("erwarte 1 migriertes postfach, habe %d", len(res.Migrated))
}
configs, err := configStore.List(context.Background(), "mandant-mai01")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(configs) != 1 {
t.Fatalf("erwarte 1 angelegte postfach-konfiguration, habe %d", len(configs))
}
if configs[0].IMAPHost != host || configs[0].IMAPUsername != "neuer-user@beispiel.example" {
t.Fatalf("struktur nicht korrekt übernommen: %+v", configs[0])
}
decrypted, err := configStore.GetDecryptedPassword(context.Background(), "mandant-mai01", configs[0].ID)
if err != nil {
t.Fatalf("passwort entschlüsseln: %v", err)
}
if decrypted != "frisch-vergeben-123" {
t.Fatalf("gespeichertes passwort ist nicht das neu vergebene testpasswort")
}
}
// TestMigrate_FailedConnectionTestBlocksCreation prüft AC3 negativ: ein
// falsches neu vergebenes Passwort lässt den Verbindungstest fehlschlagen
// und es wird KEINE Postfach-Konfiguration angelegt.
func TestMigrate_FailedConnectionTestBlocksCreation(t *testing.T) {
configStore := setupConfigStore(t)
host, port, tlsConfig, stop := startTestIMAPServer(t, "nutzer@beispiel.example", "richtiges-passwort")
defer stop()
legacy := []LegacyMailbox{{
LegacyID: 2,
TenantSlug: "mandant-mai01-fail",
Name: "postfach-zwei",
IMAPHost: host,
IMAPPort: port,
IMAPUsername: "nutzer@beispiel.example",
FolderSelection: []string{"INBOX"},
IntervalSeconds: 300,
Active: true,
}}
source := staticLegacySource(legacy)
tester := IMAPConnectionTester{TLSConfig: tlsConfig}
migrator := NewMigrator(source, configStore, tester)
_, err := migrator.Migrate(context.Background(), []NewCredential{
{LegacyID: 2, Password: "falsches-passwort"},
})
if err == nil {
t.Fatal("erwarte fehler bei fehlgeschlagenem verbindungstest")
}
configs, err := configStore.List(context.Background(), "mandant-mai01-fail")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(configs) != 0 {
t.Fatalf("erwarte keine angelegte konfiguration nach fehlgeschlagenem verbindungstest, habe %d", len(configs))
}
}
// TestMigrate_MissingCredentialRefusesLegacyCopy prüft AC2 strukturell:
// ohne neu vergebenes Passwort bricht die Migration ab — es gibt keinen
// Code-Pfad, der ein Altsystem-Passwort übernehmen könnte, weil
// LegacyMailbox gar kein Passwort-Feld besitzt.
func TestMigrate_MissingCredentialRefusesLegacyCopy(t *testing.T) {
legacy := []LegacyMailbox{{
LegacyID: 3,
TenantSlug: "mandant-mai01-missing",
Name: "postfach-drei",
IMAPHost: "irrelevant.example",
IMAPPort: 993,
Active: true,
}}
migrator := NewMigrator(staticLegacySource(legacy), noopConfigStore{}, noopTester{})
_, err := migrator.Migrate(context.Background(), nil)
if err == nil {
t.Fatal("erwarte fehler ohne neu vergebenes passwort")
}
}
type staticLegacySource []LegacyMailbox
func (s staticLegacySource) ActiveMailboxes(_ context.Context) ([]LegacyMailbox, error) {
return []LegacyMailbox(s), nil
}
type noopConfigStore struct{}
func (noopConfigStore) Create(_ context.Context, _ string, _ mailboxconfig.CreateInput) (int64, error) {
return 0, nil
}
type noopTester struct{}
func (noopTester) TestConnection(_ context.Context, _ string, _ int, _, _ string) error { return nil }
@@ -0,0 +1,104 @@
package mailboxmigration
import (
"context"
"testing"
)
// TestMigrate_NoCredentialColumnsPopulatedFromLegacy ist Prüfung 2: ein
// echter Scan der migrierten Tabelle (information_schema + Zeilen-Bytes),
// kein bloßes Postulat. Erwartete Spaltenmenge für Zugangsdaten ist exakt
// {wrapped_password_dek, encrypted_password} (beide verschlüsselt,
// mailboxconfig/0001_mail_mailboxes.sql) — keine Klartext-Spalte wie
// "password"/"imap_password"/"legacy_password" darf existieren, und der
// gespeicherte Chiffretext darf (da das neu vergebene Testpasswort nicht
// der Konstante "ALTSYSTEM-WAERE-HIER" entspricht) dieses Alt-Sentinel
// nirgends als Teilstring enthalten.
func TestMigrate_NoCredentialColumnsPopulatedFromLegacy(t *testing.T) {
configStore, pool := setupConfigStoreWithPool(t)
host, port, tlsConfig, stop := startTestIMAPServer(t, "scan-user@beispiel.example", "scan-neu-vergeben-456")
defer stop()
const legacySentinel = "ALTSYSTEM-WAERE-HIER"
legacy := []LegacyMailbox{{
LegacyID: 9,
TenantSlug: "mandant-mai01-scan",
Name: "postfach-scan",
IMAPHost: host,
IMAPPort: port,
IMAPUsername: "scan-user@beispiel.example",
FolderSelection: []string{"INBOX"},
IntervalSeconds: 300,
Active: true,
}}
migrator := NewMigrator(staticLegacySource(legacy), configStore, IMAPConnectionTester{TLSConfig: tlsConfig})
if _, err := migrator.Migrate(context.Background(), []NewCredential{{LegacyID: 9, Password: "scan-neu-vergeben-456"}}); err != nil {
t.Fatalf("migrate: %v", err)
}
rows, err := pool.Query(context.Background(), `
SELECT column_name FROM information_schema.columns
WHERE table_name = 'mail_mailboxes'
AND (column_name ILIKE '%pass%' OR column_name ILIKE '%credential%' OR column_name ILIKE '%secret%')
`)
if err != nil {
t.Fatalf("spalten scannen: %v", err)
}
defer rows.Close()
credentialColumns := map[string]bool{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("spaltenname lesen: %v", err)
}
credentialColumns[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("spalten iterieren: %v", err)
}
expected := map[string]bool{"wrapped_password_dek": true, "encrypted_password": true}
if len(credentialColumns) != len(expected) {
t.Fatalf("unerwartete zugangsdaten-spalten gefunden: %v", credentialColumns)
}
for name := range credentialColumns {
if !expected[name] {
t.Fatalf("unerwartete zugangsdaten-spalte %q", name)
}
}
var wrappedDEK, encryptedPassword []byte
err = pool.QueryRow(context.Background(), `
SELECT wrapped_password_dek, encrypted_password FROM mail_mailboxes
WHERE tenant_slug = 'mandant-mai01-scan' AND name = 'postfach-scan'
`).Scan(&wrappedDEK, &encryptedPassword)
if err != nil {
t.Fatalf("migrierte zeile lesen: %v", err)
}
if len(encryptedPassword) == 0 || len(wrappedDEK) == 0 {
t.Fatal("erwarte befüllte verschlüsselte zugangsdaten-spalten für das neu angelegte postfach")
}
if containsSubstring(encryptedPassword, legacySentinel) {
t.Fatal("gespeicherter chiffretext enthält das altsystem-sentinel — zugangsdaten-kontamination")
}
}
func containsSubstring(haystack []byte, needle string) bool {
n := []byte(needle)
if len(n) == 0 || len(haystack) < len(n) {
return false
}
for i := 0; i+len(n) <= len(haystack); i++ {
match := true
for j := range n {
if haystack[i+j] != n[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}