DOK-05: Freigabelinks-Migration #268
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user