diff --git a/internal/loadtest/loadtest_test.go b/internal/loadtest/loadtest_test.go new file mode 100644 index 0000000..289d53c --- /dev/null +++ b/internal/loadtest/loadtest_test.go @@ -0,0 +1,257 @@ +// Package loadtest implementiert Core QA-08: Last- und Leistungstests fuer +// die drei Mechanismen, die unter realistischer Mehrmodul-Last (sechs +// gleichzeitig zugreifende Fachmodule: DMS/Mail/Archive/Workflow/AI/Connect) +// am ehesten unter Druck geraten — JWT-Verifikationspfad (API-05), +// Tenant-Connection-Pooling (TEN-06) und Rate-Limiting (API-03). Jeder Test +// dokumentiert einen Zielwert UND das tatsaechlich gemessene Ergebnis +// (Ticket-Vorgabe: "Pruefen heisst messen. Behauptungen ohne Messprotokoll +// gelten nicht als erledigt."). +package loadtest + +import ( + "context" + "crypto/ed25519" + "fmt" + "os" + "sort" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/moduletrust" + "gitea.perlbach24.de/scripte/nexarch/internal/ratelimit" + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" +) + +const simulatedModuleCount = 6 // DMS, Mail, Archive, Workflow, AI, Connect + +// Akzeptanzkriterium 1 + Pruefung 1: definierte Ziel-Latenz fuer +// JWT-Verifikation unter Last aus sechs gleichzeitig zugreifenden Modulen. +// +// Zielwert: p95 < 10ms je Verify()-Aufruf. Begruendung des Zielwerts: +// Verify() ist eine rein lokale Operation gegen einen bereits im Speicher +// zwischengespeicherten Schluesselsatz (StaleCache, siehe API-05) — es +// findet kein Netzwerk-Roundtrip zu Core statt, daher ist ein niedriger +// Millisekunden-Zielwert realistisch, nicht willkuerlich hoch angesetzt. +func TestLoad_JWTVerificationLatencyUnderSixModuleLoad(t *testing.T) { + km, err := moduletrust.NewKeyManager() + if err != nil { + t.Fatalf("keymanager: %v", err) + } + if _, err := km.Rotate(); err != nil { + t.Fatalf("rotate: %v", err) + } + + verifier := moduletrust.NewVerifier(time.Minute, func(ctx context.Context) (map[string]ed25519.PublicKey, error) { + return km.PublicKeySet(), nil + }) + + const requestsPerModule = 200 + totalRequests := simulatedModuleCount * requestsPerModule + + tokens := make([]string, totalRequests) + for i := 0; i < totalRequests; i++ { + tok, err := km.Issue("user-1", fmt.Sprintf("tenant-%d", i%50), 5*time.Minute) + if err != nil { + t.Fatalf("issue: %v", err) + } + tokens[i] = tok + } + + latencies := make([]time.Duration, totalRequests) + var wg sync.WaitGroup + for module := 0; module < simulatedModuleCount; module++ { + wg.Add(1) + go func(moduleIdx int) { + defer wg.Done() + for i := 0; i < requestsPerModule; i++ { + idx := moduleIdx*requestsPerModule + i + start := time.Now() + if _, err := verifier.Verify(context.Background(), tokens[idx]); err != nil { + t.Errorf("verify: %v", err) + return + } + latencies[idx] = time.Since(start) + } + }(module) + } + wg.Wait() + + sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] }) + p95 := latencies[int(float64(len(latencies))*0.95)] + max := latencies[len(latencies)-1] + + t.Logf("JWT-Verifikation unter Last: %d module x %d anfragen = %d gesamt. p95=%s, max=%s, ziel=p95<10ms", + simulatedModuleCount, requestsPerModule, totalRequests, p95, max) + + if p95 >= 10*time.Millisecond { + t.Fatalf("p95-latenz = %s, ziel war unter 10ms", p95) + } +} + +// Akzeptanzkriterium 2 + Pruefung 2: Connection-Pooling bleibt unter der +// Postgres-Verbindungsobergrenze bei simulierter Vielzahl an Mandanten. +// +// Zielwert: bei 200 simulierten Mandanten, auf die sechs Module gleichzeitig +// zugreifen, bleiben NIE mehr als maxOpen=20 Pools gleichzeitig offen (LRU- +// Verdraengung greift) — weit unter einer typischen Postgres +// max_connections-Grenze (Default 100). +func TestLoad_ConnectionPoolingStaysUnderLimitWithManySimulatedTenants(t *testing.T) { + 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) + } + defer pool.Close() + + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + registry := tenant.NewRegistry(pool) + const simulatedTenants = 200 + const maxOpen = 20 + + prefix := fmt.Sprintf("qa08_%d", time.Now().UnixNano()) + slugs := make([]string, simulatedTenants) + for i := 0; i < simulatedTenants; i++ { + slug := fmt.Sprintf("%s_%d", prefix, i) + slugs[i] = slug + // DSN muss nur SYNTAKTISCH gueltig sein — pgxpool.New verbindet + // erst lazy bei tatsaechlicher Nutzung, fuer diesen Lasttest zaehlt + // ausschliesslich die Zahl offener *Pool-Objekte*, nicht ob die + // referenzierte Datenbank real existiert. + dsn := fmt.Sprintf("postgresql://nexarch_test:unused@localhost:5432/%s?sslmode=disable", slug) + if _, err := pool.Exec(ctx, ` + INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, $2) + `, slug, dsn); err != nil { + t.Fatalf("tenant %s anlegen: %v", slug, err) + } + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE slug LIKE $1`, prefix+"%") + }) + + router := tenant.NewRouter(registry, maxOpen) + defer router.Close() + + var maxObservedOpen int64 + var wg sync.WaitGroup + for module := 0; module < simulatedModuleCount; module++ { + wg.Add(1) + go func(moduleIdx int) { + defer wg.Done() + for i := 0; i < simulatedTenants; i++ { + slug := slugs[(i+moduleIdx*37)%simulatedTenants] // module-uebergreifend gemischter zugriff + if _, err := router.Resolve(context.Background(), slug); err != nil { + t.Errorf("resolve %s: %v", slug, err) + return + } + if current := int64(router.OpenCount()); current > atomic.LoadInt64(&maxObservedOpen) { + atomic.StoreInt64(&maxObservedOpen, current) + } + } + }(module) + } + wg.Wait() + + t.Logf("connection-pooling unter last: %d simulierte mandanten, %d module, max. gleichzeitig beobachtete offene pools = %d (ziel: <= %d)", + simulatedTenants, simulatedModuleCount, maxObservedOpen, maxOpen) + + if maxObservedOpen > int64(maxOpen) { + t.Fatalf("max. beobachtete offene pools = %d, ziel war <= %d", maxObservedOpen, maxOpen) + } + if router.OpenCount() > maxOpen { + t.Fatalf("finale offene pools = %d, ziel war <= %d", router.OpenCount(), maxOpen) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: Rate-Limiting funktioniert korrekt +// unter Mehrinstanz-Last (mehrere "Core-Instanzen" mit geteiltem Postgres- +// Zustand) aus sechs gleichzeitig zugreifenden Modulen, nicht nur im +// Einzelinstanz-Test (der bereits in API-03 abgedeckt ist). +// +// Zielwert: bei konfiguriertem Limit=500 und insgesamt 1200 Anfragen ueber +// DREI simulierte Core-Instanzen (separate Pools/Store-Objekte) hinweg +// werden EXAKT 500 Anfragen erlaubt — kein Overcounting durch fehlende +// Koordination zwischen den Instanzen, kein Undercounting durch verlorene +// Updates. +func TestLoad_RateLimitingCorrectAcrossMultipleInstancesUnderSixModuleLoad(t *testing.T) { + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + const instanceCount = 3 + pools := make([]*pgxpool.Pool, instanceCount) + stores := make([]*ratelimit.Store, instanceCount) + for i := 0; i < instanceCount; i++ { + p, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("pool %d: %v", i, err) + } + defer p.Close() + if i == 0 { + if _, err := p.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS rate_limit_configs ( + key TEXT PRIMARY KEY, limit_value INT NOT NULL, window_seconds INT NOT NULL + ); + CREATE TABLE IF NOT EXISTS rate_limit_counters ( + key TEXT NOT NULL, window_start TIMESTAMPTZ NOT NULL, count INT NOT NULL DEFAULT 0, + PRIMARY KEY (key, window_start) + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + } + pools[i] = p + stores[i] = ratelimit.NewStore(p) + } + + key := fmt.Sprintf("qa08-key-%d", time.Now().UnixNano()) + const limit = 500 + if err := stores[0].SetLimit(ctx, key, limit, time.Minute); err != nil { + t.Fatalf("setlimit: %v", err) + } + + const totalRequests = 1200 // simulierte last aus sechs modulen ueber drei instanzen, deutlich ueber dem limit von 500 + var allowedCount int64 + var wg sync.WaitGroup + for i := 0; i < totalRequests; i++ { + wg.Add(1) + instance := stores[i%instanceCount] // request "kommt" reihum von einer der drei core-instanzen + go func(s *ratelimit.Store) { + defer wg.Done() + res, err := s.Allow(ctx, key) + if err != nil { + t.Errorf("allow: %v", err) + return + } + if res.Allowed { + atomic.AddInt64(&allowedCount, 1) + } + }(instance) + } + wg.Wait() + + t.Logf("rate-limiting unter mehrinstanz-last: %d anfragen ueber %d instanzen, limit=%d, tatsaechlich erlaubt=%d", + totalRequests, instanceCount, limit, allowedCount) + + if allowedCount != limit { + t.Fatalf("erlaubte anfragen ueber alle instanzen = %d, ziel war exakt %d (geteilter, korrekter zustand)", allowedCount, limit) + } +}