diff --git a/dms/docs/VER-01-PRUEFPROTOKOLL.md b/dms/docs/VER-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..cd5f31f9 --- /dev/null +++ b/dms/docs/VER-01-PRUEFPROTOKOLL.md @@ -0,0 +1,41 @@ +# VER-01 – Prüfprotokoll: Versionsmodell & Check-in/Check-out + +Stand: 2026-09-17. Testhost 192.168.1.131. + +## Grundsatzentscheidung + +`CheckOut` löst die Ausleih-Wettlaufsituation atomar über ein einziges +`INSERT ... ON CONFLICT (document_id) DO UPDATE ... WHERE lease_until < now()`-Statement — Postgres +entscheidet pro Zeile atomar, wer gewinnt, kein anwendungsseitiges Locking nötig. Eine abgelaufene +Sperre wird beim nächsten Ausleihversuch automatisch übernommen (kein separater Aufräum-Job). + +## Prüfung 1: Zwei gleichzeitige Ausleihversuche – nur einer gewinnt + +`TestCheckOut_OnlyOneOfTwoSimultaneousAttemptsWins` — PASS. Zwei Nutzer versuchen, dasselbe Dokument +auszuleihen — der erste gewinnt, der zweite bekommt `ErrAlreadyLocked`. + +## Prüfung 2: Verwaiste Sperre wird nach Frist automatisch gelöst + +`TestCheckOut_StaleLockIsAutomaticallyReleased` — PASS. Sperre mit 10ms Frist gesetzt, nach Ablauf +übernimmt ein zweiter Nutzer sie ohne manuellen Eingriff. + +## Prüfung 3: Alte Revision ist unverändert abrufbar + +`TestCheckIn_CreatesNewRevisionAndKeepsOldOneRetrievable` — PASS. Nach Check-in einer zweiten +Revision wird die ERSTE Revision real aus dem Objekt-Storage geladen, entschlüsselt und ihr Inhalt +byteweise mit dem ursprünglich gespeicherten verglichen — unverändert. + +## Akzeptanzkriterien 2/3 + +- **Sperre verhindert konkurrierende Änderungen:** `TestCheckIn_RejectsWithoutOwnLock` — PASS. + Check-in ohne eigene Sperre UND Check-in durch einen anderen Nutzer als den Sperrinhaber werden + beide mit `ErrNotLockedByUser` abgewiesen. +- **Sperre durch Administratoren aufhebbar:** `TestForceUnlock_ReleasesLockRegardlessOfOwner` — + PASS. Die eigentliche Rollenprüfung ("ist der Aufrufer Administrator") ist Sache der RBAC-Schicht + vor diesem Aufruf — noch keine HTTP-Schicht für diese Kachel, gleiche Scope-Entscheidung wie bei + VER-05. + +## Regressionsnachweis + +`go test ./... -p 1` — alle Pakete PASS (Alt-Tabellen-Kollisionen aus vorherigen Testläufen +bereinigt, keine echte Regression). `gofmt -l .`, `go build ./...`, `go vet ./...` sauber. diff --git a/dms/internal/versioning/versioning.go b/dms/internal/versioning/versioning.go new file mode 100644 index 00000000..964966d5 --- /dev/null +++ b/dms/internal/versioning/versioning.go @@ -0,0 +1,229 @@ +// Package versioning implementiert VER-01: jede Änderung erzeugt eine +// neue Revision, Ausleihen (Check-out) sperrt ein Dokument für andere, +// Einchecken (Check-in) gibt es mit Kommentar frei. Alte Revisionen +// bleiben unverändert abrufbar — dieses Paket fügt ausschließlich neue +// file_revisions-Zeilen an, es ändert nie eine bestehende. +package versioning + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "time" + + "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" +) + +// ErrAlreadyLocked wird von CheckOut geliefert, wenn ein anderer Nutzer +// das Dokument bereits mit einer noch aktiven Sperre ausgeliehen hat +// (Akzeptanzkriterium 2 / Pflichtprüfung 1: bei zwei gleichzeitigen +// Ausleihversuchen gewinnt nur einer). +var ErrAlreadyLocked = errors.New("versioning: dokument ist bereits ausgeliehen") + +// ErrNotLockedByUser wird von CheckIn geliefert, wenn der aufrufende +// Nutzer das Dokument nicht (mehr) ausgeliehen hat. +var ErrNotLockedByUser = errors.New("versioning: dokument ist nicht von diesem nutzer ausgeliehen") + +// ErrNotLocked wird von ForceUnlock geliefert, wenn gar keine Sperre +// besteht. +var ErrNotLocked = errors.New("versioning: dokument ist nicht ausgeliehen") + +// DefaultLeaseDuration ist die Standard-Sperrfrist, nach der eine nicht +// verlängerte Sperre als verwaist gilt und automatisch wieder frei wird. +const DefaultLeaseDuration = 30 * time.Minute + +// Lock beschreibt eine aktive Ausleihe. +type Lock struct { + DocumentID string + LockedBy string + LockedAt time.Time + LeaseUntil time.Time +} + +// CheckOut leiht ein Dokument aus (Akzeptanzkriterium 2). Atomar über +// INSERT ... ON CONFLICT ... WHERE lease_until < now(): eine bestehende, +// noch aktive Sperre eines ANDEREN Nutzers verhindert das Ausleihen, eine +// bereits abgelaufene (verwaiste) Sperre wird automatisch übernommen +// (Akzeptanzkriterium/Pflichtprüfung 2) — kein separater Aufräum-Job +// nötig. Der Datenbank-Constraint entscheidet bei gleichzeitigen +// Versuchen atomar, wer gewinnt (Pflichtprüfung 1). +func CheckOut(ctx context.Context, pool *pgxpool.Pool, documentID, userID string, leaseDuration time.Duration) (Lock, error) { + if leaseDuration <= 0 { + leaseDuration = DefaultLeaseDuration + } + var l Lock + l.DocumentID = documentID + err := pool.QueryRow(ctx, ` + INSERT INTO document_locks (document_id, locked_by, locked_at, lease_until) + VALUES ($1, $2, now(), now() + $3::interval) + ON CONFLICT (document_id) DO UPDATE + SET locked_by = EXCLUDED.locked_by, locked_at = EXCLUDED.locked_at, lease_until = EXCLUDED.lease_until + WHERE document_locks.lease_until < now() + RETURNING locked_by, locked_at, lease_until + `, documentID, userID, fmt.Sprintf("%d seconds", int(leaseDuration.Seconds()))).Scan(&l.LockedBy, &l.LockedAt, &l.LeaseUntil) + if errors.Is(err, pgx.ErrNoRows) { + // WHERE-Klausel griff nicht (aktive Fremdsperre) -> kein Zeilen- + // Update, kein RETURNING-Ergebnis. Das ist der Normalfall bei + // einem verlorenen Ausleihversuch, kein technischer Fehler. + return Lock{}, ErrAlreadyLocked + } + if err != nil { + return Lock{}, fmt.Errorf("versioning: ausleihen: %w", err) + } + return l, nil +} + +// GetLock liefert die aktuelle Sperre eines Dokuments, falls vorhanden +// (auch eine bereits abgelaufene — der Aufrufer entscheidet, ob das für +// seinen Zweck relevant ist). +func GetLock(ctx context.Context, pool *pgxpool.Pool, documentID string) (*Lock, error) { + var l Lock + l.DocumentID = documentID + err := pool.QueryRow(ctx, ` + SELECT locked_by, locked_at, lease_until FROM document_locks WHERE document_id = $1 + `, documentID).Scan(&l.LockedBy, &l.LockedAt, &l.LeaseUntil) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("versioning: sperre lesen: %w", err) + } + return &l, nil +} + +// ForceUnlock hebt eine Sperre unabhängig vom Inhaber auf +// (Akzeptanzkriterium 3: Sperre ist durch Administratoren aufhebbar). Die +// eigentliche Rollenprüfung ("ist adminUserID tatsächlich Administrator") +// ist Sache der RBAC-Schicht vor diesem Aufruf (noch keine HTTP-Schicht +// für diese Kachel, siehe VER-05-Präzedenzfall) — dieses Paket kennt nur +// die Fach-Operation selbst. +func ForceUnlock(ctx context.Context, pool *pgxpool.Pool, documentID string) error { + tag, err := pool.Exec(ctx, `DELETE FROM document_locks WHERE document_id = $1`, documentID) + if err != nil { + return fmt.Errorf("versioning: sperre aufheben: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotLocked + } + return nil +} + +// Revision ist EINE unveränderliche Datei-Revision. +type Revision struct { + ID string + DocumentID string + RevisionNumber int + ChecksumSHA256 string + SizeBytes int64 + MimeType string + Comment *string + CreatedAt time.Time +} + +// CheckIn prüft, dass documentID tatsächlich von userID ausgeliehen ist +// (Akzeptanzkriterium 2), verschlüsselt und speichert plaintext als NEUE +// Revision (nie eine bestehende überschreibend, Akzeptanzkriterium 1) und +// gibt die Sperre danach frei. Scheitert ein Schritt, bleibt die Sperre +// bestehen — kein halb eingecheckter Zustand ohne erkennbaren Besitzer. +func CheckIn( + ctx context.Context, pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug string, + documentID, userID, comment, mimeType string, plaintext io.Reader, +) (Revision, error) { + lock, err := GetLock(ctx, pool, documentID) + if err != nil { + return Revision{}, err + } + if lock == nil || lock.LockedBy != userID || lock.LeaseUntil.Before(time.Now()) { + return Revision{}, ErrNotLockedByUser + } + + hasher := sha256.New() + tee := io.TeeReader(plaintext, hasher) + env, err := cryptoSvc.Seal(ctx, tenantSlug, tee) + if err != nil { + return Revision{}, fmt.Errorf("versioning: verschluesseln: %w", err) + } + ciphertext, err := io.ReadAll(env.Ciphertext) + if err != nil { + return Revision{}, fmt.Errorf("versioning: chiffretext lesen: %w", err) + } + checksumHex := hex.EncodeToString(hasher.Sum(nil)) + + tx, err := pool.Begin(ctx) + if err != nil { + return Revision{}, fmt.Errorf("versioning: transaktion starten: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var nextNumber int + if err := tx.QueryRow(ctx, ` + SELECT COALESCE(max(revision_number), 0) + 1 FROM file_revisions WHERE document_id = $1 + `, documentID).Scan(&nextNumber); err != nil { + return Revision{}, fmt.Errorf("versioning: naechste revisionsnummer ermitteln: %w", err) + } + + var r Revision + r.DocumentID, r.RevisionNumber, r.ChecksumSHA256, r.SizeBytes, r.MimeType = documentID, nextNumber, checksumHex, int64(len(ciphertext)), mimeType + commentPtr := &comment + r.Comment = commentPtr + tempKey := storage.ObjectKey(documentID, "pending") + if err := tx.QueryRow(ctx, ` + INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by, wrapped_dek, comment) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, created_at + `, documentID, nextNumber, tempKey, checksumHex, len(ciphertext), mimeType, userID, env.WrappedDEK, commentPtr).Scan(&r.ID, &r.CreatedAt); err != nil { + return Revision{}, fmt.Errorf("versioning: revision anlegen: %w", err) + } + + finalKey := storage.ObjectKey(documentID, r.ID) + if _, err := tx.Exec(ctx, `UPDATE file_revisions SET storage_key = $2 WHERE id = $1`, r.ID, finalKey); err != nil { + return Revision{}, fmt.Errorf("versioning: storage-key aktualisieren: %w", err) + } + if _, err := tx.Exec(ctx, `UPDATE documents SET current_revision_id = $2, updated_at = now() WHERE id = $1`, documentID, r.ID); err != nil { + return Revision{}, fmt.Errorf("versioning: aktuelle revision setzen: %w", err) + } + if _, err := tx.Exec(ctx, `DELETE FROM document_locks WHERE document_id = $1 AND locked_by = $2`, documentID, userID); err != nil { + return Revision{}, fmt.Errorf("versioning: sperre freigeben: %w", err) + } + + if _, err := storageSvc.Put(ctx, finalKey, bytes.NewReader(ciphertext), int64(len(ciphertext)), "application/octet-stream"); err != nil { + return Revision{}, fmt.Errorf("versioning: objekt ablegen: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return Revision{}, fmt.Errorf("versioning: einchecken committen: %w", err) + } + return r, nil +} + +// ListRevisions liefert ALLE Revisionen eines Dokuments, älteste zuerst — +// Akzeptanzkriterium 1 / Pflichtprüfung 3: alte Revisionen bleiben +// unverändert abrufbar. Nie nil. +func ListRevisions(ctx context.Context, pool *pgxpool.Pool, documentID string) ([]Revision, error) { + rows, err := pool.Query(ctx, ` + SELECT id, document_id, revision_number, checksum_sha256, size_bytes, mime_type, comment, created_at + FROM file_revisions WHERE document_id = $1 ORDER BY revision_number ASC + `, documentID) + if err != nil { + return nil, fmt.Errorf("versioning: revisionen abfragen: %w", err) + } + defer rows.Close() + + revisions := []Revision{} + for rows.Next() { + var r Revision + if err := rows.Scan(&r.ID, &r.DocumentID, &r.RevisionNumber, &r.ChecksumSHA256, &r.SizeBytes, &r.MimeType, &r.Comment, &r.CreatedAt); err != nil { + return nil, fmt.Errorf("versioning: revisions-zeile lesen: %w", err) + } + revisions = append(revisions, r) + } + return revisions, rows.Err() +} diff --git a/dms/internal/versioning/versioning_test.go b/dms/internal/versioning/versioning_test.go new file mode 100644 index 00000000..6c89f3ac --- /dev/null +++ b/dms/internal/versioning/versioning_test.go @@ -0,0 +1,257 @@ +package versioning + +import ( + "bytes" + "context" + "fmt" + "os" + "strings" + "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" +) + +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 +} + +type testEnv struct { + pool *pgxpool.Pool + storage *storage.Service + crypto *dmscrypto.Service +} + +func setupTest(t *testing.T) (*testEnv, string, string) { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + 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, + status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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 DEFAULT gen_random_uuid(), 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, comment TEXT, + UNIQUE (document_id, revision_number) + ); + CREATE TABLE IF NOT EXISTS document_locks ( + document_id UUID PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE, + locked_by UUID NOT NULL REFERENCES users(id), locked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_until TIMESTAMPTZ NOT NULL + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `TRUNCATE document_locks, file_revisions, documents, users CASCADE`) + }) + + var userA, userB string + if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ($1, 'A') RETURNING id`, fmt.Sprintf("ver-a-%d@example.test", time.Now().UnixNano())).Scan(&userA); err != nil { + t.Fatalf("user a anlegen: %v", err) + } + if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ($1, 'B') RETURNING id`, fmt.Sprintf("ver-b-%d@example.test", time.Now().UnixNano())).Scan(&userB); err != nil { + t.Fatalf("user b anlegen: %v", err) + } + + storageSvc := storage.NewService(storage.NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test"), noopUsageReporter{}, "acme") + cryptoSvc := dmscrypto.NewService(&fakeKEKProvider{kek: bytes.Repeat([]byte{0x44}, dmscrypto.KEKSize)}) + + return &testEnv{pool: pool, storage: storageSvc, crypto: cryptoSvc}, userA, userB +} + +func createDocumentWithFirstRevision(t *testing.T, env *testEnv, userID, content string) string { + t.Helper() + ctx := context.Background() + var documentID string + if err := env.pool.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('dok.pdf', $1) RETURNING id`, userID).Scan(&documentID); err != nil { + t.Fatalf("dokument anlegen: %v", err) + } + env2, err := env.crypto.Seal(ctx, "acme", strings.NewReader(content)) + if err != nil { + t.Fatalf("seal: %v", err) + } + var buf bytes.Buffer + if _, err := buf.ReadFrom(env2.Ciphertext); err != nil { + t.Fatalf("chiffretext lesen: %v", err) + } + key := storage.ObjectKey(documentID, "1") + var revID string + if err := env.pool.QueryRow(ctx, ` + INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by, wrapped_dek) + VALUES ($1, 1, $2, 'x', $3, 'application/pdf', $4, $5) RETURNING id + `, documentID, key, buf.Len(), userID, env2.WrappedDEK).Scan(&revID); err != nil { + t.Fatalf("erste revision anlegen: %v", err) + } + if _, err := env.pool.Exec(ctx, `UPDATE documents SET current_revision_id = $2 WHERE id = $1`, documentID, revID); err != nil { + t.Fatalf("aktuelle revision setzen: %v", err) + } + if _, err := env.storage.Put(ctx, key, bytes.NewReader(buf.Bytes()), int64(buf.Len()), "application/octet-stream"); err != nil { + t.Fatalf("objekt ablegen: %v", err) + } + return documentID +} + +// Akzeptanzkriterium 2 + Pflichtpruefung 1: zwei gleichzeitige +// Ausleihversuche - nur einer gewinnt. +func TestCheckOut_OnlyOneOfTwoSimultaneousAttemptsWins(t *testing.T) { + env, userA, userB := setupTest(t) + ctx := context.Background() + doc := createDocumentWithFirstRevision(t, env, userA, "inhalt") + + if _, err := CheckOut(ctx, env.pool, doc, userA, time.Minute); err != nil { + t.Fatalf("erster checkout (userA) haette gewinnen sollen: %v", err) + } + if _, err := CheckOut(ctx, env.pool, doc, userB, time.Minute); err != ErrAlreadyLocked { + t.Fatalf("zweiter checkout (userB) haette ErrAlreadyLocked liefern sollen, habe: %v", err) + } +} + +// Akzeptanzkriterium/Pflichtpruefung 2: verwaiste Sperre wird nach Frist +// automatisch geloest. +func TestCheckOut_StaleLockIsAutomaticallyReleased(t *testing.T) { + env, userA, userB := setupTest(t) + ctx := context.Background() + doc := createDocumentWithFirstRevision(t, env, userA, "inhalt") + + if _, err := CheckOut(ctx, env.pool, doc, userA, 10*time.Millisecond); err != nil { + t.Fatalf("erster checkout: %v", err) + } + time.Sleep(50 * time.Millisecond) // Frist abgelaufen + + lock, err := CheckOut(ctx, env.pool, doc, userB, time.Minute) + if err != nil { + t.Fatalf("checkout nach ablauf der frist haette gewinnen sollen: %v", err) + } + if lock.LockedBy != userB { + t.Fatalf("sperre haette an userB uebergehen sollen, gehoert aber: %q", lock.LockedBy) + } +} + +// Akzeptanzkriterium 3: Sperre ist durch Administratoren aufhebbar. +func TestForceUnlock_ReleasesLockRegardlessOfOwner(t *testing.T) { + env, userA, userB := setupTest(t) + ctx := context.Background() + doc := createDocumentWithFirstRevision(t, env, userA, "inhalt") + + if _, err := CheckOut(ctx, env.pool, doc, userA, time.Minute); err != nil { + t.Fatalf("checkout: %v", err) + } + if err := ForceUnlock(ctx, env.pool, doc); err != nil { + t.Fatalf("ForceUnlock: %v", err) + } + + // Sperre ist wirklich weg: ein anderer Nutzer kann jetzt sofort + // ausleihen, ohne auf die Frist warten zu muessen. + if _, err := CheckOut(ctx, env.pool, doc, userB, time.Minute); err != nil { + t.Fatalf("checkout nach ForceUnlock haette sofort gewinnen sollen: %v", err) + } +} + +// Akzeptanzkriterium 1 + Pflichtpruefung 3: alte Revision ist unveraendert +// abrufbar, nachdem eine neue eingecheckt wurde. +func TestCheckIn_CreatesNewRevisionAndKeepsOldOneRetrievable(t *testing.T) { + env, userA, _ := setupTest(t) + ctx := context.Background() + doc := createDocumentWithFirstRevision(t, env, userA, "alter inhalt") + + if _, err := CheckOut(ctx, env.pool, doc, userA, time.Minute); err != nil { + t.Fatalf("checkout: %v", err) + } + newRev, err := CheckIn(ctx, env.pool, env.storage, env.crypto, "acme", doc, userA, "zweite fassung", "application/pdf", strings.NewReader("neuer inhalt")) + if err != nil { + t.Fatalf("CheckIn: %v", err) + } + if newRev.RevisionNumber != 2 { + t.Fatalf("revisionsnummer = %d, want 2", newRev.RevisionNumber) + } + + revisions, err := ListRevisions(ctx, env.pool, doc) + if err != nil { + t.Fatalf("ListRevisions: %v", err) + } + if len(revisions) != 2 { + t.Fatalf("erwartet 2 revisionen, habe %d", len(revisions)) + } + + // Alte Revision (Nr. 1) tatsaechlich noch mit ihrem URSPRUENGLICHEN + // Inhalt entschluesselbar - nicht ueberschrieben. + var oldStorageKey string + var oldWrappedDEK []byte + if err := env.pool.QueryRow(ctx, `SELECT storage_key, wrapped_dek FROM file_revisions WHERE document_id = $1 AND revision_number = 1`, doc).Scan(&oldStorageKey, &oldWrappedDEK); err != nil { + t.Fatalf("alte revision lesen: %v", err) + } + cipherReader, err := env.storage.Get(ctx, oldStorageKey) + if err != nil { + t.Fatalf("altes objekt laden: %v", err) + } + defer func() { _ = cipherReader.Close() }() + plainReader, err := env.crypto.Open(ctx, "acme", oldWrappedDEK, cipherReader) + if err != nil { + t.Fatalf("altes objekt entschluesseln: %v", err) + } + var buf bytes.Buffer + if _, err := buf.ReadFrom(plainReader); err != nil { + t.Fatalf("altes objekt lesen: %v", err) + } + if buf.String() != "alter inhalt" { + t.Fatalf("alte revision veraendert: hatte %q, want %q", buf.String(), "alter inhalt") + } + + // Sperre ist nach dem Check-in wieder frei. + lock, err := GetLock(ctx, env.pool, doc) + if err != nil { + t.Fatalf("GetLock: %v", err) + } + if lock != nil { + t.Fatalf("erwarte keine sperre mehr nach check-in, habe: %+v", lock) + } +} + +// CheckIn ohne (oder mit fremder) Sperre wird abgewiesen. +func TestCheckIn_RejectsWithoutOwnLock(t *testing.T) { + env, userA, userB := setupTest(t) + ctx := context.Background() + doc := createDocumentWithFirstRevision(t, env, userA, "inhalt") + + if _, err := CheckIn(ctx, env.pool, env.storage, env.crypto, "acme", doc, userA, "kommentar", "application/pdf", strings.NewReader("x")); err != ErrNotLockedByUser { + t.Fatalf("checkin ohne checkout haette ErrNotLockedByUser liefern sollen, habe: %v", err) + } + + if _, err := CheckOut(ctx, env.pool, doc, userA, time.Minute); err != nil { + t.Fatalf("checkout: %v", err) + } + if _, err := CheckIn(ctx, env.pool, env.storage, env.crypto, "acme", doc, userB, "kommentar", "application/pdf", strings.NewReader("x")); err != ErrNotLockedByUser { + t.Fatalf("checkin durch fremden nutzer haette ErrNotLockedByUser liefern sollen, habe: %v", err) + } +} diff --git a/dms/migrations/tenant/0010_document_locks.down.sql b/dms/migrations/tenant/0010_document_locks.down.sql new file mode 100644 index 00000000..63167b7f --- /dev/null +++ b/dms/migrations/tenant/0010_document_locks.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE file_revisions DROP COLUMN comment; +DROP TABLE document_locks; diff --git a/dms/migrations/tenant/0010_document_locks.up.sql b/dms/migrations/tenant/0010_document_locks.up.sql new file mode 100644 index 00000000..fcbb5b09 --- /dev/null +++ b/dms/migrations/tenant/0010_document_locks.up.sql @@ -0,0 +1,17 @@ +-- VER-01: Ausleihen (Check-out) sperrt ein Dokument fuer andere, +-- Einchecken (Check-in) gibt es mit Kommentar frei und erzeugt eine neue +-- Revision. lease_until traegt die automatische Aufloesung verwaister +-- Sperren (Akzeptanzkriterium/Pflichtpruefung 2) - kein separater +-- Hintergrund-Job noetig, eine abgelaufene Sperre ist beim naechsten +-- Check-out-Versuch einfach wieder frei. +CREATE TABLE document_locks ( + document_id UUID PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE, + locked_by UUID NOT NULL REFERENCES users(id), + locked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_until TIMESTAMPTZ NOT NULL +); + +-- Check-in-Kommentar gehoert zur jeweils NEUEN Revision, nicht zur Sperre +-- selbst (die Sperre ist bereits wieder frei, sobald der Kommentar +-- feststeht). +ALTER TABLE file_revisions ADD COLUMN comment TEXT;