ClamAV-Anbindung (clamd-INSTREAM-Protokoll) als verpflichtender Scan-Schritt vor Ablage. Fund fuehrt zu vollstaendiger Entfernung von Dateiobjekt und Dokument plus Audit-Event statt stiller Ablage; Sichtbarkeit in Listen/Direktzugriff gesperrt bis scan_status=clean. EICAR-Erkennung real gegen installierten clamd verifiziert (dabei Null-Byte-Antwort-Bug gefunden und behoben). Nebenbei: fehlende trashed_at-Spalte in drei Test-Schemata (DOC-11-Luecke) nachgetragen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
292 lines
8.7 KiB
Go
292 lines
8.7 KiB
Go
package folder
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func requireTestPool(t *testing.T) (*pgxpool.Pool, 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 folders (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), parent_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL, path TEXT NOT NULL, created_by UUID NOT NULL REFERENCES users(id),
|
|
trashed_at TIMESTAMPTZ, trashed_via_folder_id UUID,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_folders_path ON folders (path text_pattern_ops);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE folders, users CASCADE`) })
|
|
|
|
var userID string
|
|
if err := pool.QueryRow(ctx, `
|
|
INSERT INTO users (email, name) VALUES ($1, 'Test-Benutzer') RETURNING id
|
|
`, fmt.Sprintf("folder-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil {
|
|
t.Fatalf("testbenutzer anlegen: %v", err)
|
|
}
|
|
return pool, userID
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: beliebig tiefe Hierarchie abbildbar.
|
|
func TestCreate_ArbitraryDepthHierarchy(t *testing.T) {
|
|
pool, userID := requireTestPool(t)
|
|
ctx := context.Background()
|
|
|
|
var parentID *string
|
|
var last Folder
|
|
for i := 0; i < 20; i++ {
|
|
f, err := Create(ctx, pool, fmt.Sprintf("ebene-%d", i), parentID, userID)
|
|
if err != nil {
|
|
t.Fatalf("Create ebene %d: %v", i, err)
|
|
}
|
|
parentID = &f.ID
|
|
last = f
|
|
}
|
|
|
|
// Pfad der tiefsten Ebene enthaelt alle 20 IDs.
|
|
subtree, err := Subtree(ctx, pool, last.ID)
|
|
if err != nil {
|
|
t.Fatalf("Subtree: %v", err)
|
|
}
|
|
if len(subtree) != 1 {
|
|
t.Fatalf("erwartet 1 (blatt hat keine kinder), habe %d", len(subtree))
|
|
}
|
|
|
|
root, err := Get(ctx, pool, *findRootID(t, ctx, pool, last.ID))
|
|
if err != nil {
|
|
t.Fatalf("Get root: %v", err)
|
|
}
|
|
rootSubtree, err := Subtree(ctx, pool, root.ID)
|
|
if err != nil {
|
|
t.Fatalf("Subtree(root): %v", err)
|
|
}
|
|
if len(rootSubtree) != 20 {
|
|
t.Fatalf("erwartet 20 ordner im gesamten teilbaum, habe %d", len(rootSubtree))
|
|
}
|
|
}
|
|
|
|
func findRootID(t *testing.T, ctx context.Context, pool *pgxpool.Pool, folderID string) *string {
|
|
t.Helper()
|
|
f, err := Get(ctx, pool, folderID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if f.ParentFolderID == nil {
|
|
return &f.ID
|
|
}
|
|
return findRootID(t, ctx, pool, *f.ParentFolderID)
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung: Verschieben aktualisiert den gesamten
|
|
// Teilbaum.
|
|
func TestMove_UpdatesEntireSubtree(t *testing.T) {
|
|
pool, userID := requireTestPool(t)
|
|
ctx := context.Background()
|
|
|
|
root1, err := Create(ctx, pool, "wurzel-1", nil, userID)
|
|
if err != nil {
|
|
t.Fatalf("root1: %v", err)
|
|
}
|
|
root2, err := Create(ctx, pool, "wurzel-2", nil, userID)
|
|
if err != nil {
|
|
t.Fatalf("root2: %v", err)
|
|
}
|
|
child, err := Create(ctx, pool, "kind", &root1.ID, userID)
|
|
if err != nil {
|
|
t.Fatalf("child: %v", err)
|
|
}
|
|
grandchild, err := Create(ctx, pool, "enkel", &child.ID, userID)
|
|
if err != nil {
|
|
t.Fatalf("grandchild: %v", err)
|
|
}
|
|
|
|
if err := Move(ctx, pool, child.ID, &root2.ID); err != nil {
|
|
t.Fatalf("Move: %v", err)
|
|
}
|
|
|
|
movedChild, err := Get(ctx, pool, child.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get child: %v", err)
|
|
}
|
|
if *movedChild.ParentFolderID != root2.ID {
|
|
t.Fatalf("kind haette unter root2 haengen sollen")
|
|
}
|
|
|
|
movedGrandchild, err := Get(ctx, pool, grandchild.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get grandchild: %v", err)
|
|
}
|
|
if movedGrandchild.Path[:len(movedChild.Path)] != movedChild.Path {
|
|
t.Fatalf("enkel-pfad %q sollte mit kind-pfad %q beginnen (teilbaum mitgewandert)", movedGrandchild.Path, movedChild.Path)
|
|
}
|
|
|
|
subtreeUnderRoot2, err := Subtree(ctx, pool, root2.ID)
|
|
if err != nil {
|
|
t.Fatalf("Subtree(root2): %v", err)
|
|
}
|
|
if len(subtreeUnderRoot2) != 3 { // root2, kind, enkel
|
|
t.Fatalf("erwartet 3 ordner unter root2 nach dem verschieben, habe %d", len(subtreeUnderRoot2))
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 + Pflichtpruefung 1: Zyklus beim Verschieben wird
|
|
// verhindert.
|
|
func TestMove_PreventsCycle(t *testing.T) {
|
|
pool, userID := requireTestPool(t)
|
|
ctx := context.Background()
|
|
|
|
root, err := Create(ctx, pool, "wurzel", nil, userID)
|
|
if err != nil {
|
|
t.Fatalf("root: %v", err)
|
|
}
|
|
child, err := Create(ctx, pool, "kind", &root.ID, userID)
|
|
if err != nil {
|
|
t.Fatalf("child: %v", err)
|
|
}
|
|
grandchild, err := Create(ctx, pool, "enkel", &child.ID, userID)
|
|
if err != nil {
|
|
t.Fatalf("grandchild: %v", err)
|
|
}
|
|
|
|
// Wurzel unter ihr eigenes Kind verschieben -> Zyklus.
|
|
if err := Move(ctx, pool, root.ID, &child.ID); err != ErrCycle {
|
|
t.Fatalf("erwartet ErrCycle, habe: %v", err)
|
|
}
|
|
// Wurzel unter ihren eigenen Enkel verschieben -> ebenfalls Zyklus.
|
|
if err := Move(ctx, pool, root.ID, &grandchild.ID); err != ErrCycle {
|
|
t.Fatalf("erwartet ErrCycle (enkel), habe: %v", err)
|
|
}
|
|
// Ordner in sich selbst verschieben -> Zyklus.
|
|
if err := Move(ctx, pool, child.ID, &child.ID); err != ErrCycle {
|
|
t.Fatalf("erwartet ErrCycle (selbst), habe: %v", err)
|
|
}
|
|
|
|
// Zustand nach den abgelehnten Versuchen unveraendert.
|
|
unchangedChild, err := Get(ctx, pool, child.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if *unchangedChild.ParentFolderID != root.ID {
|
|
t.Fatal("kind haette nach abgelehntem zyklus-versuch unveraendert unter root bleiben sollen")
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pflichtpruefung 3: Umbenennen bricht keine
|
|
// Verweise - der Pfad (auf IDs basierend) bleibt unveraendert, auch fuer
|
|
// Kinder.
|
|
func TestRename_DoesNotBreakPathReferences(t *testing.T) {
|
|
pool, userID := requireTestPool(t)
|
|
ctx := context.Background()
|
|
|
|
root, err := Create(ctx, pool, "alter-name", nil, userID)
|
|
if err != nil {
|
|
t.Fatalf("root: %v", err)
|
|
}
|
|
child, err := Create(ctx, pool, "kind", &root.ID, userID)
|
|
if err != nil {
|
|
t.Fatalf("child: %v", err)
|
|
}
|
|
pathBefore := root.Path
|
|
childPathBefore := child.Path
|
|
|
|
if err := Rename(ctx, pool, root.ID, "neuer-name"); err != nil {
|
|
t.Fatalf("Rename: %v", err)
|
|
}
|
|
|
|
renamed, err := Get(ctx, pool, root.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if renamed.Name != "neuer-name" {
|
|
t.Fatalf("name = %q, want neuer-name", renamed.Name)
|
|
}
|
|
if renamed.Path != pathBefore {
|
|
t.Fatalf("pfad haette sich durch umbenennen NICHT aendern duerfen: %q != %q", renamed.Path, pathBefore)
|
|
}
|
|
|
|
unchangedChild, err := Get(ctx, pool, child.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get child: %v", err)
|
|
}
|
|
if unchangedChild.Path != childPathBefore {
|
|
t.Fatalf("kind-pfad haette sich durch umbenennen des elternteils NICHT aendern duerfen: %q != %q", unchangedChild.Path, childPathBefore)
|
|
}
|
|
|
|
// Teilbaum-Abfrage ueber den (unveraenderten) Pfad findet das kind
|
|
// weiterhin - der "Verweis" (Pfad) ist nicht gebrochen.
|
|
subtree, err := Subtree(ctx, pool, root.ID)
|
|
if err != nil {
|
|
t.Fatalf("Subtree: %v", err)
|
|
}
|
|
if len(subtree) != 2 {
|
|
t.Fatalf("erwartet 2 ordner (wurzel+kind) nach umbenennen, habe %d", len(subtree))
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pflichtpruefung 2: Abfrage bei 10.000 Ordnern im
|
|
// Zeitbudget.
|
|
func TestSubtree_RemainsFastWith10000Folders(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("uebersprungen (-short)")
|
|
}
|
|
pool, userID := requireTestPool(t)
|
|
ctx := context.Background()
|
|
|
|
root, err := Create(ctx, pool, "grosse-wurzel", nil, userID)
|
|
if err != nil {
|
|
t.Fatalf("root: %v", err)
|
|
}
|
|
|
|
// Breiter Baum: 100 Kinder der Wurzel, je 99 Enkel -> 9900 + 100 + 1 = 10001.
|
|
const branches = 100
|
|
const perBranch = 99
|
|
for i := 0; i < branches; i++ {
|
|
branch, err := Create(ctx, pool, fmt.Sprintf("zweig-%d", i), &root.ID, userID)
|
|
if err != nil {
|
|
t.Fatalf("branch %d: %v", i, err)
|
|
}
|
|
for j := 0; j < perBranch; j++ {
|
|
if _, err := Create(ctx, pool, fmt.Sprintf("blatt-%d-%d", i, j), &branch.ID, userID); err != nil {
|
|
t.Fatalf("leaf %d/%d: %v", i, j, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
start := time.Now()
|
|
subtree, err := Subtree(ctx, pool, root.ID)
|
|
elapsed := time.Since(start)
|
|
if err != nil {
|
|
t.Fatalf("Subtree: %v", err)
|
|
}
|
|
t.Logf("teilbaum-abfrage ueber %d ordner: %v", len(subtree), elapsed)
|
|
if len(subtree) != branches*perBranch+branches+1 {
|
|
t.Fatalf("erwartet %d ordner, habe %d", branches*perBranch+branches+1, len(subtree))
|
|
}
|
|
if elapsed > 500*time.Millisecond {
|
|
t.Fatalf("teilbaum-abfrage dauerte %v, erwartet < 500ms", elapsed)
|
|
}
|
|
}
|