// wiring_test.go ist der Nachweis fuer AUD-06: fuer jeden der vier // sicherheitsrelevanten Bereiche (Policy, Tenant-Lifecycle, Lockout, // KEK-Rotation) wird eine reale Aktion ausgefuehrt und per direkter Abfrage // der audit_events-Tabelle nachgewiesen, dass ein passender Eintrag // entstanden ist - derselbe Nachweisstil wie der QA-05-Stichprobenabgleich // (docs/QA-05-ABNAHME-COMPLIANCE-PRUEFUNG.md Abschnitt 2), der die Luecke // ursprünglich aufdeckte. package audit_test import ( "context" "crypto/rand" "errors" "fmt" "os" "path/filepath" "sort" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/audit" "gitea.perlbach24.de/scripte/nexarch/internal/kek" "gitea.perlbach24.de/scripte/nexarch/internal/lockout" "gitea.perlbach24.de/scripte/nexarch/internal/policy" "gitea.perlbach24.de/scripte/nexarch/internal/rbac" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" ) func requireAdminDSN(t *testing.T) string { t.Helper() dsn := os.Getenv("TEST_ADMIN_DSN") if dsn == "" { t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") } return dsn } func repoRoot(t *testing.T) string { t.Helper() wd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } return filepath.Join(wd, "..", "..") } func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) { t.Helper() entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err) } var names []string for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") { names = append(names, e.Name()) } } sort.Strings(names) for _, name := range names { sqlBytes, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { t.Fatalf("migration %q lesen: %v", name, err) } if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710" || pgErr.Code == "42723") { continue } t.Fatalf("migration %q anwenden: %v", name, err) } } } func countAuditEvents(t *testing.T, ctx context.Context, pool *pgxpool.Pool, action, target string) int { t.Helper() var n int if err := pool.QueryRow(ctx, ` SELECT count(*) FROM audit_events WHERE action = $1 AND target = $2 `, action, target).Scan(&n); err != nil { t.Fatalf("audit_events abfragen: %v", err) } return n } func setupWiringTest(t *testing.T) (*pgxpool.Pool, *audit.Log) { t.Helper() adminDSN := requireAdminDSN(t) ctx := context.Background() pool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(func() { pool.Close() }) root := repoRoot(t) applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations")) // login_attempts liegt in Produktion in der Tenant-Datenbank - fuer diesen // Verdrahtungsnachweis genuegt dieselbe physische Datenbank, da es nur um // den Audit-Log-Schreibpfad geht, nicht um Tenant-Isolation (die ist // bereits durch TEN-01/QA-02 nachgewiesen). applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations", "tenant")) return pool, audit.NewLog(pool) } // TestWiring_PolicyGrantRevokeAreAudited ist der Nachweis fuer // Akzeptanzkriterium 1: internal/policy.Store.Grant/Revoke schreiben einen // Audit-Log-Eintrag. func TestWiring_PolicyGrantRevokeAreAudited(t *testing.T) { pool, log := setupWiringTest(t) ctx := context.Background() store := policy.NewStore(pool).WithAudit(log) target := "user:self.read" if err := store.Grant(ctx, rbac.RoleUser, rbac.PermSelfRead, "aud06-actor"); err != nil { t.Fatalf("grant: %v", err) } if n := countAuditEvents(t, ctx, pool, "policy.grant", target); n != 1 { t.Fatalf("erwartet 1 audit-eintrag fuer policy.grant %q, habe %d", target, n) } if err := store.Revoke(ctx, rbac.RoleUser, rbac.PermSelfRead, "aud06-actor"); err != nil { t.Fatalf("revoke: %v", err) } if n := countAuditEvents(t, ctx, pool, "policy.revoke", target); n != 1 { t.Fatalf("erwartet 1 audit-eintrag fuer policy.revoke %q, habe %d", target, n) } } // TestWiring_TenantLifecycleTransitionIsAudited ist der Nachweis fuer // Akzeptanzkriterium 2: internal/tenant.Registry-Uebergaenge schreiben einen // Audit-Log-Eintrag. func TestWiring_TenantLifecycleTransitionIsAudited(t *testing.T) { pool, log := setupWiringTest(t) ctx := context.Background() registry := tenant.NewRegistry(pool).WithAudit(log) dsnTemplate := strings.Replace(requireAdminDSN(t), "/postgres?", "/%s?", 1) provisioner := tenant.NewProvisioner(pool, tenant.NewRegistry(pool), dsnTemplate) slug := uniqueSlug("aud06_lifecycle") tn, err := provisioner.Provision(ctx, slug, "AUD-06 Lifecycle GmbH") if err != nil { t.Fatalf("provision: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug) _, _ = pool.Exec(ctx, `DROP DATABASE IF EXISTS "tenant_`+slug+`"`) }) _ = tn if _, err := registry.Suspend(ctx, slug); err != nil { t.Fatalf("suspend: %v", err) } if n := countAuditEvents(t, ctx, pool, "tenant.transition", "suspended"); n == 0 { t.Fatal("erwartet mindestens 1 audit-eintrag fuer tenant.transition -> suspended, habe 0") } } // TestWiring_LockoutFailuresAndUnlockAreAudited ist der Nachweis fuer // Akzeptanzkriterium 3 (Teil Lockout): internal/lockout.Store.RecordFailure // und Unlock schreiben je einen Audit-Log-Eintrag. func TestWiring_LockoutFailuresAndUnlockAreAudited(t *testing.T) { pool, log := setupWiringTest(t) ctx := context.Background() email := fmt.Sprintf("aud06-%d@example.test", time.Now().UnixNano()) store := lockout.NewStore(pool).WithPolicy(2, time.Minute).WithAudit(log, "aud06-tenant") t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM login_attempts WHERE email = $1`, email) }) if _, _, err := store.RecordFailure(ctx, email); err != nil { t.Fatalf("fehlversuch 1: %v", err) } if n := countAuditEvents(t, ctx, pool, "auth.login_failed", email); n != 1 { t.Fatalf("erwartet 1 audit-eintrag fuer auth.login_failed %q, habe %d", email, n) } locked, _, err := store.RecordFailure(ctx, email) if err != nil { t.Fatalf("fehlversuch 2 (sperrt): %v", err) } if !locked { t.Fatal("konto haette nach 2 fehlversuchen (policy maxFailed=2) gesperrt sein muessen") } if n := countAuditEvents(t, ctx, pool, "auth.account_locked", email); n != 1 { t.Fatalf("erwartet 1 audit-eintrag fuer auth.account_locked %q, habe %d", email, n) } if err := store.Unlock(ctx, email); err != nil { t.Fatalf("entsperren: %v", err) } if n := countAuditEvents(t, ctx, pool, "auth.account_unlocked", email); n != 1 { t.Fatalf("erwartet 1 audit-eintrag fuer auth.account_unlocked %q, habe %d", email, n) } } // TestWiring_KEKRotationIsAudited ist der Nachweis fuer Akzeptanzkriterium 3 // (Teil KEK): internal/kek.Store.RotateTenantKEK/RotateMasterKey schreiben // je einen Audit-Log-Eintrag. func TestWiring_KEKRotationIsAudited(t *testing.T) { pool, log := setupWiringTest(t) ctx := context.Background() store := kek.NewStore(pool).WithAudit(log) var tenantID string slug := uniqueSlug("aud06_kek") if err := pool.QueryRow(ctx, ` INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id `, slug).Scan(&tenantID); err != nil { t.Fatalf("tenant-zeile anlegen: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM tenant_keks WHERE tenant_id = $1`, tenantID) _, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID) }) masterKey := randomMasterKey(t) if _, err := store.CreateForTenant(ctx, tenantID, masterKey); err != nil { t.Fatalf("tenant-kek anlegen: %v", err) } if _, err := store.RotateTenantKEK(ctx, tenantID, masterKey); err != nil { t.Fatalf("tenant-kek rotieren: %v", err) } if n := countAuditEvents(t, ctx, pool, "kek.tenant_rotated", tenantID); n != 1 { t.Fatalf("erwartet 1 audit-eintrag fuer kek.tenant_rotated %q, habe %d", tenantID, n) } newMasterKey := randomMasterKey(t) if _, _, err := store.RotateMasterKey(ctx, masterKey, newMasterKey); err != nil { t.Fatalf("master-key rotieren: %v", err) } if n := countAuditEvents(t, ctx, pool, "kek.master_rotated", audit.SystemTenant); n != 1 { t.Fatalf("erwartet 1 audit-eintrag fuer kek.master_rotated, habe %d", n) } } func randomMasterKey(t *testing.T) kek.MasterKey { t.Helper() buf := make([]byte, kek.MasterKeySize) if _, err := rand.Read(buf); err != nil { t.Fatalf("master-key erzeugen: %v", err) } return kek.MasterKey(buf) } func uniqueSlug(prefix string) string { return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) }