Files
nexarch/dms/internal/versioning/versioning_test.go
T
patrickandClaude Sonnet 5 1aedd2ed67 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>
2026-09-17 21:11:05 +02:00

258 lines
9.8 KiB
Go

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)
}
}