package auditapi import ( "context" "encoding/csv" "encoding/json" "io" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail" ) func setupTest(t *testing.T) (*pgxpool.Pool, *httptest.Server) { 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 TABLE IF NOT EXISTS audit_log ( id BIGSERIAL PRIMARY KEY, occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), actor TEXT NOT NULL, action TEXT NOT NULL, object_type TEXT NOT NULL, object_reference TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, prev_hash TEXT NOT NULL, entry_hash TEXT NOT NULL ); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE audit_log RESTART IDENTITY`) }) server := NewServer(pool, NewAllowlistRoleChecker([]string{"auditor-1"})) mux := http.NewServeMux() server.RegisterRoutes(mux) httpServer := httptest.NewServer(mux) t.Cleanup(httpServer.Close) return pool, httpServer } // Akzeptanzkriterium 1: Filter nach Zeitraum, Akteur und Aktion vorhanden. func TestHandleList_FiltersbyActorAndAction(t *testing.T) { pool, server := setupTest(t) ctx := context.Background() if _, err := audittrail.Append(ctx, pool, "user-a", "login", "session", "s1", nil); err != nil { t.Fatalf("append 1: %v", err) } if _, err := audittrail.Append(ctx, pool, "user-b", "logout", "session", "s2", nil); err != nil { t.Fatalf("append 2: %v", err) } resp, err := http.Get(server.URL + "/audit-log?caller=auditor-1&actor=user-a") if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() var entries []entryDTO if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil { t.Fatalf("decode: %v", err) } if len(entries) != 1 || entries[0].Actor != "user-a" { t.Fatalf("erwartet 1 eintrag von user-a, habe: %+v", entries) } } // Akzeptanzkriterium 3 / Pflichtpruefung 2: nicht berechtigter Nutzer // erhaelt keinen Zugriff. func TestHandleList_RejectsUnauthorizedCaller(t *testing.T) { _, server := setupTest(t) resp, err := http.Get(server.URL + "/audit-log?caller=irgendwer") if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusForbidden { t.Fatalf("status = %d, want 403", resp.StatusCode) } } // Akzeptanzkriterium 2: Export enthaelt exakt die gefilterten Eintraege. func TestHandleExport_ContainsExactlyFilteredEntries(t *testing.T) { pool, server := setupTest(t) ctx := context.Background() if _, err := audittrail.Append(ctx, pool, "user-a", "login", "session", "s1", nil); err != nil { t.Fatalf("append 1: %v", err) } if _, err := audittrail.Append(ctx, pool, "user-b", "login", "session", "s2", nil); err != nil { t.Fatalf("append 2: %v", err) } resp, err := http.Get(server.URL + "/audit-log/export?caller=auditor-1&actor=user-a") if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d", resp.StatusCode) } reader := csv.NewReader(resp.Body) rows, err := reader.ReadAll() if err != nil { t.Fatalf("csv lesen: %v", err) } if len(rows) != 2 { // header + 1 datenzeile t.Fatalf("erwartet header + 1 zeile, habe %d zeilen: %v", len(rows), rows) } if rows[1][2] != "user-a" { // spalte "actor" t.Fatalf("erwartet actor=user-a in der exportierten zeile, habe: %v", rows[1]) } } // Pflichtpruefung 3: Exportdatei ist in Tabellenkalkulation korrekt lesbar // — reale RFC4180-CSV-Parsbarkeit inkl. Kommas/Anfuehrungszeichen in // Feldern (object_reference kann beliebigen Text enthalten). func TestHandleExport_ProducesValidCSVWithSpecialCharacters(t *testing.T) { pool, server := setupTest(t) ctx := context.Background() if _, err := audittrail.Append(ctx, pool, "user-a", "note", "document", `Titel, "mit" Sonderzeichen`, nil); err != nil { t.Fatalf("append: %v", err) } resp, err := http.Get(server.URL + "/audit-log/export?caller=auditor-1") if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() reader := csv.NewReader(resp.Body) rows, err := reader.ReadAll() if err != nil { t.Fatalf("csv nicht parsbar: %v", err) } if len(rows) != 2 || rows[1][5] != `Titel, "mit" Sonderzeichen` { t.Fatalf("sonderzeichen nicht korrekt roundgetriped: %v", rows) } } // Pflichtpruefung 1: Export mit 100.000 Eintraegen laeuft ohne Abbruch. // Direkter Bulk-Insert statt 100.000 einzelner Append-Aufrufe (die // Advisory-Lock-Serialisierung von Append ist bereits in VER-05 separat // getestet) - hier geht es ausschliesslich um die Skalierung des // Export-Lesepfads selbst. func TestHandleExport_HandlesOneHundredThousandEntriesWithoutFailure(t *testing.T) { pool, server := setupTest(t) ctx := context.Background() if _, err := pool.Exec(ctx, ` INSERT INTO audit_log (actor, action, object_type, object_reference, prev_hash, entry_hash) SELECT 'bulk-user', 'bulk-action', 'document', 'doc-' || gs, 'x', 'x' || gs FROM generate_series(1, 100000) AS gs `); err != nil { t.Fatalf("bulk-insert: %v", err) } start := time.Now() resp, err := http.Get(server.URL + "/audit-log/export?caller=auditor-1") if err != nil { t.Fatalf("get: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("body lesen: %v", err) } elapsed := time.Since(start) t.Logf("export von 100.000 eintraegen: %s, %d bytes", elapsed, len(body)) lineCount := strings.Count(string(body), "\n") if lineCount != 100001 { // header + 100.000 zeilen t.Fatalf("erwartet 100.001 zeilen (inkl. header), habe %d", lineCount) } if elapsed > 30*time.Second { t.Fatalf("export hat %s gedauert, erwartet deutlich schneller", elapsed) } }