249 lines
7.8 KiB
Go
249 lines
7.8 KiB
Go
package audit
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
|
|
)
|
|
|
|
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 := testdb.Pool(t, adminDSN)
|
|
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. QA-15 F-09: das Token geht seit dieser Kachel per Header
|
|
// (ExportTokenHeader), nicht mehr per "caller"-Query-Parameter.
|
|
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", nil)
|
|
req.Header.Set(ExportTokenHeader, "unberechtigt@example.com")
|
|
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 token: status = %d, want 403", recNoCaller.Code)
|
|
}
|
|
|
|
reqOK := httptest.NewRequest(http.MethodGet, "/audit/export", nil)
|
|
reqOK.Header.Set(ExportTokenHeader, "berechtigte-person@example.com")
|
|
recOK := httptest.NewRecorder()
|
|
handler.Export(recOK, reqOK)
|
|
if recOK.Code != http.StatusOK {
|
|
t.Fatalf("berechtigt: status = %d, want 200", recOK.Code)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: kein Token mehr im Query akzeptiert.
|
|
func TestExportHandler_RejectsTokenInQuery(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"
|
|
})
|
|
|
|
for _, param := range []string{"caller", "token"} {
|
|
t.Run(param, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/audit/export?"+param+"=berechtigte-person@example.com", nil)
|
|
// Auch mit einem GUELTIGEN Header muss die Query-Variante
|
|
// abgewiesen werden (Akzeptanzkriterium 3: nicht still
|
|
// akzeptieren, nur weil zusaetzlich ein korrekter Header dabei ist).
|
|
req.Header.Set(ExportTokenHeader, "berechtigte-person@example.com")
|
|
rec := httptest.NewRecorder()
|
|
handler.Export(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("token im query (%s): status = %d, want 401", param, rec.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|