Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Voller Merge der 15 verbleibenden Vorbedingungen (QA-07/QA-08/TEN-08/AUD-02/ API-04/OPS-03/API-06/AUD-05/API-07/LIC-05/OPS-04/OPS-05/OPS-06/IAM-15 plus QA-03) auf den bereits gemergten Staenden von QA-02/QA-04/QA-09. Konsolidiert alle sechs vorgelagerten Pruefgates (QA-02/03/04/07/08/09) - widerspruchsfrei, Akzeptanzkriterium 1 erfuellt. Echter, substanzieller Befund beim Audit-Log-Stichprobenabgleich (Pruefung 1): internal/policy.Store.Grant/Revoke, Tenant-Lifecycle-Uebergaenge, Lockout und KEK-Rotation rufen internal/audit.Log.Record nirgends auf - der zentrale, unveraenderliche Audit-Log (AUD-01/02) existiert und ist getestet, wird aber von keinem Produktions-Handler tatsaechlich befuellt. Bewusst NICHT in dieser Kachel behoben (waere Umbau vieler bestehender Pakete, kein punktueller Fix) - dokumentiert mit Begruendung und Auflage vor QA-06. Pruefung 2 (Vier-Augen-Gegenlesen) mangels zweiter Person nicht durchgefuehrt, ebenfalls als Auflage vermerkt. Siehe docs/QA-05-ABNAHME-COMPLIANCE-PRUEFUNG.md. Zwei reale Testinfrastruktur-Fehler gefunden und behoben (kein Produktions- code): fehlender PG-Fehlercode 42723 (duplicate_function, AUD-02s CREATE FUNCTION bei zweiter Migrationsanwendung) in der Toleranzliste der E2E-/Pentest-Testhelfer; internal/loadtest wiederholte die aus QA-04 bekannte defer-vor-t.Cleanup-Reihenfolge-Fehlerklasse (200 liegen gebliebene synthetische Tenant-Zeilen verfaelschten internal/migrate). 51/51 Pakete gruen auf 192.168.1.131. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
263 lines
9.2 KiB
Go
263 lines
9.2 KiB
Go
// 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)
|
|
}
|
|
// Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen
|
|
// erst NACH allen defer-Aufrufen der Testfunktion, daher muss diese
|
|
// Registrierung vor der Loesch-Cleanup unten stehen, damit der Pool
|
|
// beim Aufraeumen noch offen ist (dieselbe Fehlerklasse wie in QA-04,
|
|
// siehe internal/e2e/*_test.go und internal/kek/kek_test.go).
|
|
t.Cleanup(func() { 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)
|
|
}
|
|
}
|