package audit import ( "context" "errors" "fmt" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) func setupAuditTest(t *testing.T) (*Log, *pgxpool.Pool, 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.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug LIKE 'test\_%' ESCAPE '\' OR tenant_slug = $1`, SystemTenant) pool.Close() } return NewLog(pool), pool, cleanup } // Akzeptanzkriterium 1 + Pruefung 1: ein sicherheitsrelevanter Vorgang // (hier: fehlgeschlagener Login) erzeugt zuverlaessig genau einen Eintrag. func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) { log, pool, cleanup := setupAuditTest(t) defer cleanup() ctx := context.Background() // Seit AUD-02 ist audit_events append-only — Zeilen koennen nie wieder // geloescht werden (auch nicht vom Test-Cleanup). Eindeutiger Slug pro // Lauf, damit wiederholte Testlaeufe die Zaehlung nicht verfaelschen. tenantSlug := "test_acme_" + fmt.Sprint(time.Now().UnixNano()) err := log.Record(ctx, Event{ TenantSlug: tenantSlug, Actor: "alice@example.com", Action: "iam.login_failed", Target: "user:alice@example.com", Metadata: map[string]any{"reason": "falsches passwort"}, }) if err != nil { t.Fatalf("record: %v", err) } count, err := log.CountByTenant(ctx, tenantSlug) if err != nil { t.Fatalf("count: %v", err) } if count != 1 { t.Fatalf("erwartet genau 1 audit-eintrag, habe %d", count) } var actor, action, target string if err := pool.QueryRow(ctx, ` SELECT actor, action, target FROM audit_events WHERE tenant_slug = $1 `, tenantSlug).Scan(&actor, &action, &target); err != nil { t.Fatalf("eintrag lesen: %v", err) } if actor != "alice@example.com" || action != "iam.login_failed" || target != "user:alice@example.com" { t.Fatalf("eintrag unerwartet: actor=%q action=%q target=%q", actor, action, target) } } // Akzeptanzkriterium 2 + Pruefung 2 (App-Ebene): fehlender Tenant-Bezug wird // bereits vom zentralen Schreibpfad abgewiesen. func TestRecord_RejectsMissingTenant(t *testing.T) { log, _, cleanup := setupAuditTest(t) defer cleanup() ctx := context.Background() err := log.Record(ctx, Event{TenantSlug: "", Actor: "alice", Action: "irgendwas"}) if !errors.Is(err, ErrMissingTenant) { t.Fatalf("erwartet ErrMissingTenant, habe %v", err) } } // Akzeptanzkriterium 2 + Pruefung 2 (DB-Ebene): selbst ein direkter INSERT, // der Log.Record umgeht, wird durch die CHECK-Constraint verhindert — der // Schutz haengt nicht allein von der Go-Validierung ab. func TestConstraint_RejectsMissingTenantAtDatabaseLevel(t *testing.T) { _, pool, cleanup := setupAuditTest(t) defer cleanup() ctx := context.Background() _, err := pool.Exec(ctx, ` INSERT INTO audit_events (tenant_slug, actor, action, target) VALUES ('', 'alice', 'irgendwas', 'ziel') `) if err == nil { t.Fatal("erwartet fehler durch CHECK-constraint bei leerem tenant_slug, habe nil") } } func TestRecord_RejectsMissingActorAndAction(t *testing.T) { log, _, cleanup := setupAuditTest(t) defer cleanup() ctx := context.Background() if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "", Action: "x"}); !errors.Is(err, ErrMissingActor) { t.Fatalf("erwartet ErrMissingActor, habe %v", err) } if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "alice", Action: ""}); !errors.Is(err, ErrMissingAction) { t.Fatalf("erwartet ErrMissingAction, habe %v", err) } } func TestRecord_SystemTenantForCrossTenantEvents(t *testing.T) { log, _, cleanup := setupAuditTest(t) defer cleanup() ctx := context.Background() if err := log.Record(ctx, Event{TenantSlug: SystemTenant, Actor: "superadmin", Action: "tenant.provisioned", Target: "tenant:acme"}); err != nil { t.Fatalf("record mit SystemTenant: %v", err) } }