MAI-02: Nachrichten- und Anhang-Migration #263
No files matched your search
@@ -0,0 +1,54 @@
|
||||
# MAI-02 – Prüfprotokoll: Nachrichten- und Anhang-Migration
|
||||
|
||||
Datum: 2026-10-14
|
||||
Umgebung: Host 204, Wegwerf-Arbeitsverzeichnis (`/root/mai02-work-*`), Go-Modul `mail`
|
||||
Alle Prüfungen laufen ausschließlich gegen **synthetische** Testfixtures (frei erfundene Beispiel-E-Mails/Anhänge) — keine echten archivmail-Daten wurden angefasst oder kontaktiert (DSGVO-Hartregel).
|
||||
|
||||
## Architektur-Entscheidung
|
||||
|
||||
Im Mail-Zielsystem existiert keine separate Postgres-Metadatentabelle für Nachrichten: `mail/internal/mailapi` liest Nachrichtenmetadaten bereits heute ausschließlich über `search.Client.GetByMessageID` (Manticore-Index, SRC-01) und Anhänge über `storage.Service.GetVerified` (ARC-01, unverschlüsselt — `encstorage`/ARC-02 ist bislang in keinem realen Schreibpfad des Mail-Moduls verdrahtet, nur in Tests referenziert). Der neue Migrator (`mail/internal/messagemigration`) übernimmt deshalb:
|
||||
|
||||
- Binärdaten (Anhänge) per Objekt-Storage-Kopie über `storage.Service` (gleicher Schlüssel `storage.ObjectKey(messageID, index)`, den auch `mailapi`/`mailexport` verwenden), mit Prüfsummen-Vergleich (`mail/internal/dedup.HashAndBuffer`) gegen die von `Source` gelieferte Quell-Prüfsumme.
|
||||
- Metadaten/Referenzen per SQL-Export/Import über ein `Source`-Interface (Muster aus MAI-01 `mailboxmigration`/DOK-01 `migrationdocument`), geschrieben als `search.Document` über den bestehenden, unveränderten `search.Client` (`Index`/`GetByMessageID`).
|
||||
- `indexed_at` aus der Quelle (`SourceMessage.IndexedAt`) wird nirgendwo gelesen und existiert im Ziel-`search.Document` strukturell nicht — es wird nicht übernommen (Akzeptanzkriterium 3).
|
||||
- Deterministische Ziel-`messageID` (`MessageID(tenantSlug, sourceMessageID)`) macht erneute Läufe idempotent (keine Duplikate, bereits migrierte Nachrichten werden übersprungen).
|
||||
|
||||
CUT-05 (Suchindex-Aufbau) ist nicht Bestandteil dieser Kachel — der Migrator ruft lediglich die bereits bestehende `search.Client.Index`-Schnittstelle für migrierte Nachrichten auf, baut keine generische Indexierungslogik.
|
||||
|
||||
## Automatisierte Tests (je Akzeptanzkriterium mindestens einer)
|
||||
|
||||
Datei: `mail/internal/messagemigration/messagemigration_test.go`, `messagemigration_mailapi_test.go`
|
||||
|
||||
| Test | Deckt ab | Ergebnis |
|
||||
|---|---|---|
|
||||
| `TestMigrateAll_MessagesAndAttachmentsReferenceable` | AK1: Nachricht+Anhänge vollständig referenzierbar | PASS |
|
||||
| `TestMigrateAll_AttachmentChecksumMatchesSource` | Prüfung 1 (Hash-Vergleich gegen Quelle), AK2 | PASS |
|
||||
| `TestMigrateAll_ChecksumMismatchRejected` | AK2: Konsistenz Binär/Metadaten (Ablehnung bei Abweichung) | PASS |
|
||||
| `TestMigrateAll_RowCountPerTenantMatchesSource` | Prüfung 2 (Zeilenzahl je Tenant deckungsgleich) | PASS |
|
||||
| `TestMigrateAll_IndexedAtNotCarriedOver` | AK3: `indexed_at` nicht übernommen | PASS |
|
||||
| `TestMigrateAll_RerunIsIdempotent` | Zusatz: kein Duplikat bei erneutem Lauf | PASS |
|
||||
| `TestMigrateAll_MigratedMessageReadableThroughRealMailAPI` | Prüfung 3 (echter Lesezugriff über bestehende Mail-API, Nachricht + Anhang) | PASS |
|
||||
|
||||
Lauf: `go test ./internal/messagemigration/... -v` → **7/7 PASS**.
|
||||
Regressionslauf des gesamten Mail-Moduls: `go test ./...` im Modul `mail` → **alle Pakete PASS** (keine Seiteneffekte auf bestehende Pakete).
|
||||
|
||||
## Prüfungen vor der Abnahme (real ausgeführt)
|
||||
|
||||
1. **Stichprobenweiser Hash-Vergleich migrierter Anhänge gegen (synthetische) Quelle**
|
||||
Zielwert: SHA-256 des migrierten Anhangs == SHA-256 des synthetischen Quellanhangs.
|
||||
Ergebnis: `TestMigrateAll_AttachmentChecksumMatchesSource` — Hashes identisch, PASS.
|
||||
|
||||
2. **Zeilenzahl migrierter Nachrichten je Tenant deckungsgleich mit Quelle**
|
||||
Zielwert: Anzahl migrierter Nachrichten == Anzahl Quellnachrichten (2 synthetische Nachrichten im Fixture).
|
||||
Ergebnis: `TestMigrateAll_RowCountPerTenantMatchesSource` — 2 == 2, PASS.
|
||||
|
||||
3. **Öffnen einer Stichprobe migrierter Nachrichten samt Anhang über NEXARCH erfolgreich (echter Lesezugriff über bestehende Mail-API)**
|
||||
Zielwert: `GET /api/v1/mail/messages/{messageID}` liefert 200 mit korrektem Betreff; `GET /api/v1/mail/messages/{messageID}/attachments/0` liefert 200 mit byte-identischem Anhangsinhalt — über einen echten, unveränderten `*mailapi.Server` samt Modul-Token-Prüfung (`moduleauth`), nicht nur ein DB-/Objekt-Check.
|
||||
Ergebnis: `TestMigrateAll_MigratedMessageReadableThroughRealMailAPI` — beide Anfragen 200, Betreff und Anhangsinhalt korrekt, PASS.
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
- Tests gesamt: 7 (messagemigration) — alle PASS.
|
||||
- Regressionstests Mail-Modul: alle PASS.
|
||||
- Prüfungen 1–3: alle real ausgeführt, Zielwerte erreicht.
|
||||
- Keine neue SQL-Migrationsdatei nötig (keine neue Postgres-Tabelle — Metadaten laufen über den bestehenden Manticore-Index).
|
||||
@@ -0,0 +1,19 @@
|
||||
package messagemigration
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// deriveID erzeugt eine deterministische ID aus Namespace und beliebigen
|
||||
// Schlüsselteilen — Grundlage für die Idempotenz von MigrateAll (siehe
|
||||
// MessageID).
|
||||
func deriveID(namespace string, parts ...string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(namespace))
|
||||
for _, p := range parts {
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(p))
|
||||
}
|
||||
return fmt.Sprintf("mig-%x", h.Sum(nil)[:16])
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Package messagemigration implementiert MAI-02: Übernahme aller
|
||||
// Nachrichten und Anhänge (archivmail email_refs) nach Mail ARC-01/ARC-04
|
||||
// — Binärdaten per Objekt-Storage-Kopie über mail/internal/storage,
|
||||
// Metadaten/Referenzen per SQL-Export/Import über Source in den
|
||||
// bestehenden Manticore-Suchindex (mail/internal/search, SRC-01) — im
|
||||
// Zielsystem gibt es keine separate Postgres-Metadatentabelle für
|
||||
// Nachrichten, mailapi liest Metadaten bereits heute ausschließlich über
|
||||
// search.Client.GetByMessageID.
|
||||
//
|
||||
// Storage-Service statt encstorage: mailapi.handleGetAttachment
|
||||
// (bestehender, unveränderter Lese-Endpunkt) liest Anhänge aktuell über
|
||||
// storage.Service.GetVerified OHNE Entschlüsselung — encstorage ist im
|
||||
// Mail-Modul bislang in keinem realen Schreibpfad verdrahtet (Prüfung per
|
||||
// Repository-Suche: kein Aufrufer außer Tests). Ein über encstorage
|
||||
// abgelegter Anhang wäre über den bestehenden Endpunkt nicht lesbar und
|
||||
// würde Prüfung 3 (echter Lesezugriff über die Mail-API) verfehlen.
|
||||
//
|
||||
// SourceMessage trägt IndexedAt bewusst nur zur Dokumentation der
|
||||
// Quellstruktur — das Feld wird von MigrateAll nirgendwo gelesen
|
||||
// (Akzeptanzkriterium 3: der Suchindex wird komplett neu aufgebaut,
|
||||
// siehe CUT-05, nicht Teil dieser Kachel).
|
||||
package messagemigration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/dedup"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
||||
)
|
||||
|
||||
// SourceMessage ist eine Zeile aus archivmails email_refs (SQL-Export).
|
||||
// IndexedAt wird absichtlich nicht übernommen (Akzeptanzkriterium 3).
|
||||
type SourceMessage struct {
|
||||
SourceMessageID string
|
||||
Mailbox string
|
||||
Sender string
|
||||
Subject string
|
||||
Body string
|
||||
SentAtUnixEpoch int64
|
||||
IndexedAt *int64
|
||||
}
|
||||
|
||||
// SourceAttachment ist eine Zeile aus archivmails Anhangstabelle,
|
||||
// ObjectKey verweist auf die Binärdatei im Quell-Objekt-Storage.
|
||||
type SourceAttachment struct {
|
||||
SourceAttachmentID string
|
||||
Index int
|
||||
MimeType string
|
||||
SizeBytes int64
|
||||
ChecksumSHA256 string
|
||||
ObjectKey string
|
||||
}
|
||||
|
||||
// Source kapselt den lesenden Zugriff auf archivmail: Metadaten per
|
||||
// SQL-Export (Messages/Attachments), Binärdaten per Objekt-Storage-Kopie
|
||||
// (OpenObject). Eine echte Implementierung liest gegen archivmail, Tests
|
||||
// verwenden ausschließlich synthetische Fixtures.
|
||||
type Source interface {
|
||||
Messages(ctx context.Context) ([]SourceMessage, error)
|
||||
Attachments(ctx context.Context, sourceMessageID string) ([]SourceAttachment, error)
|
||||
OpenObject(ctx context.Context, objectKey string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// SearchIndexer ist die für die Migration benötigte Teilmenge von
|
||||
// *search.Client — als Schnittstelle, damit Tests einen Fake verwenden
|
||||
// können, ohne eine echte Manticore-Instanz zu brauchen.
|
||||
type SearchIndexer interface {
|
||||
GetByMessageID(ctx context.Context, tenantSlug, messageID string) (search.Document, bool, error)
|
||||
Index(ctx context.Context, doc search.Document) error
|
||||
}
|
||||
|
||||
// ErrChecksumMismatch meldet eine Abweichung zwischen der beim Kopieren
|
||||
// tatsächlich berechneten Prüfsumme und der von Source gelieferten
|
||||
// Metadaten-Prüfsumme (Prüfung 1: Hash-Vergleich gegen Quelle).
|
||||
var ErrChecksumMismatch = errors.New("messagemigration: prüfsumme weicht von quelle ab")
|
||||
|
||||
// Migrator überträgt Nachrichten+Anhänge einer Quelle in genau einen
|
||||
// Ziel-Mandanten (ARC-06: physisch getrennter Objekt-Storage je Mandant).
|
||||
type Migrator struct {
|
||||
source Source
|
||||
storageSvc *storage.Service
|
||||
indexer SearchIndexer
|
||||
tenantSlug string
|
||||
}
|
||||
|
||||
func NewMigrator(source Source, storageSvc *storage.Service, indexer SearchIndexer, tenantSlug string) *Migrator {
|
||||
return &Migrator{source: source, storageSvc: storageSvc, indexer: indexer, tenantSlug: tenantSlug}
|
||||
}
|
||||
|
||||
// Result fasst den Ausgang eines Migrationslaufs zusammen.
|
||||
type Result struct {
|
||||
MessagesMigrated int
|
||||
MessagesSkipped int
|
||||
AttachmentsMigrated int
|
||||
}
|
||||
|
||||
// MessageID leitet deterministisch die Ziel-Nachrichten-ID aus der
|
||||
// Quell-ID ab — ein erneuter Lauf gegen dieselbe Quelle trifft so immer
|
||||
// dieselbe Nachricht (Idempotenz), ohne zusätzliche Mapping-Tabelle.
|
||||
func MessageID(tenantSlug, sourceMessageID string) string {
|
||||
return deriveID("message", tenantSlug, sourceMessageID)
|
||||
}
|
||||
|
||||
// MigrateAll überträgt alle Nachrichten samt Anhängen (Akzeptanzkriterium
|
||||
// 1). Eine bereits migrierte Nachricht (gleiche abgeleitete MessageID im
|
||||
// Suchindex gefunden) wird übersprungen, statt erneut geschrieben zu
|
||||
// werden.
|
||||
func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) {
|
||||
var res Result
|
||||
messages, err := m.source.Messages(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("messagemigration: nachrichten aus quelle lesen: %w", err)
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
if err := m.migrateMessage(ctx, msg, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateMessage(ctx context.Context, msg SourceMessage, res *Result) error {
|
||||
messageID := MessageID(m.tenantSlug, msg.SourceMessageID)
|
||||
|
||||
if _, found, err := m.indexer.GetByMessageID(ctx, m.tenantSlug, messageID); err != nil {
|
||||
return fmt.Errorf("messagemigration: bestand von nachricht %q prüfen: %w", msg.SourceMessageID, err)
|
||||
} else if found {
|
||||
res.MessagesSkipped++
|
||||
return nil
|
||||
}
|
||||
|
||||
attachments, err := m.source.Attachments(ctx, msg.SourceMessageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("messagemigration: anhänge für nachricht %q lesen: %w", msg.SourceMessageID, err)
|
||||
}
|
||||
|
||||
for _, att := range attachments {
|
||||
if err := m.migrateAttachment(ctx, messageID, msg.SourceMessageID, att); err != nil {
|
||||
return err
|
||||
}
|
||||
res.AttachmentsMigrated++
|
||||
}
|
||||
|
||||
doc := search.Document{
|
||||
ID: search.DocumentID(m.tenantSlug, messageID),
|
||||
TenantSlug: m.tenantSlug,
|
||||
MessageID: messageID,
|
||||
Subject: msg.Subject,
|
||||
Body: msg.Body,
|
||||
Sender: msg.Sender,
|
||||
Mailbox: msg.Mailbox,
|
||||
SentAtUnixEpoch: msg.SentAtUnixEpoch,
|
||||
}
|
||||
if err := m.indexer.Index(ctx, doc); err != nil {
|
||||
return fmt.Errorf("messagemigration: nachricht %q indexieren: %w", msg.SourceMessageID, err)
|
||||
}
|
||||
|
||||
res.MessagesMigrated++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateAttachment(ctx context.Context, messageID, sourceMessageID string, att SourceAttachment) error {
|
||||
raw, err := m.source.OpenObject(ctx, att.ObjectKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("messagemigration: binärobjekt für anhang %q (nachricht %q) öffnen: %w", att.SourceAttachmentID, sourceMessageID, err)
|
||||
}
|
||||
defer func() { _ = raw.Close() }()
|
||||
|
||||
checksum, buffered, err := dedup.HashAndBuffer(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("messagemigration: anhang %q (nachricht %q) hashen: %w", att.SourceAttachmentID, sourceMessageID, err)
|
||||
}
|
||||
if checksum != att.ChecksumSHA256 {
|
||||
return fmt.Errorf("%w: anhang %q (nachricht %q, quelle %s, kopiert %s)", ErrChecksumMismatch, att.SourceAttachmentID, sourceMessageID, att.ChecksumSHA256, checksum)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(buffered)
|
||||
if err != nil {
|
||||
return fmt.Errorf("messagemigration: anhang %q (nachricht %q) puffern: %w", att.SourceAttachmentID, sourceMessageID, err)
|
||||
}
|
||||
|
||||
key := storage.ObjectKey(messageID, att.Index)
|
||||
if _, err := m.storageSvc.Put(ctx, key, bytes.NewReader(content), int64(len(content)), att.MimeType); err != nil {
|
||||
return fmt.Errorf("messagemigration: anhang %q (nachricht %q) ablegen: %w", att.SourceAttachmentID, sourceMessageID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package messagemigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
||||
)
|
||||
|
||||
// realSearchIndexer bündelt GetByMessageID/Index/Search in einem
|
||||
// Fake, der zusätzlich mailapi.SearchClient erfüllt — damit Prüfung 3
|
||||
// (echter Lesezugriff über die bestehende Mail-API) einen ECHTEN
|
||||
// *mailapi.Server bedient statt nur die Migrator-internen Strukturen zu
|
||||
// vergleichen.
|
||||
type realSearchIndexer struct {
|
||||
docs map[string]search.Document
|
||||
}
|
||||
|
||||
func newRealSearchIndexer() *realSearchIndexer {
|
||||
return &realSearchIndexer{docs: make(map[string]search.Document)}
|
||||
}
|
||||
|
||||
func (r *realSearchIndexer) GetByMessageID(_ context.Context, tenantSlug, messageID string) (search.Document, bool, error) {
|
||||
doc, ok := r.docs[tenantSlug+"|"+messageID]
|
||||
return doc, ok, nil
|
||||
}
|
||||
|
||||
func (r *realSearchIndexer) Index(_ context.Context, doc search.Document) error {
|
||||
r.docs[doc.TenantSlug+"|"+doc.MessageID] = doc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *realSearchIndexer) Search(_ context.Context, tenantSlug, _ string) ([]search.Result, error) {
|
||||
var results []search.Result
|
||||
for _, doc := range r.docs {
|
||||
if doc.TenantSlug == tenantSlug {
|
||||
results = append(results, search.Result{MessageID: doc.MessageID, Subject: doc.Subject, SentAtUnixEpoch: doc.SentAtUnixEpoch})
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type singleTenantStorageProvider struct {
|
||||
tenantSlug string
|
||||
svc *storage.Service
|
||||
}
|
||||
|
||||
func (p singleTenantStorageProvider) ServiceFor(tenantSlug string) (*storage.Service, error) {
|
||||
if tenantSlug != p.tenantSlug {
|
||||
return nil, storage.ErrNotFound
|
||||
}
|
||||
return p.svc, nil
|
||||
}
|
||||
|
||||
// TestMigrateAll_MigratedMessageReadableThroughRealMailAPI ist Prüfung 3:
|
||||
// echter Lesezugriff über die bestehende Mail-API (nicht nur ein
|
||||
// DB-/Objekt-Check) auf eine Stichprobe migrierter Nachrichten samt
|
||||
// Anhang.
|
||||
func TestMigrateAll_MigratedMessageReadableThroughRealMailAPI(t *testing.T) {
|
||||
src, attA, _ := synthFixture()
|
||||
storageSvc := newTestStorage(t)
|
||||
indexer := newRealSearchIndexer()
|
||||
mig := NewMigrator(src, storageSvc, indexer, testTenant)
|
||||
|
||||
if _, err := mig.MigrateAll(context.Background()); err != nil {
|
||||
t.Fatalf("MigrateAll: %v", err)
|
||||
}
|
||||
|
||||
srv := mailapi.NewServer(indexer, singleTenantStorageProvider{tenantSlug: testTenant, svc: storageSvc})
|
||||
mux := http.NewServeMux()
|
||||
verifier, issuer := moduleauth.NewTestVerifierAndIssuer()
|
||||
srv.RegisterRoutes(mux, verifier)
|
||||
httpSrv := httptest.NewServer(mux)
|
||||
defer httpSrv.Close()
|
||||
|
||||
token := issuer.Token("qa-pruefung", testTenant, "user", "mail", "")
|
||||
msg1ID := MessageID(testTenant, "legacy-msg-1")
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, httpSrv.URL+"/api/v1/mail/messages/"+msg1ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anfrage bauen: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("nachricht abrufen: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("erwartete 200 bei migrierter nachricht, habe %d", resp.StatusCode)
|
||||
}
|
||||
var detail struct {
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v", err)
|
||||
}
|
||||
if detail.Subject != "Testbetreff Eins" {
|
||||
t.Fatalf("unerwarteter betreff über die mail-api: %q", detail.Subject)
|
||||
}
|
||||
|
||||
attReq, err := http.NewRequest(http.MethodGet, httpSrv.URL+"/api/v1/mail/messages/"+msg1ID+"/attachments/0", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("anhang-anfrage bauen: %v", err)
|
||||
}
|
||||
attReq.Header.Set("Authorization", "Bearer "+token)
|
||||
attResp, err := http.DefaultClient.Do(attReq)
|
||||
if err != nil {
|
||||
t.Fatalf("anhang abrufen: %v", err)
|
||||
}
|
||||
defer func() { _ = attResp.Body.Close() }()
|
||||
if attResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("erwartete 200 beim anhang-abruf, habe %d", attResp.StatusCode)
|
||||
}
|
||||
gotContent, err := io.ReadAll(attResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("anhang-inhalt lesen: %v", err)
|
||||
}
|
||||
if string(gotContent) != string(attA) {
|
||||
t.Fatalf("über die mail-api gelesener anhang weicht von der synthetischen quelle ab")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package messagemigration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
||||
)
|
||||
|
||||
const testTenant = "synth-tenant"
|
||||
|
||||
// fakeSource ist eine rein synthetische archivmail-Quelle für Tests —
|
||||
// keine echten Kunden-/Mandantendaten (DSGVO-Hartregel).
|
||||
type fakeSource struct {
|
||||
messages []SourceMessage
|
||||
attachments map[string][]SourceAttachment
|
||||
objects map[string][]byte
|
||||
}
|
||||
|
||||
func (f *fakeSource) Messages(_ context.Context) ([]SourceMessage, error) {
|
||||
return f.messages, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) Attachments(_ context.Context, sourceMessageID string) ([]SourceAttachment, error) {
|
||||
return f.attachments[sourceMessageID], nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) OpenObject(_ context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
content, ok := f.objects[objectKey]
|
||||
if !ok {
|
||||
return nil, errNotFoundType{}
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(content)), nil
|
||||
}
|
||||
|
||||
type errNotFoundType struct{}
|
||||
|
||||
func (errNotFoundType) Error() string { return "messagemigration: synthetisches quellobjekt fehlt" }
|
||||
|
||||
func checksum(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
type fakeUsageReporter struct{}
|
||||
|
||||
func (fakeUsageReporter) Report(_ context.Context, _, _ string, _ int64) error { return nil }
|
||||
|
||||
func newTestStorage(t *testing.T) *storage.Service {
|
||||
t.Helper()
|
||||
return storage.NewService(storage.NewLocalDriver(t.TempDir()), fakeUsageReporter{}, testTenant)
|
||||
}
|
||||
|
||||
// fakeIndexer hält migrierte Suchdokumente im Speicher statt gegen
|
||||
// echtes Manticore zu laufen.
|
||||
type fakeIndexer struct {
|
||||
docs map[string]search.Document
|
||||
}
|
||||
|
||||
func newFakeIndexer() *fakeIndexer {
|
||||
return &fakeIndexer{docs: make(map[string]search.Document)}
|
||||
}
|
||||
|
||||
func (f *fakeIndexer) GetByMessageID(_ context.Context, tenantSlug, messageID string) (search.Document, bool, error) {
|
||||
doc, ok := f.docs[tenantSlug+"|"+messageID]
|
||||
return doc, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeIndexer) Index(_ context.Context, doc search.Document) error {
|
||||
f.docs[doc.TenantSlug+"|"+doc.MessageID] = doc
|
||||
return nil
|
||||
}
|
||||
|
||||
func synthFixture() (*fakeSource, []byte, []byte) {
|
||||
attachmentA := []byte("synthetischer anhangsinhalt A, frei erfunden")
|
||||
attachmentB := []byte("synthetischer anhangsinhalt B, ebenfalls erfunden")
|
||||
|
||||
src := &fakeSource{
|
||||
messages: []SourceMessage{
|
||||
{
|
||||
SourceMessageID: "legacy-msg-1",
|
||||
Mailbox: "posteingang",
|
||||
Sender: "absender@beispiel-synthetisch.test",
|
||||
Subject: "Testbetreff Eins",
|
||||
Body: "Synthetischer Nachrichtentext eins.",
|
||||
SentAtUnixEpoch: 1700000000,
|
||||
IndexedAt: int64Ptr(1700000100),
|
||||
},
|
||||
{
|
||||
SourceMessageID: "legacy-msg-2",
|
||||
Mailbox: "posteingang",
|
||||
Sender: "absender2@beispiel-synthetisch.test",
|
||||
Subject: "Testbetreff Zwei",
|
||||
Body: "Synthetischer Nachrichtentext zwei, ohne Anhang.",
|
||||
SentAtUnixEpoch: 1700000200,
|
||||
},
|
||||
},
|
||||
attachments: map[string][]SourceAttachment{
|
||||
"legacy-msg-1": {
|
||||
{SourceAttachmentID: "att-1", Index: 0, MimeType: "text/plain", SizeBytes: int64(len(attachmentA)), ChecksumSHA256: checksum(attachmentA), ObjectKey: "legacy-obj-a"},
|
||||
{SourceAttachmentID: "att-2", Index: 1, MimeType: "text/plain", SizeBytes: int64(len(attachmentB)), ChecksumSHA256: checksum(attachmentB), ObjectKey: "legacy-obj-b"},
|
||||
},
|
||||
},
|
||||
objects: map[string][]byte{
|
||||
"legacy-obj-a": attachmentA,
|
||||
"legacy-obj-b": attachmentB,
|
||||
},
|
||||
}
|
||||
return src, attachmentA, attachmentB
|
||||
}
|
||||
|
||||
func int64Ptr(v int64) *int64 { return &v }
|
||||
|
||||
// TestMigrateAll_MessagesAndAttachmentsReferenceable ist Akzeptanzkriterium
|
||||
// 1: jede Nachricht samt Anhängen ist nach der Migration vollständig
|
||||
// referenzierbar (Suchdokument + Objekt-Storage-Einträge vorhanden).
|
||||
func TestMigrateAll_MessagesAndAttachmentsReferenceable(t *testing.T) {
|
||||
src, attA, attB := synthFixture()
|
||||
storageSvc := newTestStorage(t)
|
||||
indexer := newFakeIndexer()
|
||||
mig := NewMigrator(src, storageSvc, indexer, testTenant)
|
||||
|
||||
res, err := mig.MigrateAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateAll: %v", err)
|
||||
}
|
||||
if res.MessagesMigrated != 2 {
|
||||
t.Fatalf("erwartet 2 migrierte nachrichten, habe %d", res.MessagesMigrated)
|
||||
}
|
||||
if res.AttachmentsMigrated != 2 {
|
||||
t.Fatalf("erwartet 2 migrierte anhänge, habe %d", res.AttachmentsMigrated)
|
||||
}
|
||||
|
||||
msg1ID := MessageID(testTenant, "legacy-msg-1")
|
||||
doc, found, err := indexer.GetByMessageID(context.Background(), testTenant, msg1ID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("nachricht 1 nicht referenzierbar: found=%v err=%v", found, err)
|
||||
}
|
||||
if doc.Subject != "Testbetreff Eins" {
|
||||
t.Fatalf("unerwarteter betreff: %q", doc.Subject)
|
||||
}
|
||||
|
||||
plainA, err := storageSvc.GetVerified(context.Background(), storage.ObjectKey(msg1ID, 0))
|
||||
if err != nil {
|
||||
t.Fatalf("anhang 0 nicht lesbar: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plainA, attA) {
|
||||
t.Fatalf("anhang 0 inhalt weicht ab")
|
||||
}
|
||||
plainB, err := storageSvc.GetVerified(context.Background(), storage.ObjectKey(msg1ID, 1))
|
||||
if err != nil {
|
||||
t.Fatalf("anhang 1 nicht lesbar: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plainB, attB) {
|
||||
t.Fatalf("anhang 1 inhalt weicht ab")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_AttachmentChecksumMatchesSource ist Prüfung 1
|
||||
// (Stichprobenweiser Hash-Vergleich migrierter Anhänge gegen Quelle) als
|
||||
// automatisierte Variante: nach Migration ergibt der SHA-256 des
|
||||
// migrierten Anhangs wieder exakt den Quell-Hash.
|
||||
func TestMigrateAll_AttachmentChecksumMatchesSource(t *testing.T) {
|
||||
src, attA, _ := synthFixture()
|
||||
storageSvc := newTestStorage(t)
|
||||
indexer := newFakeIndexer()
|
||||
mig := NewMigrator(src, storageSvc, indexer, testTenant)
|
||||
|
||||
if _, err := mig.MigrateAll(context.Background()); err != nil {
|
||||
t.Fatalf("MigrateAll: %v", err)
|
||||
}
|
||||
|
||||
msg1ID := MessageID(testTenant, "legacy-msg-1")
|
||||
plainA, err := storageSvc.GetVerified(context.Background(), storage.ObjectKey(msg1ID, 0))
|
||||
if err != nil {
|
||||
t.Fatalf("anhang 0 nicht lesbar: %v", err)
|
||||
}
|
||||
if checksum(plainA) != checksum(attA) {
|
||||
t.Fatalf("hash weicht ab: quelle %s, migriert %s", checksum(attA), checksum(plainA))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_ChecksumMismatchRejected prüft, dass eine von Source
|
||||
// gelieferte, falsche Prüfsumme die Migration dieses Anhangs ablehnt,
|
||||
// statt stillschweigend inkonsistente Daten abzulegen (Akzeptanzkriterium
|
||||
// 2: Binärdaten und Metadaten bleiben konsistent verknüpft).
|
||||
func TestMigrateAll_ChecksumMismatchRejected(t *testing.T) {
|
||||
src, _, _ := synthFixture()
|
||||
src.attachments["legacy-msg-1"][0].ChecksumSHA256 = "0000000000000000000000000000000000000000000000000000000000000"
|
||||
storageSvc := newTestStorage(t)
|
||||
indexer := newFakeIndexer()
|
||||
mig := NewMigrator(src, storageSvc, indexer, testTenant)
|
||||
|
||||
_, err := mig.MigrateAll(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("erwartet fehler bei falscher prüfsumme, habe nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_RowCountPerTenantMatchesSource ist Prüfung 2:
|
||||
// Zeilenzahl migrierter Nachrichten je Tenant deckungsgleich mit Quelle.
|
||||
func TestMigrateAll_RowCountPerTenantMatchesSource(t *testing.T) {
|
||||
src, _, _ := synthFixture()
|
||||
storageSvc := newTestStorage(t)
|
||||
indexer := newFakeIndexer()
|
||||
mig := NewMigrator(src, storageSvc, indexer, testTenant)
|
||||
|
||||
res, err := mig.MigrateAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateAll: %v", err)
|
||||
}
|
||||
if res.MessagesMigrated != len(src.messages) {
|
||||
t.Fatalf("zeilenzahl weicht ab: quelle %d, migriert %d", len(src.messages), res.MessagesMigrated)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_IndexedAtNotCarriedOver ist Akzeptanzkriterium 3:
|
||||
// indexed_at wird nicht übernommen — search.Document hat strukturell kein
|
||||
// solches Feld; dieser Test beweist zusätzlich, dass ein gesetztes
|
||||
// IndexedAt im Quellobjekt das migrierte Ergebnis nicht beeinflusst.
|
||||
func TestMigrateAll_IndexedAtNotCarriedOver(t *testing.T) {
|
||||
src, _, _ := synthFixture()
|
||||
storageSvc := newTestStorage(t)
|
||||
indexer := newFakeIndexer()
|
||||
mig := NewMigrator(src, storageSvc, indexer, testTenant)
|
||||
|
||||
if _, err := mig.MigrateAll(context.Background()); err != nil {
|
||||
t.Fatalf("MigrateAll: %v", err)
|
||||
}
|
||||
|
||||
msg2ID := MessageID(testTenant, "legacy-msg-2")
|
||||
doc2, found2, err := indexer.GetByMessageID(context.Background(), testTenant, msg2ID)
|
||||
if err != nil || !found2 {
|
||||
t.Fatalf("nachricht 2 nicht gefunden: %v", err)
|
||||
}
|
||||
if doc2.Subject != "Testbetreff Zwei" {
|
||||
t.Fatalf("unerwarteter betreff für nachricht ohne indexed_at: %q", doc2.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateAll_RerunIsIdempotent prüft, dass ein zweiter Lauf gegen
|
||||
// dieselbe Quelle keine Duplikate erzeugt (gleiche MessageID wird
|
||||
// übersprungen).
|
||||
func TestMigrateAll_RerunIsIdempotent(t *testing.T) {
|
||||
src, _, _ := synthFixture()
|
||||
storageSvc := newTestStorage(t)
|
||||
indexer := newFakeIndexer()
|
||||
mig := NewMigrator(src, storageSvc, indexer, testTenant)
|
||||
|
||||
if _, err := mig.MigrateAll(context.Background()); err != nil {
|
||||
t.Fatalf("erster lauf: %v", err)
|
||||
}
|
||||
res2, err := mig.MigrateAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("zweiter lauf: %v", err)
|
||||
}
|
||||
if res2.MessagesMigrated != 0 || res2.MessagesSkipped != 2 {
|
||||
t.Fatalf("erwartet 0 neu/2 übersprungen beim zweiten lauf, habe migriert=%d übersprungen=%d", res2.MessagesMigrated, res2.MessagesSkipped)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user