DOK-05: Freigabelinks-Migration #268

Merged
patrick merged 1 commits from feature/dok-05-freigabelinks-migration into main 2026-10-14 22:05:05 +02:00
4 changed files with 404 additions and 0 deletions

No files matched your search

+111
View File
@@ -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
}
@@ -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)
}
}
+26
View File
@@ -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])
}
+61
View File
@@ -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.