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