DOK-01: Dokument- & Revisions-Migration #255
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user