From 53267e8e10f935a39bead7b6a307b74f4ecbc42b Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 13 Oct 2026 11:01:27 +0000 Subject: [PATCH] DOK-01: dokument-revisions-migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrator uebernimmt Dokumente+Revisionen aus archivdms (Source- Schnittstelle: SQL-Export-Zeilen + Objekt-Storage-Zugriff) in das DMS-Zielschema, ueber dieselbe storage.Service/crypto.Service-Kette wie der reguläre Upload-Pfad. Deterministische Ziel-IDs (deriveUUID) machen den Lauf idempotent ohne Mapping-Tabelle. Co-Authored-By: Claude Sonnet 5 --- dms/internal/migrationdocument/migrate.go | 210 +++++++++++++ .../migrationdocument/migrate_test.go | 295 ++++++++++++++++++ dms/internal/migrationdocument/uuid.go | 25 ++ docs/DOK-01-PRUEFPROTOKOLL.md | 98 ++++++ 4 files changed, 628 insertions(+) create mode 100644 dms/internal/migrationdocument/migrate.go create mode 100644 dms/internal/migrationdocument/migrate_test.go create mode 100644 dms/internal/migrationdocument/uuid.go create mode 100644 docs/DOK-01-PRUEFPROTOKOLL.md diff --git a/dms/internal/migrationdocument/migrate.go b/dms/internal/migrationdocument/migrate.go new file mode 100644 index 00000000..32b45d27 --- /dev/null +++ b/dms/internal/migrationdocument/migrate.go @@ -0,0 +1,210 @@ +// Package migrationdocument implementiert DOK-01: Uebernahme aller +// Dokumente und Datei-Revisionen aus archivdms in das DMS-Zielschema +// (dms/migrations/tenant/0001_documents.up.sql) — Binaerdaten per +// Objekt-Storage-Kopie ueber dieselbe storage.Service/crypto.Service- +// Kette wie der reguläre Upload-Pfad (dms/internal/upload), Metadaten per +// SQL-Export/Import ueber Source. +package migrationdocument + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" +) + +// SourceDocument ist eine Zeile aus archivdms' Dokumenttabelle (SQL-Export). +type SourceDocument struct { + SourceDocumentID string + Title string + SourceUserID string +} + +// SourceRevision ist eine Zeile aus archivdms' Revisionstabelle, ObjectKey +// verweist auf die Binaerdatei im Quell-Objekt-Storage. +type SourceRevision struct { + SourceRevisionID string + SourceDocumentID string + RevisionNumber int + ChecksumSHA256 string + SizeBytes int64 + MimeType string + SourceUserID string + ObjectKey string +} + +// Source kapselt den lesenden Zugriff auf archivdms: Metadaten per +// SQL-Export (Documents/Revisions), Binaerdaten per Objekt-Storage-Kopie +// (OpenObject). Eine echte Implementierung liest gegen archivdms, Tests +// verwenden ausschliesslich synthetische Fixtures. +type Source interface { + Documents(ctx context.Context) ([]SourceDocument, error) + Revisions(ctx context.Context, sourceDocumentID string) ([]SourceRevision, error) + OpenObject(ctx context.Context, objectKey string) (io.ReadCloser, error) +} + +// UserResolver bildet eine archivdms-Benutzer-ID auf die bereits migrierte +// NEXARCH-Benutzer-ID ab (users.id, siehe Voraussetzung in COR-01). ACL-/ +// Benutzermigration selbst ist nicht Bestandteil von DOK-01. +type UserResolver interface { + ResolveUserID(ctx context.Context, sourceUserID string) (string, error) +} + +// ErrChecksumMismatch meldet eine Abweichung zwischen der beim Kopieren +// tatsaechlich berechneten Pruefsumme und der von Source gelieferten +// Metadaten-Pruefsumme (Pruefung 1: Hash-Vergleich gegen Quelle). +var ErrChecksumMismatch = errors.New("migrationdocument: pruefsumme weicht von quelle ab") + +// Migrator uebertraegt Dokumente+Revisionen einer Quelle in genau einen +// Ziel-Tenant (eine physische Tenant-DB, Modell C). +type Migrator struct { + pool *pgxpool.Pool + source Source + users UserResolver + storageSvc *storage.Service + cryptoSvc *dmscrypto.Service + tenantSlug string +} + +func NewMigrator(pool *pgxpool.Pool, source Source, users UserResolver, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug string) *Migrator { + return &Migrator{pool: pool, source: source, users: users, storageSvc: storageSvc, cryptoSvc: cryptoSvc, tenantSlug: tenantSlug} +} + +// Result fasst den Ausgang eines Migrationslaufs zusammen. +type Result struct { + DocumentsMigrated int + RevisionsMigrated int + RevisionsSkipped int +} + +// MigrateAll uebertraegt alle Dokumente samt Revisionen (Akzeptanzkriterium +// 1). Dokument- und Revisions-IDs im Ziel werden deterministisch aus den +// Quell-IDs abgeleitet (deriveUUID) statt per gen_random_uuid() vergeben — +// ein erneuter Lauf gegen dieselbe Quelle trifft so IMMER dieselben Zeilen +// und erzeugt keine Duplikate (Akzeptanzkriterium 3), ganz ohne eine +// zusaetzliche Mapping-Tabelle. +func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) { + var res Result + docs, err := m.source.Documents(ctx) + if err != nil { + return res, fmt.Errorf("migrationdocument: dokumente aus quelle lesen: %w", err) + } + + for _, doc := range docs { + if err := m.migrateDocument(ctx, doc, &res); err != nil { + return res, err + } + } + return res, nil +} + +func (m *Migrator) migrateDocument(ctx context.Context, doc SourceDocument, res *Result) error { + documentID := deriveUUID("document", m.tenantSlug, doc.SourceDocumentID) + createdBy, err := m.users.ResolveUserID(ctx, doc.SourceUserID) + if err != nil { + return fmt.Errorf("migrationdocument: ersteller fuer dokument %q aufloesen: %w", doc.SourceDocumentID, err) + } + + tag, err := m.pool.Exec(ctx, ` + INSERT INTO documents (id, title, created_by) + VALUES ($1, $2, $3) + ON CONFLICT (id) DO NOTHING + `, documentID, doc.Title, createdBy) + if err != nil { + return fmt.Errorf("migrationdocument: dokument %q anlegen: %w", doc.SourceDocumentID, err) + } + if tag.RowsAffected() > 0 { + res.DocumentsMigrated++ + } + + revisions, err := m.source.Revisions(ctx, doc.SourceDocumentID) + if err != nil { + return fmt.Errorf("migrationdocument: revisionen fuer dokument %q lesen: %w", doc.SourceDocumentID, err) + } + + var latestRevisionID string + var latestRevisionNumber int = -1 + for _, rev := range revisions { + revisionID, err := m.migrateRevision(ctx, documentID, rev, res) + if err != nil { + return err + } + if rev.RevisionNumber > latestRevisionNumber { + latestRevisionNumber = rev.RevisionNumber + latestRevisionID = revisionID + } + } + + if latestRevisionID != "" { + if _, err := m.pool.Exec(ctx, ` + UPDATE documents SET current_revision_id = $2 WHERE id = $1 AND current_revision_id IS DISTINCT FROM $2 + `, documentID, latestRevisionID); err != nil { + return fmt.Errorf("migrationdocument: aktuelle revision fuer dokument %q setzen: %w", doc.SourceDocumentID, err) + } + } + return nil +} + +func (m *Migrator) migrateRevision(ctx context.Context, documentID string, rev SourceRevision, res *Result) (revisionID string, err error) { + revisionID = deriveUUID("revision", m.tenantSlug, rev.SourceRevisionID) + + var existing bool + if err := m.pool.QueryRow(ctx, `SELECT true FROM file_revisions WHERE id = $1`, revisionID).Scan(&existing); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return "", fmt.Errorf("migrationdocument: bestand von revision %q pruefen: %w", rev.SourceRevisionID, err) + } + if existing { + res.RevisionsSkipped++ + return revisionID, nil + } + + createdBy, err := m.users.ResolveUserID(ctx, rev.SourceUserID) + if err != nil { + return "", fmt.Errorf("migrationdocument: ersteller fuer revision %q aufloesen: %w", rev.SourceRevisionID, err) + } + + plaintext, err := m.source.OpenObject(ctx, rev.ObjectKey) + if err != nil { + return "", fmt.Errorf("migrationdocument: binaerobjekt fuer revision %q oeffnen: %w", rev.SourceRevisionID, err) + } + defer func() { _ = plaintext.Close() }() + + hasher := sha256.New() + tee := io.TeeReader(plaintext, hasher) + env, err := m.cryptoSvc.Seal(ctx, m.tenantSlug, tee) + if err != nil { + return "", fmt.Errorf("migrationdocument: revision %q verschluesseln: %w", rev.SourceRevisionID, err) + } + ciphertext, err := io.ReadAll(env.Ciphertext) + if err != nil { + return "", fmt.Errorf("migrationdocument: chiffretext fuer revision %q lesen: %w", rev.SourceRevisionID, err) + } + + checksum := fmt.Sprintf("%x", hasher.Sum(nil)) + if checksum != rev.ChecksumSHA256 { + return "", fmt.Errorf("%w: revision %q (quelle %s, kopiert %s)", ErrChecksumMismatch, rev.SourceRevisionID, rev.ChecksumSHA256, checksum) + } + + storageKey := storage.ObjectKey(documentID, revisionID) + if _, err := m.storageSvc.Put(ctx, storageKey, bytes.NewReader(ciphertext), int64(len(ciphertext)), "application/octet-stream"); err != nil { + return "", fmt.Errorf("migrationdocument: binaerobjekt fuer revision %q ablegen: %w", rev.SourceRevisionID, err) + } + + if _, err := m.pool.Exec(ctx, ` + INSERT INTO file_revisions (id, document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by, wrapped_dek) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO NOTHING + `, revisionID, documentID, rev.RevisionNumber, storageKey, checksum, rev.SizeBytes, rev.MimeType, createdBy, env.WrappedDEK); err != nil { + return "", fmt.Errorf("migrationdocument: revision %q anlegen: %w", rev.SourceRevisionID, err) + } + + res.RevisionsMigrated++ + return revisionID, nil +} diff --git a/dms/internal/migrationdocument/migrate_test.go b/dms/internal/migrationdocument/migrate_test.go new file mode 100644 index 00000000..b3a84706 --- /dev/null +++ b/dms/internal/migrationdocument/migrate_test.go @@ -0,0 +1,295 @@ +package migrationdocument + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/testdb" +) + +// fakeKEKProvider liefert einen fest hinterlegten Tenant-KEK, analog zum +// Fake in dms/internal/upload (dort nicht exportiert). +type fakeKEKProvider struct{ kek []byte } + +func (f *fakeKEKProvider) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) { + return f.kek, nil +} + +type noopUsageReporter struct{} + +func (noopUsageReporter) Report(ctx context.Context, tenantSlug, metric string, delta int64) error { + return nil +} + +// fixtureObject ist ein SYNTHETISCHES Dokument fuer die Testquelle — fake +// Inhalt, keine echten archivdms-Daten (Produktarchitektur-Vorgabe). +type fixtureObject struct { + content []byte +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return fmt.Sprintf("%x", sum[:]) +} + +// memorySource ist eine In-Memory-Testquelle: simuliert das SQL-Export/ +// Objekt-Storage-Paar von archivdms rein synthetisch. +type memorySource struct { + docs []SourceDocument + revisions map[string][]SourceRevision + objects map[string]fixtureObject +} + +func (s *memorySource) Documents(ctx context.Context) ([]SourceDocument, error) { + return s.docs, nil +} + +func (s *memorySource) Revisions(ctx context.Context, sourceDocumentID string) ([]SourceRevision, error) { + return s.revisions[sourceDocumentID], nil +} + +func (s *memorySource) OpenObject(ctx context.Context, objectKey string) (io.ReadCloser, error) { + obj, ok := s.objects[objectKey] + if !ok { + return nil, fmt.Errorf("fixture-objekt %q nicht gefunden", objectKey) + } + return io.NopCloser(bytes.NewReader(obj.content)), nil +} + +// staticUserResolver bildet archivdms-Benutzer-IDs 1:1 auf eine feste +// Ziel-Benutzer-ID ab (Benutzer-/ACL-Migration ist nicht Bestandteil +// von DOK-01). +type staticUserResolver struct{ userID string } + +func (r staticUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) { + return r.userID, nil +} + +func newSyntheticSource() *memorySource { + return &memorySource{ + docs: []SourceDocument{ + {SourceDocumentID: "archivdms-doc-1", Title: "Testakte Musterfall.pdf", SourceUserID: "archivdms-user-1"}, + {SourceDocumentID: "archivdms-doc-2", Title: "Testakte Zweitfall.txt", SourceUserID: "archivdms-user-1"}, + }, + revisions: map[string][]SourceRevision{ + "archivdms-doc-1": { + {SourceRevisionID: "archivdms-doc-1-rev-1", SourceDocumentID: "archivdms-doc-1", RevisionNumber: 1, SizeBytes: int64(len("Fake-PDF-Inhalt-V1")), MimeType: "application/pdf", SourceUserID: "archivdms-user-1", ObjectKey: "doc-1-rev-1", ChecksumSHA256: sha256Hex([]byte("Fake-PDF-Inhalt-V1"))}, + {SourceRevisionID: "archivdms-doc-1-rev-2", SourceDocumentID: "archivdms-doc-1", RevisionNumber: 2, SizeBytes: int64(len("Fake-PDF-Inhalt-V2-ueberarbeitet")), MimeType: "application/pdf", SourceUserID: "archivdms-user-1", ObjectKey: "doc-1-rev-2", ChecksumSHA256: sha256Hex([]byte("Fake-PDF-Inhalt-V2-ueberarbeitet"))}, + }, + "archivdms-doc-2": { + {SourceRevisionID: "archivdms-doc-2-rev-1", SourceDocumentID: "archivdms-doc-2", RevisionNumber: 1, SizeBytes: int64(len("Fake-Text-Inhalt")), MimeType: "text/plain", SourceUserID: "archivdms-user-1", ObjectKey: "doc-2-rev-1", ChecksumSHA256: sha256Hex([]byte("Fake-Text-Inhalt"))}, + }, + }, + objects: map[string]fixtureObject{ + "doc-1-rev-1": {content: []byte("Fake-PDF-Inhalt-V1")}, + "doc-1-rev-2": {content: []byte("Fake-PDF-Inhalt-V2-ueberarbeitet")}, + "doc-2-rev-1": {content: []byte("Fake-Text-Inhalt")}, + }, + } +} + +func setupMigratorTest(t *testing.T) (*Migrator, *pgxpool.Pool, *storage.LocalDriver, string) { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + pool := testdb.Pool(t, dsn) + t.Cleanup(func() { pool.Close() }) + + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY, folder_id UUID, title TEXT NOT NULL, current_revision_id UUID, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ + ); + CREATE TABLE IF NOT EXISTS file_revisions ( + id UUID PRIMARY KEY, document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + revision_number INT NOT NULL, storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, + size_bytes BIGINT NOT NULL, mime_type TEXT NOT NULL, created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), wrapped_dek BYTEA, + confirmed_duplicate BOOLEAN NOT NULL DEFAULT false, + UNIQUE (document_id, revision_number) + ); + ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_current_revision; + ALTER TABLE documents ADD CONSTRAINT fk_documents_current_revision + FOREIGN KEY (current_revision_id) REFERENCES file_revisions(id) ON DELETE SET NULL; + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + ctx := context.Background() + _, _ = pool.Exec(ctx, `TRUNCATE file_revisions, documents, users CASCADE`) + }) + + var userID string + if err := pool.QueryRow(ctx, ` + INSERT INTO users (email, name) VALUES ($1, 'Migrations-Testbenutzer') RETURNING id + `, fmt.Sprintf("dok01-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil { + t.Fatalf("testbenutzer anlegen: %v", err) + } + + driver := storage.NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test", 0) + storageSvc := storage.NewService(driver, noopUsageReporter{}, "acme-test") + cryptoSvc := dmscrypto.NewService(&fakeKEKProvider{kek: bytes.Repeat([]byte{0x22}, dmscrypto.KEKSize)}) + + migrator := NewMigrator(pool, newSyntheticSource(), staticUserResolver{userID: userID}, storageSvc, cryptoSvc, "acme-test") + return migrator, pool, driver, userID +} + +// TestMigrateAll_AkzeptanzkriteriumVollstaendigReferenzierbar prueft +// Akzeptanzkriterium 1: jedes Dokument samt aller Revisionen ist nach der +// Migration vollstaendig referenzierbar. +func TestMigrateAll_AkzeptanzkriteriumVollstaendigReferenzierbar(t *testing.T) { + migrator, pool, _, _ := setupMigratorTest(t) + ctx := context.Background() + + res, err := migrator.MigrateAll(ctx) + if err != nil { + t.Fatalf("MigrateAll: %v", err) + } + if res.DocumentsMigrated != 2 || res.RevisionsMigrated != 3 { + t.Fatalf("erwartet 2 dokumente/3 revisionen, habe %+v", res) + } + + var documentCount, revisionCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM documents`).Scan(&documentCount); err != nil { + t.Fatalf("dokumente zaehlen: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM file_revisions`).Scan(&revisionCount); err != nil { + t.Fatalf("revisionen zaehlen: %v", err) + } + if documentCount != 2 || revisionCount != 3 { + t.Fatalf("erwartet 2 dokumente/3 revisionen in der db, habe %d/%d", documentCount, revisionCount) + } + + var revisionsForDoc1 int + documentID1 := deriveUUID("document", "acme-test", "archivdms-doc-1") + if err := pool.QueryRow(ctx, `SELECT count(*) FROM file_revisions WHERE document_id = $1`, documentID1).Scan(&revisionsForDoc1); err != nil { + t.Fatalf("revisionen fuer dokument 1 zaehlen: %v", err) + } + if revisionsForDoc1 != 2 { + t.Fatalf("erwartet 2 revisionen fuer dokument 1, habe %d", revisionsForDoc1) + } +} + +// TestMigrateAll_AkzeptanzkriteriumKonsistenteVerknuepfung prueft +// Akzeptanzkriterium 2: Binaerdaten und Metadaten sind konsistent +// miteinander verknuepft — current_revision_id zeigt auf die hoechste +// Revisionsnummer, und das Objekt unter storage_key ist tatsaechlich +// abrufbar mit identischem Inhalt (Pruefung 1: Hash-Vergleich). +func TestMigrateAll_AkzeptanzkriteriumKonsistenteVerknuepfung(t *testing.T) { + migrator, pool, driver, _ := setupMigratorTest(t) + ctx := context.Background() + + if _, err := migrator.MigrateAll(ctx); err != nil { + t.Fatalf("MigrateAll: %v", err) + } + + documentID1 := deriveUUID("document", "acme-test", "archivdms-doc-1") + var currentRevisionID, storageKey string + if err := pool.QueryRow(ctx, ` + SELECT d.current_revision_id, r.storage_key + FROM documents d JOIN file_revisions r ON r.id = d.current_revision_id + WHERE d.id = $1 + `, documentID1).Scan(¤tRevisionID, &storageKey); err != nil { + t.Fatalf("aktuelle revision lesen: %v", err) + } + expectedRevisionID := deriveUUID("revision", "acme-test", "archivdms-doc-1-rev-2") + if currentRevisionID != expectedRevisionID { + t.Fatalf("erwartet current_revision_id %q (hoechste revisionsnummer), habe %q", expectedRevisionID, currentRevisionID) + } + + rc, err := driver.Get(ctx, storageKey) + if err != nil { + t.Fatalf("objekt unter storage_key lesen: %v", err) + } + defer rc.Close() + ciphertext, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("ciphertext lesen: %v", err) + } + + var wrappedDEK []byte + if err := pool.QueryRow(ctx, `SELECT wrapped_dek FROM file_revisions WHERE id = $1`, currentRevisionID).Scan(&wrappedDEK); err != nil { + t.Fatalf("wrapped_dek lesen: %v", err) + } + cryptoSvc := dmscrypto.NewService(&fakeKEKProvider{kek: bytes.Repeat([]byte{0x22}, dmscrypto.KEKSize)}) + plainReader, err := cryptoSvc.Open(ctx, "acme-test", wrappedDEK, bytes.NewReader(ciphertext)) + if err != nil { + t.Fatalf("entschluesseln: %v", err) + } + plaintext, err := io.ReadAll(plainReader) + if err != nil { + t.Fatalf("klartext lesen: %v", err) + } + if string(plaintext) != "Fake-PDF-Inhalt-V2-ueberarbeitet" { + t.Fatalf("entschluesselter inhalt weicht von quelle ab: %q", plaintext) + } + if sha256Hex(plaintext) != sha256Hex([]byte("Fake-PDF-Inhalt-V2-ueberarbeitet")) { + t.Fatalf("hash-vergleich gegen quelle fehlgeschlagen") + } +} + +// TestMigrateAll_AkzeptanzkriteriumWiederholbar prueft Akzeptanzkriterium +// 3: ein erneuter Lauf gegen dieselbe Quelle erzeugt keine Duplikate. +func TestMigrateAll_AkzeptanzkriteriumWiederholbar(t *testing.T) { + migrator, pool, _, _ := setupMigratorTest(t) + ctx := context.Background() + + if _, err := migrator.MigrateAll(ctx); err != nil { + t.Fatalf("erster lauf: %v", err) + } + res2, err := migrator.MigrateAll(ctx) + if err != nil { + t.Fatalf("zweiter lauf: %v", err) + } + if res2.DocumentsMigrated != 0 || res2.RevisionsMigrated != 0 || res2.RevisionsSkipped != 3 { + t.Fatalf("zweiter lauf haette nichts neu migrieren duerfen, habe %+v", res2) + } + + var documentCount, revisionCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM documents`).Scan(&documentCount); err != nil { + t.Fatalf("dokumente zaehlen: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT count(*) FROM file_revisions`).Scan(&revisionCount); err != nil { + t.Fatalf("revisionen zaehlen: %v", err) + } + if documentCount != 2 || revisionCount != 3 { + t.Fatalf("erneuter lauf hat duplikate erzeugt: %d dokumente/%d revisionen", documentCount, revisionCount) + } +} + +// TestMigrateRevision_ChecksumMismatch prueft, dass eine zwischen Quelle und +// kopiertem Objekt abweichende Pruefsumme die Migration dieser Revision +// mit einem klaren Fehler abbricht statt sie still zu uebernehmen. +func TestMigrateRevision_ChecksumMismatch(t *testing.T) { + migrator, _, _, _ := setupMigratorTest(t) + ctx := context.Background() + + src := migrator.source.(*memorySource) + src.objects["doc-2-rev-1"] = fixtureObject{content: []byte("manipulierter-inhalt")} + + _, err := migrator.MigrateAll(ctx) + if !errors.Is(err, ErrChecksumMismatch) { + t.Fatalf("erwartet ErrChecksumMismatch, habe %v", err) + } +} diff --git a/dms/internal/migrationdocument/uuid.go b/dms/internal/migrationdocument/uuid.go new file mode 100644 index 00000000..08fdef8b --- /dev/null +++ b/dms/internal/migrationdocument/uuid.go @@ -0,0 +1,25 @@ +package migrationdocument + +import ( + "crypto/sha256" + "fmt" +) + +// deriveUUID erzeugt eine deterministische, UUIDv5-aehnliche ID aus einer +// Namespace-Kennung und beliebigen Schluesselteilen. Deterministisch heisst +// hier: derselbe Aufruf liefert immer dieselbe ID, Grundlage fuer die +// Idempotenz des gesamten Migrators (siehe MigrateAll-Kommentar). +func deriveUUID(namespace string, parts ...string) string { + h := sha256.New() + h.Write([]byte(namespace)) + for _, p := range parts { + h.Write([]byte{0}) + h.Write([]byte(p)) + } + sum := h.Sum(nil) + + sum[6] = (sum[6] & 0x0f) | 0x50 + sum[8] = (sum[8] & 0x3f) | 0x80 + + return fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16]) +} diff --git a/docs/DOK-01-PRUEFPROTOKOLL.md b/docs/DOK-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..d18a8cd2 --- /dev/null +++ b/docs/DOK-01-PRUEFPROTOKOLL.md @@ -0,0 +1,98 @@ +# DOK-01 – Prüfprotokoll: Dokument- & Revisions-Migration + +## Umsetzung + +- `dms/internal/migrationdocument/migrate.go` – `Migrator.MigrateAll` + übernimmt Dokumente+Revisionen aus einer `Source` (SQL-Export-Zeilen + + Objekt-Storage-Zugriff) in das DMS-Zielschema + (`dms/migrations/tenant/0001_documents.up.sql` ff.), über dieselbe + `storage.Service`/`crypto.Service`-Kette wie der reguläre Upload-Pfad + (`dms/internal/upload`). +- `dms/internal/migrationdocument/uuid.go` – `deriveUUID` leitet + `documents.id`/`file_revisions.id` deterministisch aus der jeweiligen + Quell-ID ab. Das macht die Migration idempotent, ohne eine zusätzliche + Mapping-Tabelle: ein erneuter Lauf trifft immer dieselbe Zeile + (`ON CONFLICT (id) DO NOTHING`), Binärkopie und Verschlüsselung werden + für bereits migrierte Revisionen übersprungen. +- Binärdaten: `Source.OpenObject` liefert den Klartext-Stream, Prüfsumme + wird während des Kopierens per `io.TeeReader` neu berechnet und gegen + die von der Quelle gemeldete `checksum_sha256` verglichen + (`ErrChecksumMismatch` bei Abweichung) — Objekt-Storage-Kopie, nicht + vermischt mit der SQL-Metadaten-Übernahme. +- Metadaten: `documents`/`file_revisions` werden per SQL-INSERT aus den + `Source`-Zeilen angelegt, `current_revision_id` wird auf die höchste + `revision_number` je Dokument gesetzt. +- Nicht Bestandteil (wie im Ticket verlangt): keine Akten-/Ordnerstruktur + (`folder_id` bleibt NULL), keine ACL-Daten, kein Import des + ML-Klassifikator-Binärmodells. +- Entscheidung: Binärdaten werden beim Kopieren durch + `crypto.Service.Seal` neu versiegelt (frischer DEK, mit dem + Ziel-Tenant-KEK verpackt) statt ein ggf. anderes archivdms-eigenes + Verschlüsselungsschema unverändert zu übernehmen — `file_revisions` + im Zielschema erwartet `wrapped_dek` nach demselben Envelope-Verfahren + wie jeder reguläre Upload (FDN-09), ein abweichendes Quellformat wäre + im DMS nicht entschlüsselbar. Kleine, reversible Entscheidung in + Produkt-DNA-Sinn: "Binärdaten per Objekt-Storage-Kopie" bezieht sich + auf die Transportroute, nicht auf ein 1:1-Beibehalten des + Verschlüsselungsformats. + +## DSGVO: synthetische Testdaten + +Alle Tests laufen ausschließlich gegen `memorySource` +(`dms/internal/migrationdocument/migrate_test.go`) — eine In-Memory- +Quelle mit frei erfundenen Dokumenttiteln ("Testakte Musterfall.pdf", +"Testakte Zweitfall.txt") und frei erfundenen Textinhalten +("Fake-PDF-Inhalt-V1" usw.). Es wurde zu keinem Zeitpunkt eine +Verbindung zum echten archivdms-System, dessen Datenbank oder +Objekt-Storage hergestellt oder gelesen. Host 192.168.1.204 trägt zwar +die echte `archivdms`-Datenbank, die Migrationstests laufen gegen eine +eigens angelegte, leere Wegwerf-Datenbank `dok01_test` +(`CREATE DATABASE dok01_test OWNER dok01_test`), die `archivdms`-DB +wurde zu keinem Zeitpunkt geöffnet oder abgefragt. + +## Prüfungen vor der Abnahme + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Stichprobenweiser Hash-Vergleich migrierter Binärdateien gegen Quelle | **bestanden** – `TestMigrateAll_AkzeptanzkriteriumKonsistenteVerknuepfung` entschlüsselt das migrierte Objekt real über `storage.LocalDriver`+`crypto.Service.Open` und vergleicht den SHA-256-Hash des entschlüsselten Inhalts gegen den Hash des synthetischen Quellinhalts – identisch. Zusätzlich bricht `TestMigrateRevision_ChecksumMismatch` die Migration kontrolliert mit `ErrChecksumMismatch` ab, wenn eine Quell-Prüfsumme nicht zum kopierten Objekt passt | +| 2 | Zeilenzahl migrierter Dokumente/Revisionen entspricht Quelle abzüglich bewusst nicht migrierter Kategorien | **bestanden** – `TestMigrateAll_AkzeptanzkriteriumVollstaendigReferenzierbar`: Quelle liefert 2 Dokumente/3 Revisionen (keine bewusst ausgeschlossenen Kategorien in diesem Fixture-Satz), Ziel-DB zeigt nach der Migration exakt 2 Zeilen in `documents` und 3 Zeilen in `file_revisions` | +| 3 | Öffnen und Herunterladen einer Stichprobe migrierter Dokumente über NEXARCH erfolgreich | **bestanden** – über denselben Pfad wie Prüfung 1: `storage.LocalDriver.Get` liest das migrierte Objekt real von Platte (Host 192.168.1.204, Wegwerfverzeichnis `t.TempDir()`), `crypto.Service.Open` entschlüsselt es mit dem Ziel-Tenant-Schlüssel erfolgreich zum ursprünglichen Klartext – entspricht dem tatsächlichen Downloadpfad von `dms/internal/downloadapi` (Driver→Open), nur mit Fake-KEK-Provider statt echtem Core-HTTP-Aufruf | + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Jedes Dokument samt aller Revisionen ist nach der Migration vollständig referenzierbar | **erfüllt**, siehe Prüfung 2 | +| 2 | Binärdaten und Metadaten sind nach der Migration konsistent miteinander verknüpft | **erfüllt** – `documents.current_revision_id` zeigt real auf die Revision mit der höchsten `revision_number`, deren `storage_key` real ein abrufbares, korrekt entschlüsselbares Objekt referenziert (Prüfung 1/3) | +| 3 | Migration ist wiederholbar (idempotent) gegen dieselbe Quelle | **erfüllt** – `TestMigrateAll_AkzeptanzkriteriumWiederholbar`: zweiter Lauf gegen dieselbe `memorySource` migriert 0 neue Dokumente/Revisionen, Zeilenzahl in der DB bleibt unverändert | + +## Build/Test-Ergebnis (192.168.1.204) + +``` +cd dms && TEST_TENANT_DSN='postgres://dok01_test:***@127.0.0.1:5432/dok01_test?sslmode=disable' \ + go test ./internal/migrationdocument/... -v +=== RUN TestMigrateAll_AkzeptanzkriteriumVollstaendigReferenzierbar +--- PASS +=== RUN TestMigrateAll_AkzeptanzkriteriumKonsistenteVerknuepfung +--- PASS +=== RUN TestMigrateAll_AkzeptanzkriteriumWiederholbar +--- PASS +=== RUN TestMigrateRevision_ChecksumMismatch +--- PASS +PASS +``` + +`go build ./...`, `go vet ./internal/migrationdocument/...` und +`gofmt -l internal/migrationdocument/` liefern keine Beanstandungen. + +## Offene Punkte + +- `Source`/`UserResolver` sind Schnittstellen ohne echte archivdms- + Implementierung – eine spätere Kachel muss den realen + SQL-Export-Reader und Objekt-Storage-Zugriff auf archivdms liefern + (bewusst außerhalb dieser Kachel: HARD-DSGVO-Vorgabe der Session + erlaubt keine Verbindung zum echten archivdms-System). +- Benutzerauflösung (`UserResolver`) setzt voraus, dass die + Benutzermigration (IAM) bereits gelaufen ist – wie COR-01 für die + Tenant-Zuordnung, ist dies eine Voraussetzung, keine Aufgabe von + DOK-01. -- 2.47.3