package audit import ( "bytes" "context" "encoding/csv" "fmt" "net/http" "net/http/httptest" "os" "runtime" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) func setupExportTest(t *testing.T) (*Log, func()) { t.Helper() adminDSN := os.Getenv("TEST_ADMIN_DSN") if adminDSN == "" { t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") } ctx := context.Background() pool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("pool: %v", err) } if _, err := pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS audit_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''), actor TEXT NOT NULL CHECK (actor <> ''), action TEXT NOT NULL CHECK (action <> ''), target TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::jsonb )`); err != nil { t.Fatalf("schema: %v", err) } cleanup := func() { pool.Close() } return NewLog(pool), cleanup } // Akzeptanzkriterium 1 + Pruefung 2: Filterkombinationen liefern korrekte // Teilmengen. func TestExport_FilterCombinations(t *testing.T) { log, cleanup := setupExportTest(t) defer cleanup() ctx := context.Background() run := fmt.Sprintf("_%d", time.Now().UnixNano()) tenantA, tenantB := "test_fa"+run, "test_fb"+run alice, bob := "alice"+run, "bob"+run events := []Event{ {TenantSlug: tenantA, Actor: alice, Action: "login", Target: "x"}, {TenantSlug: tenantA, Actor: bob, Action: "login", Target: "x"}, {TenantSlug: tenantA, Actor: alice, Action: "logout", Target: "x"}, {TenantSlug: tenantB, Actor: alice, Action: "login", Target: "x"}, } for _, e := range events { if err := log.Record(ctx, e); err != nil { t.Fatalf("record: %v", err) } } cases := []struct { name string filter Filter wantLen int }{ {"nach tenant", Filter{TenantSlug: tenantA}, 3}, {"nach tenant+actor", Filter{TenantSlug: tenantA, Actor: alice}, 2}, {"nach tenant+actor+action", Filter{TenantSlug: tenantA, Actor: alice, Action: "login"}, 1}, {"nach actor ueber beide tenants", Filter{Actor: alice, Action: "login"}, 2}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { var buf bytes.Buffer if err := log.StreamCSV(ctx, c.filter, &buf); err != nil { t.Fatalf("stream: %v", err) } rows, err := csv.NewReader(&buf).ReadAll() if err != nil { t.Fatalf("csv parsen: %v", err) } got := len(rows) - 1 // Header abziehen if got != c.wantLen { t.Fatalf("erwartet %d zeilen, habe %d", c.wantLen, got) } }) } } // Akzeptanzkriterium 1 + Pruefung 2: Zeitraum-Filter. func TestExport_TimeRangeFilter(t *testing.T) { log, cleanup := setupExportTest(t) defer cleanup() ctx := context.Background() tenant := fmt.Sprintf("test_tr_%d", time.Now().UnixNano()) past := time.Now().Add(-48 * time.Hour) future := time.Now().Add(48 * time.Hour) if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "a", Action: "x", Target: "t", OccurredAt: time.Now()}); err != nil { t.Fatalf("record: %v", err) } var buf bytes.Buffer if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &past, To: &future}, &buf); err != nil { t.Fatalf("stream (innerhalb range): %v", err) } if got := countLines(buf.String()) - 1; got != 1 { t.Fatalf("erwartet 1 eintrag innerhalb des zeitraums, habe %d", got) } farPast := time.Now().Add(-96 * time.Hour) buf.Reset() if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &farPast, To: &past}, &buf); err != nil { t.Fatalf("stream (ausserhalb range): %v", err) } if got := countLines(buf.String()) - 1; got != 0 { t.Fatalf("erwartet 0 eintraege ausserhalb des zeitraums, habe %d", got) } } func countLines(s string) int { s = strings.TrimRight(s, "\n") if s == "" { return 0 } return len(strings.Split(s, "\n")) } // Akzeptanzkriterium 3 + Pruefung 1: Export mit hoher Eintragszahl ohne // uebermaessigen Speicherverbrauch — Stichprobe per runtime.MemStats. func TestExport_StreamsLargeResultWithoutExcessiveMemory(t *testing.T) { log, cleanup := setupExportTest(t) defer cleanup() ctx := context.Background() tenant := fmt.Sprintf("test_large_%d", time.Now().UnixNano()) const n = 20000 for i := 0; i < n; i++ { if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "bulk", Action: "test.bulk", Target: fmt.Sprintf("obj-%d", i)}); err != nil { t.Fatalf("record %d: %v", i, err) } } runtime.GC() var before runtime.MemStats runtime.ReadMemStats(&before) lineCount := 0 cw := &countingWriter{onWrite: func(p []byte) { lineCount += strings.Count(string(p), "\n") }} if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant}, cw); err != nil { t.Fatalf("stream: %v", err) } runtime.GC() var after runtime.MemStats runtime.ReadMemStats(&after) if lineCount != n+1 { // +1 Header t.Fatalf("erwartet %d zeilen (inkl. header), habe %d", n+1, lineCount) } // Grobe Stichprobe: ein NICHT streamender Export haette hier locker // mehrere MB an einmal gehaltenen Zeilen/Strings erzeugt. Grosszuegige // Schwelle, da Go-Heap-Messungen naturgemaess rauschen. const maxAcceptableGrowth = 3 * 1024 * 1024 // 3 MB growth := int64(after.HeapAlloc) - int64(before.HeapAlloc) t.Logf("heap-wachstum waehrend export von %d zeilen: %d bytes (schwelle: %d)", n, growth, maxAcceptableGrowth) if growth > maxAcceptableGrowth { t.Fatalf("heap ist um %d bytes gewachsen, erwartet unter %d (hinweis auf vollstaendige pufferung statt streaming)", growth, maxAcceptableGrowth) } } type countingWriter struct { onWrite func(p []byte) } func (w *countingWriter) Write(p []byte) (int, error) { w.onWrite(p) return len(p), nil } // Akzeptanzkriterium 2 + Pruefung 3: Zugriff ohne passende Berechtigung wird abgewiesen. func TestExportHandler_RejectsWithoutAuthorization(t *testing.T) { log, cleanup := setupExportTest(t) defer cleanup() handler := NewExportHandler(log, func(ctx context.Context, caller string) bool { return caller == "berechtigte-person@example.com" }) req := httptest.NewRequest(http.MethodGet, "/audit/export?caller=unberechtigt@example.com", nil) rec := httptest.NewRecorder() handler.Export(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("unberechtigt: status = %d, want 403", rec.Code) } reqNoCaller := httptest.NewRequest(http.MethodGet, "/audit/export", nil) recNoCaller := httptest.NewRecorder() handler.Export(recNoCaller, reqNoCaller) if recNoCaller.Code != http.StatusForbidden { t.Fatalf("ohne caller: status = %d, want 403", recNoCaller.Code) } reqOK := httptest.NewRequest(http.MethodGet, "/audit/export?caller=berechtigte-person@example.com", nil) recOK := httptest.NewRecorder() handler.Export(recOK, reqOK) if recOK.Code != http.StatusOK { t.Fatalf("berechtigt: status = %d, want 200", recOK.Code) } }