Files
nexarch/dms/internal/folder/folder_test.go
T
patrickandClaude Sonnet 5 43574f7872 DOC-17: malware-scan-vor-ablage
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>
2026-09-18 00:41:52 +02:00

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