package resync import ( "context" "fmt" "net/http" "net/http/httptest" "os" "sync" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/flag" "gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry" ) // fakeAudit steht fuer internal/audit.Log (AUD-01, nicht Abhaengigkeit // dieser Kachel) — zeichnet Aufrufe in Empfangsreihenfolge auf, damit // Vollstaendigkeit UND Reihenfolge geprueft werden koennen. type fakeAudit struct { mu sync.Mutex events []auditEventDTO failAt int // -1 = nie fehlschlagen; sonst: ab diesem Index (0-basiert) schlaegt Record fehl } func (f *fakeAudit) Record(ctx context.Context, tenantSlug, actor, action, target string, metadata map[string]any, occurredAt time.Time) error { f.mu.Lock() defer f.mu.Unlock() if f.failAt >= 0 && len(f.events) == f.failAt { return fmt.Errorf("simulierter core-ausfall waehrend der nachlieferung") } f.events = append(f.events, auditEventDTO{TenantSlug: tenantSlug, Actor: actor, Action: action, Target: target, Metadata: metadata, OccurredAt: occurredAt}) return nil } // fakeUsage steht fuer internal/usage.Store (LIC-03, nicht Abhaengigkeit // dieser Kachel) — summiert Deltas wie der echte Store. type fakeUsage struct { mu sync.Mutex totals map[string]int64 } func newFakeUsage() *fakeUsage { return &fakeUsage{totals: map[string]int64{}} } func (f *fakeUsage) Increment(ctx context.Context, tenantSlug, metric string, delta int64) error { f.mu.Lock() defer f.mu.Unlock() f.totals[tenantSlug+"|"+metric] += delta return nil } func setupTest(t *testing.T) (*Buffer, *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 resync_audit_buffer ( id BIGSERIAL PRIMARY KEY, seq BIGSERIAL, tenant_slug TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS resync_usage_buffer ( id BIGSERIAL PRIMARY KEY, seq BIGSERIAL, tenant_slug TEXT NOT NULL, metric TEXT NOT NULL, delta BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS feature_flags ( key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false, rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}', updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS modules ( name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''), required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS module_credentials ( module_name TEXT PRIMARY KEY REFERENCES modules(name), client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL, issued_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("schema: %v", err) } cleanup := func() { pool.Close() } return NewBuffer(pool), pool, cleanup } func uniqueModuleName() string { return fmt.Sprintf("resync-test-%d", time.Now().UnixNano()) } // setupModuleCredential registriert ein echtes Modul + Service-Credential // ueber internal/moduleregistry (API-02) — dieselbe Authentifizierung wird // vom Handler tatsaechlich geprueft, kein Mock. func setupModuleCredential(t *testing.T, pool *pgxpool.Pool) (registry *moduleregistry.Registry, clientID, secret string) { t.Helper() ctx := context.Background() flagService := flag.NewService(flag.NewStore(pool), 10*time.Millisecond) registry = moduleregistry.NewRegistry(pool, flagService) name := uniqueModuleName() if _, err := registry.Register(ctx, name, "1.0.0", nil); err != nil { t.Fatalf("modul registrieren: %v", err) } clientID, secret, err := registry.Provision(ctx, name) if err != nil { t.Fatalf("credential provisionieren: %v", err) } return registry, clientID, secret } // Akzeptanzkriterium 2 + Pruefung 1: waehrend eines simulierten Ausfalls // lokal gepufferte Audit-Events sind nach Wiederanlauf vollstaendig und in // korrekter Reihenfolge in Core's Audit-Log vorhanden. func TestFlushAll_DeliversBufferedAuditEventsCompleteAndInOrder(t *testing.T) { buffer, pool, cleanup := setupTest(t) defer cleanup() ctx := context.Background() registry, clientID, secret := setupModuleCredential(t, pool) tenantSlug := "acme" // Ereignisse "waehrend core down" lokal puffern. actions := []string{"login", "upload", "delete", "logout"} for _, action := range actions { if err := buffer.EnqueueAuditEvent(ctx, tenantSlug, "user-1", action, "res-1", nil); err != nil { t.Fatalf("enqueue %s: %v", action, err) } } audit := &fakeAudit{failAt: -1} usage := newFakeUsage() handler := NewHandler(registry, audit, usage) mux := http.NewServeMux() mux.HandleFunc("/internal/resync/audit", handler.AuditHandler) mux.HandleFunc("/internal/resync/usage", handler.UsageHandler) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) server := httptest.NewServer(mux) defer server.Close() client := NewCoreClient(server.URL, clientID, secret) worker := NewWorker(buffer, client) if err := worker.FlushAll(ctx); err != nil { t.Fatalf("flushall: %v", err) } audit.mu.Lock() defer audit.mu.Unlock() if len(audit.events) != len(actions) { t.Fatalf("erwartet %d zugestellte events, habe %d", len(actions), len(audit.events)) } for i, e := range audit.events { if e.Action != actions[i] { t.Fatalf("reihenfolge falsch: position %d = %q, want %q", i, e.Action, actions[i]) } } // Puffer muss nach bestaetigter Zustellung leer sein. remaining, err := buffer.PendingAuditEvents(ctx) if err != nil { t.Fatalf("pending: %v", err) } if len(remaining) != 0 { t.Fatalf("erwartet leeren puffer nach bestaetigter zustellung, habe %d verbleibende", len(remaining)) } } // Bekannter-Fehler-Praevention: bricht die Zustellung waehrend der // Nachlieferung erneut ab (Core faellt wieder aus), bleiben die NICHT // bestaetigten Events sicher im Puffer erhalten statt verloren zu gehen. func TestFlushAll_KeepsUnconfirmedEventsInBufferOnPartialFailure(t *testing.T) { buffer, pool, cleanup := setupTest(t) defer cleanup() ctx := context.Background() registry, clientID, secret := setupModuleCredential(t, pool) for i := 0; i < 5; i++ { if err := buffer.EnqueueAuditEvent(ctx, "acme", "user-1", fmt.Sprintf("action-%d", i), "", nil); err != nil { t.Fatalf("enqueue %d: %v", i, err) } } // Core-Fake schlaegt AB dem 3. Event fehl -> simuliert erneuten Ausfall // mitten in der Nachlieferung. audit := &fakeAudit{failAt: 3} handler := NewHandler(registry, audit, newFakeUsage()) mux := http.NewServeMux() mux.HandleFunc("/internal/resync/audit", handler.AuditHandler) mux.HandleFunc("/internal/resync/usage", handler.UsageHandler) server := httptest.NewServer(mux) defer server.Close() client := NewCoreClient(server.URL, clientID, secret) worker := NewWorker(buffer, client) if err := worker.FlushAll(ctx); err == nil { t.Fatal("erwartet fehler, da core nur teilweise bestaetigt hat") } remaining, err := buffer.PendingAuditEvents(ctx) if err != nil { t.Fatalf("pending: %v", err) } if len(remaining) != 2 { t.Fatalf("erwartet 2 verbleibende (nicht bestaetigte) events im puffer, habe %d", len(remaining)) } } // Akzeptanzkriterium 3 + Pruefung 2: Nutzungszaehler-Differenz aus der // Ausfallzeit wird korrekt nachgebucht, ein wiederholter (fehlerhafter) // Flush-Versuch fuehrt NICHT zu Doppelzaehlung, weil bereits bestaetigte // Deltas aus dem Puffer entfernt sind. func TestFlushAll_AppliesUsageDeltasWithoutDoubleCounting(t *testing.T) { buffer, pool, cleanup := setupTest(t) defer cleanup() ctx := context.Background() registry, clientID, secret := setupModuleCredential(t, pool) deltas := []int64{3, 5, 2} var want int64 for _, d := range deltas { want += d if err := buffer.EnqueueUsageDelta(ctx, "acme", "api_calls", d); err != nil { t.Fatalf("enqueue delta %d: %v", d, err) } } usage := newFakeUsage() handler := NewHandler(registry, &fakeAudit{failAt: -1}, usage) mux := http.NewServeMux() mux.HandleFunc("/internal/resync/audit", handler.AuditHandler) mux.HandleFunc("/internal/resync/usage", handler.UsageHandler) server := httptest.NewServer(mux) defer server.Close() client := NewCoreClient(server.URL, clientID, secret) worker := NewWorker(buffer, client) if err := worker.FlushAll(ctx); err != nil { t.Fatalf("flushall 1: %v", err) } usage.mu.Lock() got := usage.totals["acme|api_calls"] usage.mu.Unlock() if got != want { t.Fatalf("nutzungsstand nach nachbuchung = %d, want %d", got, want) } // Ein zweiter Flush-Versuch (z.B. redundanter Retry) darf NICHTS mehr // nachbuchen, da der Puffer bereits geleert wurde. if err := worker.FlushAll(ctx); err != nil { t.Fatalf("flushall 2: %v", err) } usage.mu.Lock() got2 := usage.totals["acme|api_calls"] usage.mu.Unlock() if got2 != want { t.Fatalf("nutzungsstand nach redundantem zweiten flush = %d, want unveraendert %d (keine doppelzaehlung)", got2, want) } } // Akzeptanzkriterium 1 + Pruefung 3: bei erkannter Core-Wiedererreichbarkeit // wird der Cache SOFORT invalidiert (naechster Zugriff refetcht), nicht // erst nach TTL-Ablauf — Latenz wird gemessen und liegt weit unter einer // langen TTL. func TestCheckAndSync_InvalidatesCacheImmediatelyOnRecovery(t *testing.T) { buffer, pool, cleanup := setupTest(t) defer cleanup() registry, clientID, secret := setupModuleCredential(t, pool) _ = registry mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) server := httptest.NewServer(mux) defer server.Close() client := NewCoreClient(server.URL, clientID, secret) worker := NewWorker(buffer, client) invalidated := false var mu sync.Mutex worker.OnReachable(func() { mu.Lock() invalidated = true mu.Unlock() }) start := time.Now() becameReachable, err := worker.CheckAndSync(context.Background()) elapsed := time.Since(start) if err != nil { t.Fatalf("checkandsync: %v", err) } if !becameReachable { t.Fatal("erwartet erkannten uebergang zu 'erreichbar' beim ersten erfolgreichen check") } mu.Lock() defer mu.Unlock() if !invalidated { t.Fatal("erwartet sofortigen cache-invalidierungs-callback bei core-wiedererreichbarkeit") } // Zielwert: deutlich unter einer typischen TTL (z.B. 5s beim // Feature-Flag-Cache, LIC-02) — hier im Millisekundenbereich, da rein // lokal ohne Netzwerk-Overhead. if elapsed > time.Second { t.Fatalf("cache-invalidierung brauchte %s, erwartet deutlich unter 1s", elapsed) } }