// 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) } }