Files
nexarch/dms/internal/versioningapi/handler_test.go
T
patrickandClaude Sonnet 5 b5cee200db VER-04: diff-ansicht
Diff-Endpunkt (GET /documents/{id}/diff) im versioningapi-Handler, Diff-Seite
mit freier Revisionswahl, Farbe+Symbol je Aenderung und Sprung zur naechsten
Aenderung im dms-browser-Frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:31:57 +02:00

316 lines
12 KiB
Go

package versioningapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"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"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/versioning"
)
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
server *httptest.Server
}
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("verapi-a-%d@example.test", time.Now().UnixNano())).Scan(&userA); err != nil {
t.Fatalf("user a: %v", err)
}
if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ($1, 'B') RETURNING id`, fmt.Sprintf("verapi-b-%d@example.test", time.Now().UnixNano())).Scan(&userB); err != nil {
t.Fatalf("user b: %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{0x55}, dmscrypto.KEKSize)})
server := NewServer(pool, storageSvc, cryptoSvc, "acme", &OwnerChecker{Pool: pool})
mux := http.NewServeMux()
server.RegisterRoutes(mux)
httpServer := httptest.NewServer(mux)
t.Cleanup(httpServer.Close)
return &testEnv{pool: pool, storage: storageSvc, crypto: cryptoSvc, server: httpServer}, userA, userB
}
func createDocumentWithRevision(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)
}
sealEnv, err := env.crypto.Seal(ctx, "acme", strings.NewReader(content))
if err != nil {
t.Fatalf("seal: %v", err)
}
var buf bytes.Buffer
_, _ = buf.ReadFrom(sealEnv.Ciphertext)
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, sealEnv.WrappedDEK).Scan(&revID); err != nil {
t.Fatalf("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 1: Revisionen chronologisch mit Metadaten sichtbar.
func TestHandleList_ReturnsRevisionsChronologicallyWithMetadata(t *testing.T) {
env, userA, _ := setupTest(t)
doc := createDocumentWithRevision(t, env, userA, "inhalt")
resp, err := http.Get(env.server.URL + "/documents/" + doc + "/revisions?caller=" + userA)
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var revisions []revisionDTO
_ = json.NewDecoder(resp.Body).Decode(&revisions)
if len(revisions) != 1 || revisions[0].CreatedBy != userA {
t.Fatalf("erwartet 1 revision mit autor %q, habe: %+v", userA, revisions)
}
}
// Akzeptanzkriterium/Pflichtpruefung 3: Berechtigung wird je Revision
// geprueft - userB (weder Dokument- noch Revisions-Ersteller) sieht die
// Revision als NICHT zugaenglich markiert und bekommt beim Content-Abruf
// 403.
func TestPermission_CheckedPerRevisionNotJustPerDocument(t *testing.T) {
env, userA, userB := setupTest(t)
doc := createDocumentWithRevision(t, env, userA, "geheim")
resp, err := http.Get(env.server.URL + "/documents/" + doc + "/revisions?caller=" + userB)
if err != nil {
t.Fatalf("get (userB liste): %v", err)
}
defer func() { _ = resp.Body.Close() }()
var revisions []revisionDTO
_ = json.NewDecoder(resp.Body).Decode(&revisions)
if len(revisions) != 1 || revisions[0].Accessible {
t.Fatalf("userB haette die revision als NICHT zugaenglich sehen sollen: %+v", revisions)
}
contentResp, err := http.Get(env.server.URL + "/documents/" + doc + "/revisions/" + revisions[0].ID + "/content?caller=" + userB)
if err != nil {
t.Fatalf("get (userB content): %v", err)
}
defer func() { _ = contentResp.Body.Close() }()
if contentResp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403 fuer nicht-berechtigten aufrufer", contentResp.StatusCode)
}
// userA (ersteller) darf hingegen zugreifen.
okResp, err := http.Get(env.server.URL + "/documents/" + doc + "/revisions/" + revisions[0].ID + "/content?caller=" + userA)
if err != nil {
t.Fatalf("get (userA content): %v", err)
}
defer func() { _ = okResp.Body.Close() }()
if okResp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 fuer berechtigten aufrufer", okResp.StatusCode)
}
var buf bytes.Buffer
_, _ = buf.ReadFrom(okResp.Body)
if buf.String() != "geheim" {
t.Fatalf("inhalt = %q, want 'geheim'", buf.String())
}
}
// Akzeptanzkriterium 2: Wiederherstellung ueber die API erzeugt eine neue
// Revision.
func TestHandleRestore_CreatesNewRevisionViaAPI(t *testing.T) {
env, userA, _ := setupTest(t)
doc := createDocumentWithRevision(t, env, userA, "original")
resp, err := http.Get(env.server.URL + "/documents/" + doc + "/revisions?caller=" + userA)
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var revisions []revisionDTO
_ = json.NewDecoder(resp.Body).Decode(&revisions)
restoreResp, err := http.Post(env.server.URL+"/documents/"+doc+"/revisions/"+revisions[0].ID+"/restore?caller="+userA, "application/json", nil)
if err != nil {
t.Fatalf("post restore: %v", err)
}
defer func() { _ = restoreResp.Body.Close() }()
if restoreResp.StatusCode != http.StatusOK {
body := new(bytes.Buffer)
_, _ = body.ReadFrom(restoreResp.Body)
t.Fatalf("status = %d, want 200: %s", restoreResp.StatusCode, body.String())
}
var restored revisionDTO
_ = json.NewDecoder(restoreResp.Body).Decode(&restored)
if restored.RevisionNumber != 2 {
t.Fatalf("restore haette revision 2 erzeugen sollen, habe %d", restored.RevisionNumber)
}
all, err := versioning.ListRevisions(context.Background(), env.pool, doc)
if err != nil {
t.Fatalf("ListRevisions: %v", err)
}
if len(all) != 2 {
t.Fatalf("erwartet 2 revisionen nach restore, habe %d", len(all))
}
}
// VER-04 Akzeptanzkriterium 1: Revisionen sind frei zum Vergleich waehlbar
// (jedes beliebige Paar ueber den Diff-Endpunkt, nicht nur benachbarte).
func TestHandleDiff_ComparesFreelyChosenRevisions(t *testing.T) {
env, userA, _ := setupTest(t)
ctx := context.Background()
doc := createDocumentWithRevision(t, env, userA, "Zeile 1\nZeile 2\nZeile 3")
if _, err := versioning.CheckOut(ctx, env.pool, doc, userA, versioning.DefaultLeaseDuration); err != nil {
t.Fatalf("checkout: %v", err)
}
if _, err := versioning.CheckIn(ctx, env.pool, env.storage, env.crypto, "acme", doc, userA, "zweite fassung", "text/plain", strings.NewReader("Zeile 1\nZeile 2 geaendert\nZeile 3\nZeile 4")); err != nil {
t.Fatalf("checkin: %v", err)
}
all, err := versioning.ListRevisions(ctx, env.pool, doc)
if err != nil || len(all) != 2 {
t.Fatalf("ListRevisions: %v, %d", err, len(all))
}
resp, err := http.Get(env.server.URL + "/documents/" + doc + "/diff?caller=" + userA + "&from=" + all[0].ID + "&to=" + all[1].ID)
if err != nil {
t.Fatalf("get diff: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body := new(bytes.Buffer)
_, _ = body.ReadFrom(resp.Body)
t.Fatalf("status = %d: %s", resp.StatusCode, body.String())
}
var got diffResponseDTO
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
var deletes, inserts int
for _, op := range got.Text {
switch op.Op {
case "delete":
deletes++
case "insert":
inserts++
}
}
if deletes != 1 || inserts != 2 {
t.Fatalf("erwartet 1 loeschung, 2 einfuegungen, habe deletes=%d inserts=%d: %+v", deletes, inserts, got.Text)
}
foundComment := false
for _, d := range got.Metadata {
if d.Field == "comment" && d.After == "zweite fassung" {
foundComment = true
}
}
if !foundComment {
t.Fatalf("erwartete comment-metadatenaenderung nicht gefunden: %+v", got.Metadata)
}
}
// Berechtigung wird fuer BEIDE Revisionen geprueft, nicht nur eine.
func TestHandleDiff_RequiresPermissionOnBothRevisions(t *testing.T) {
env, userA, userB := setupTest(t)
ctx := context.Background()
doc := createDocumentWithRevision(t, env, userA, "geheim v1")
if _, err := versioning.CheckOut(ctx, env.pool, doc, userA, versioning.DefaultLeaseDuration); err != nil {
t.Fatalf("checkout: %v", err)
}
if _, err := versioning.CheckIn(ctx, env.pool, env.storage, env.crypto, "acme", doc, userA, "v2", "text/plain", strings.NewReader("geheim v2")); err != nil {
t.Fatalf("checkin: %v", err)
}
all, err := versioning.ListRevisions(ctx, env.pool, doc)
if err != nil || len(all) != 2 {
t.Fatalf("ListRevisions: %v, %d", err, len(all))
}
resp, err := http.Get(env.server.URL + "/documents/" + doc + "/diff?caller=" + userB + "&from=" + all[0].ID + "&to=" + all[1].ID)
if err != nil {
t.Fatalf("get diff: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403 fuer nicht-berechtigten aufrufer", resp.StatusCode)
}
}