diff --git a/docs/MAI-01-PRUEFPROTOKOLL.md b/docs/MAI-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..fb193d27 --- /dev/null +++ b/docs/MAI-01-PRUEFPROTOKOLL.md @@ -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). diff --git a/mail/internal/mailboxmigration/imap_tester.go b/mail/internal/mailboxmigration/imap_tester.go new file mode 100644 index 00000000..fff759c9 --- /dev/null +++ b/mail/internal/mailboxmigration/imap_tester.go @@ -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 +} diff --git a/mail/internal/mailboxmigration/mailboxmigration.go b/mail/internal/mailboxmigration/mailboxmigration.go new file mode 100644 index 00000000..80b0bccf --- /dev/null +++ b/mail/internal/mailboxmigration/mailboxmigration.go @@ -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 +} diff --git a/mail/internal/mailboxmigration/mailboxmigration_test.go b/mail/internal/mailboxmigration/mailboxmigration_test.go new file mode 100644 index 00000000..1f28e64f --- /dev/null +++ b/mail/internal/mailboxmigration/mailboxmigration_test.go @@ -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 } diff --git a/mail/internal/mailboxmigration/schema_scan_test.go b/mail/internal/mailboxmigration/schema_scan_test.go new file mode 100644 index 00000000..8ad6cb5b --- /dev/null +++ b/mail/internal/mailboxmigration/schema_scan_test.go @@ -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 +}