VER-01: Versionsmodell & Check-in/Check-out
Migration 0010: document_locks (Ausleih-Sperre mit Lease-Frist) und file_revisions.comment (Check-in-Kommentar gehoert zur neuen Revision). internal/versioning: CheckOut (atomar via INSERT...ON CONFLICT...WHERE lease_until < now(), Postgres entscheidet pro Zeile, wer bei gleichzeitigen Versuchen gewinnt), ForceUnlock, CheckIn (prueft Sperrinhaberschaft, verschluesselt+speichert als NEUE Revision, gibt Sperre frei), ListRevisions. Pruefungen: 1. Zwei gleichzeitige Ausleihversuche - nur einer gewinnt 2. Verwaiste Sperre (10ms Frist) wird automatisch geloest 3. Alte Revision nach Check-in real aus Storage geladen, entschluesselt und byteweise unveraendert verifiziert RBAC-Rollenpruefung fuer ForceUnlock (Akzeptanzkriterium 3: "durch Administratoren aufhebbar") ist Sache einer HTTP-Schicht, die fuer diese Kachel noch nicht existiert - gleiche Scope-Entscheidung wie VER-05. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed15ce12cf
commit
1aedd2ed67
5 files changed
+546
No files matched your search
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE file_revisions DROP COLUMN comment;
|
||||
DROP TABLE document_locks;
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user