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>
316 lines
12 KiB
Go
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)
|
|
}
|
|
}
|