diff --git a/dms/internal/migrationsharelink/migrate.go b/dms/internal/migrationsharelink/migrate.go new file mode 100644 index 00000000..d149e0ec --- /dev/null +++ b/dms/internal/migrationsharelink/migrate.go @@ -0,0 +1,111 @@ +// Package migrationsharelink implementiert DOK-05: Uebernahme aktiver, +// nicht abgelaufener Freigabelinks (DOC-07/DOC-22, dms/internal/sharelink) +// aus archivdms in das Ziel-DMS-Schema (dms/migrations/tenant/0033_share_links), +// unter den DOK-01 bereits migrierten Dokumenten. token_hash/password_hash +// werden unveraendert aus der Quelle uebernommen (Produkt-DNA: kein Rehash +// wo Quelle bereits kompatibel) -- damit bleibt dieselbe URL/derselbe Token +// nach der Migration gueltig. +package migrationsharelink + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// SourceShareLink ist eine Zeile aus archivdms' Freigabelink-Tabelle +// (document_shares, SQL-Export). +type SourceShareLink struct { + SourceLinkID string + SourceDocumentID string + SourceUserID string + TokenHash []byte + PasswordHash *string + DownloadLimit *int + DownloadCount int + CreatedAt time.Time + ExpiresAt time.Time +} + +// Source kapselt den lesenden Zugriff auf archivdms' Freigabelink-Schema. +// Eine echte Implementierung liest gegen archivdms, Tests verwenden +// ausschliesslich synthetische Fixtures. +type Source interface { + ShareLinks(ctx context.Context) ([]SourceShareLink, error) +} + +// UserResolver bildet eine archivdms-Benutzer-ID auf die bereits migrierte +// NEXARCH-Benutzer-ID ab (gleiches Interface wie migrationdocument). +type UserResolver interface { + ResolveUserID(ctx context.Context, sourceUserID string) (string, error) +} + +// Migrator uebertraegt Freigabelinks einer Quelle in genau einen Ziel-Tenant. +type Migrator struct { + pool *pgxpool.Pool + source Source + users UserResolver + tenantSlug string +} + +func NewMigrator(pool *pgxpool.Pool, source Source, users UserResolver, tenantSlug string) *Migrator { + return &Migrator{pool: pool, source: source, users: users, tenantSlug: tenantSlug} +} + +// Result fasst den Ausgang eines Migrationslaufs zusammen. +type Result struct { + LinksMigrated int + LinksExpired int +} + +// MigrateAll uebertraegt alle nicht abgelaufenen Freigabelinks +// (Akzeptanzkriterium 1); bereits abgelaufene Links werden gezaehlt, aber +// verworfen statt migriert. Ziel-IDs werden deterministisch aus den +// Quell-IDs abgeleitet (deriveUUID) -- ein erneuter Lauf trifft so immer +// dieselben Zeilen und erzeugt keine Duplikate. +func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) { + var res Result + + links, err := m.source.ShareLinks(ctx) + if err != nil { + return res, fmt.Errorf("migrationsharelink: freigabelinks aus quelle lesen: %w", err) + } + + now := time.Now() + for _, l := range links { + if !l.ExpiresAt.After(now) { + res.LinksExpired++ + continue + } + migrated, err := m.migrateLink(ctx, l) + if err != nil { + return res, err + } + if migrated { + res.LinksMigrated++ + } + } + return res, nil +} + +func (m *Migrator) migrateLink(ctx context.Context, l SourceShareLink) (bool, error) { + linkID := deriveUUID("sharelink", m.tenantSlug, l.SourceLinkID) + documentID := deriveUUID("document", m.tenantSlug, l.SourceDocumentID) + + createdBy, err := m.users.ResolveUserID(ctx, l.SourceUserID) + if err != nil { + return false, fmt.Errorf("migrationsharelink: ersteller fuer freigabelink %q aufloesen: %w", l.SourceLinkID, err) + } + + tag, err := m.pool.Exec(ctx, ` + INSERT INTO share_links (id, document_id, token_hash, password_hash, download_limit, download_count, created_by, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO NOTHING + `, linkID, documentID, l.TokenHash, l.PasswordHash, l.DownloadLimit, l.DownloadCount, createdBy, l.CreatedAt, l.ExpiresAt) + if err != nil { + return false, fmt.Errorf("migrationsharelink: freigabelink %q anlegen: %w", l.SourceLinkID, err) + } + return tag.RowsAffected() > 0, nil +} diff --git a/dms/internal/migrationsharelink/migrate_test.go b/dms/internal/migrationsharelink/migrate_test.go new file mode 100644 index 00000000..67bc69aa --- /dev/null +++ b/dms/internal/migrationsharelink/migrate_test.go @@ -0,0 +1,206 @@ +package migrationsharelink + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/sharelink" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/testdb" +) + +// memorySource simuliert archivdms' Freigabelink-Export rein synthetisch +// (Produktarchitektur-Vorgabe: keine echten archivdms-Daten in Tests). +type memorySource struct { + links []SourceShareLink +} + +func (s *memorySource) ShareLinks(ctx context.Context) ([]SourceShareLink, error) { + return s.links, nil +} + +type fixedUserResolver struct{ userID string } + +func (r fixedUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) { + return r.userID, nil +} + +func hashToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +func strPtr(s string) *string { return &s } +func intPtr(i int) *int { return &i } + +func setupMigratorTest(t *testing.T) (*Migrator, *pgxpool.Pool, 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 share_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + token_hash BYTEA NOT NULL, + password_hash TEXT, + download_limit INT, + download_count INT NOT NULL DEFAULT 0, + failed_attempts INT NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + CHECK (download_limit IS NULL OR download_limit > 0) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_share_links_token_hash ON share_links(token_hash); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + ctx := context.Background() + _, _ = pool.Exec(ctx, `TRUNCATE share_links, documents, users CASCADE`) + }) + + var userID string + if err := pool.QueryRow(ctx, ` + INSERT INTO users (email, name) VALUES ($1, 'Migrations-Testbenutzer') RETURNING id + `, fmt.Sprintf("dok05-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil { + t.Fatalf("testbenutzer anlegen: %v", err) + } + + tenantSlug := "acme-test" + for _, sourceDocID := range []string{"archivdms-doc-1", "archivdms-doc-2"} { + documentID := deriveUUID("document", tenantSlug, sourceDocID) + if _, err := pool.Exec(ctx, ` + INSERT INTO documents (id, title, created_by) VALUES ($1, $2, $3) + `, documentID, "Testakte "+sourceDocID, userID); err != nil { + t.Fatalf("testdokument %q anlegen: %v", sourceDocID, err) + } + } + + source := &memorySource{ + links: []SourceShareLink{ + { + SourceLinkID: "archivdms-share-1", SourceDocumentID: "archivdms-doc-1", SourceUserID: "archivdms-user-1", + TokenHash: hashToken("aktiver-token-1"), DownloadLimit: intPtr(5), DownloadCount: 1, + CreatedAt: time.Now().Add(-24 * time.Hour), ExpiresAt: time.Now().Add(7 * 24 * time.Hour), + }, + { + SourceLinkID: "archivdms-share-2", SourceDocumentID: "archivdms-doc-2", SourceUserID: "archivdms-user-1", + TokenHash: hashToken("aktiver-token-2"), PasswordHash: strPtr("$2a$10$abcdefghijklmnopqrstuv"), + CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(30 * 24 * time.Hour), + }, + { + SourceLinkID: "archivdms-share-3-abgelaufen", SourceDocumentID: "archivdms-doc-1", SourceUserID: "archivdms-user-1", + TokenHash: hashToken("abgelaufener-token"), + CreatedAt: time.Now().Add(-30 * 24 * time.Hour), ExpiresAt: time.Now().Add(-24 * time.Hour), + }, + }, + } + + migrator := NewMigrator(pool, source, fixedUserResolver{userID: userID}, tenantSlug) + return migrator, pool, tenantSlug +} + +// TestMigrateAll_AkzeptanzkriteriumNurAktiveLinks prueft Akzeptanzkriterium 1 +// (Pruefung 2: abgelaufener Testlink nachweislich nicht migriert): nur +// Freigabelinks mit Ablaufdatum in der Zukunft werden migriert. +func TestMigrateAll_AkzeptanzkriteriumNurAktiveLinks(t *testing.T) { + migrator, pool, tenantSlug := setupMigratorTest(t) + ctx := context.Background() + + res, err := migrator.MigrateAll(ctx) + if err != nil { + t.Fatalf("MigrateAll: %v", err) + } + if res.LinksMigrated != 2 || res.LinksExpired != 1 { + t.Fatalf("erwartet 2 migrierte/1 abgelaufenen link, habe %+v", res) + } + + abgelaufenID := deriveUUID("sharelink", tenantSlug, "archivdms-share-3-abgelaufen") + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM share_links WHERE id = $1`, abgelaufenID).Scan(&count); err != nil { + t.Fatalf("abgelaufenen link pruefen: %v", err) + } + if count != 0 { + t.Fatalf("abgelaufener link wurde migriert, haette verworfen werden muessen") + } +} + +// TestMigrateAll_AkzeptanzkriteriumTokenFunktioniertUnveraendert prueft +// Akzeptanzkriterium 2 (Pruefung 1: Stichprobe aktiver Links nach Migration +// erfolgreich aufgerufen): migrierte Links loesen mit demselben Klartext- +// Token ueber sharelink.Resolve auf wie vor der Migration. +func TestMigrateAll_AkzeptanzkriteriumTokenFunktioniertUnveraendert(t *testing.T) { + migrator, pool, tenantSlug := setupMigratorTest(t) + ctx := context.Background() + + if _, err := migrator.MigrateAll(ctx); err != nil { + t.Fatalf("MigrateAll: %v", err) + } + + documentID, linkID, err := sharelink.Resolve(ctx, pool, "aktiver-token-1", "", 5, time.Minute) + if err != nil { + t.Fatalf("resolve fuer aktiven token: %v", err) + } + wantDocumentID := deriveUUID("document", tenantSlug, "archivdms-doc-1") + if documentID != wantDocumentID { + t.Fatalf("falsches dokument aufgeloest: erwartet %q habe %q", wantDocumentID, documentID) + } + wantLinkID := deriveUUID("sharelink", tenantSlug, "archivdms-share-1") + if linkID != wantLinkID { + t.Fatalf("falscher link aufgeloest: erwartet %q habe %q", wantLinkID, linkID) + } + + if _, _, err := sharelink.Resolve(ctx, pool, "abgelaufener-token", "", 5, time.Minute); err != sharelink.ErrNotFound { + t.Fatalf("abgelaufener token haette ErrNotFound liefern muessen, habe %v", err) + } +} + +// TestMigrateAll_AkzeptanzkriteriumWiederholbar prueft, dass ein erneuter +// Lauf gegen dieselbe Quelle keine Duplikate erzeugt (gleiches Idempotenz- +// Muster wie DOK-01/DOK-04). +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.LinksMigrated != 0 { + t.Fatalf("zweiter lauf haette nichts neu migrieren duerfen, habe %+v", res2) + } + + var count int + _ = pool.QueryRow(ctx, `SELECT count(*) FROM share_links`).Scan(&count) + if count != 2 { + t.Fatalf("erneuter lauf hat duplikate erzeugt: %d links statt 2", count) + } +} diff --git a/dms/internal/migrationsharelink/uuid.go b/dms/internal/migrationsharelink/uuid.go new file mode 100644 index 00000000..5af62aae --- /dev/null +++ b/dms/internal/migrationsharelink/uuid.go @@ -0,0 +1,26 @@ +package migrationsharelink + +import ( + "crypto/sha256" + "fmt" +) + +// deriveUUID ist identisch zu migrationdocument.deriveUUID (bewusst +// dupliziert, nicht importiert): Namespace "document" muss hier dieselben +// Ziel-IDs treffen wie DOK-01, damit Freigabelinks an den DORT bereits +// migrierten Dokumenten verankert werden koennen, ohne eine zusaetzliche +// Mapping-Tabelle. +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-05-PRUEFPROTOKOLL.md b/docs/DOK-05-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..2de5e02c --- /dev/null +++ b/docs/DOK-05-PRUEFPROTOKOLL.md @@ -0,0 +1,61 @@ +# DOK-05 – Prüfprotokoll Freigabelinks-Migration + +Durchgeführt am 2026-10-14 auf Host 204 gegen echten Postgres-Testcluster +(DSN aus `/etc/nexarch-dev/tenant-dev.env`, `TEST_TENANT_DSN`), isoliertes +Test-Schema pro Lauf (`dms/internal/testdb`). Ausschließlich synthetische +Testfixtures (`archivdms-share-1/2/3-abgelaufen`), keine echten +archivmail-/archivdms-Daten. + +## Prüfung 1 — Stichprobe aktiver Links nach Migration erfolgreich aufgerufen + +- **Ziel:** Ein migrierter, nicht abgelaufener Freigabelink löst mit demselben + Klartext-Token wie vor der Migration über `sharelink.Resolve` auf dieselbe + Dokument-ID auf (Akzeptanzkriterium 2 — dieselbe URL/derselbe Token + funktioniert unverändert). +- **Vorgehen:** Test `TestMigrateAll_AkzeptanzkriteriumTokenFunktioniertUnveraendert` + migriert 2 synthetische aktive Links (einer ohne, einer mit Passwort-Hash) + und ruft danach `sharelink.Resolve(ctx, pool, "aktiver-token-1", ...)` + gegen die Zieltabelle `share_links` auf — denselben Code-Pfad, den auch + der reguläre Abruf-Endpunkt nutzt. +- **Ergebnis:** PASS. `Resolve` liefert die erwartete migrierte Dokument- + und Link-ID; `token_hash`/`password_hash` wurden unverändert aus der + Quelle übernommen (kein Rehash, Produkt-DNA). + +## Prüfung 2 — Abgelaufener Testlink ist nachweislich nicht migriert worden + +- **Ziel:** Ein Freigabelink mit Ablaufdatum in der Vergangenheit wird + verworfen statt migriert (Akzeptanzkriterium 1). +- **Vorgehen:** Test `TestMigrateAll_AkzeptanzkriteriumNurAktiveLinks` + migriert ein Fixture-Set aus 2 aktiven und 1 bereits abgelaufenen Link + (`archivdms-share-3-abgelaufen`, `expires_at` 24h in der Vergangenheit) + und prüft `Result.LinksMigrated`/`LinksExpired` sowie direkt per SQL, dass + keine Zeile mit der für den abgelaufenen Link deterministisch abgeleiteten + ID in `share_links` existiert. +- **Ergebnis:** PASS. 2 aktive Links migriert, 1 abgelaufener Link verworfen + (`LinksExpired == 1`), keine Zeile in `share_links` für den abgelaufenen + Link. Zusätzlich bestätigt `sharelink.Resolve` für den abgelaufenen Token + `sharelink.ErrNotFound`. + +## Zusatzprüfung — Wiederholbarkeit (Idempotenz) + +- **Ziel:** Ein erneuter Migrationslauf gegen dieselbe Quelle erzeugt keine + Duplikate (gleiches Muster wie DOK-01/DOK-04, deterministische + Ziel-UUID-Ableitung). +- **Ergebnis:** PASS (`TestMigrateAll_AkzeptanzkriteriumWiederholbar`): + zweiter Lauf migriert 0 neue Links, Gesamtzahl bleibt bei 2. + +## Testlauf + +``` +go test ./internal/migrationsharelink/... -v +=== RUN TestMigrateAll_AkzeptanzkriteriumNurAktiveLinks +--- PASS: TestMigrateAll_AkzeptanzkriteriumNurAktiveLinks (0.10s) +=== RUN TestMigrateAll_AkzeptanzkriteriumTokenFunktioniertUnveraendert +--- PASS: TestMigrateAll_AkzeptanzkriteriumTokenFunktioniertUnveraendert (0.08s) +=== RUN TestMigrateAll_AkzeptanzkriteriumWiederholbar +--- PASS: TestMigrateAll_AkzeptanzkriteriumWiederholbar (0.08s) +PASS +ok gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationsharelink 0.261s +``` + +`go build ./...` und `go vet ./...` (Modul `dms`) ohne Befunde.