internal/audit/export.go: StreamCSV/StreamJSON filtern nach Tenant, Akteur, Aktion und Zeitraum (Akzeptanzkriterium 1) und schreiben Zeile fuer Zeile ueber rows.Next() DIREKT auf den uebergebenen io.Writer — zu keinem Zeitpunkt wird das komplette Ergebnis im Speicher aufgebaut (Akzeptanz- kriterium 3). JSON-Export als JSON Lines statt einem grossen Array, um Streaming ohne Sonderbehandlung von Klammern/Kommas zu ermoeglichen. ExportHandler (Akzeptanzkriterium 2) schreibt direkt auf http.ResponseWriter — derselbe Streaming-Pfad wie in Tests, kein Zwischenpuffer nur fuer HTTP. Authorize ist eine schmale Schnittstelle (Vorbild: AUD-05 RetentionRegistrar- Muster), da die eigentliche Rollenpruefung RBAC-02 (Policy-Enforcement) ist und nicht Teil dieser Kachel — der Handler kennt nur "darf dieser Aufrufer exportieren", nicht wie das entschieden wird. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Export mit hoher Eintragszahl ohne uebermaessigen Speicherverbrauch — TestExport_StreamsLargeResultWithoutExcessiveMemory: 20.000 Eintraege, Heap-Wachstum waehrend Export nur ~1.8KB (Schwelle 3MB). PASS. 2. Filterkombinationen automatisiert gegen erwartete Ergebnismengen — TestExport_FilterCombinations (Tenant/Actor/Action einzeln und kombiniert) und TestExport_TimeRangeFilter (innerhalb/ausserhalb Zeitraum). PASS. 3. Zugriff ohne passende Berechtigung abgewiesen — TestExportHandler_RejectsWithoutAuthorization: fehlender/falscher caller -> 403, berechtigter caller -> 200. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
223 lines
6.7 KiB
Go
223 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|