package kek import ( "bytes" "context" "encoding/base64" "fmt" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/flag" "gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" ) func setupTest(t *testing.T) (*Store, *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 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() ); CREATE TABLE IF NOT EXISTS tenant_keks ( tenant_id UUID PRIMARY KEY REFERENCES tenants(id), wrapped_kek BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ ); 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) } // Als t.Cleanup statt nur per defer im Aufrufer zurueckgegeben: falls ein // Aufrufer "defer cleanup()" nutzt, liefe das Schliessen VOR den // t.Cleanup-Aufraeumfunktionen von createTenant (die den Pool noch // brauchen), da t.Cleanup grundsaetzlich erst nach allen defers der // Testfunktion ausgefuehrt wird. Registrierung hier stellt sicher, dass // das Schliessen selbst als letzte Cleanup-Funktion (LIFO) laeuft. t.Cleanup(func() { pool.Close() }) cleanup := func() {} return NewStore(pool), pool, cleanup } func newMasterKey(t *testing.T) MasterKey { t.Helper() key, err := generateRandomKey() if err != nil { t.Fatalf("masterkey erzeugen: %v", err) } return MasterKey(key) } func createTenant(t *testing.T, pool *pgxpool.Pool, slug string) string { t.Helper() var id string err := pool.QueryRow(context.Background(), ` INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id `, slug).Scan(&id) if err != nil { t.Fatalf("tenant anlegen: %v", err) } // Aufraeumen nach Testende - ohne dies bleiben Registry-Zeilen mit // db_dsn='unused' dauerhaft in der geteilten tenants-Tabelle stehen und // verfaelschen registry.List()-Ergebnisse in anderen Paketen (z.B. // internal/migrate), die im selben Testlauf gegen dieselbe physische // Postgres-Instanz laufen. t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM tenant_keks WHERE tenant_id = $1`, id) _, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE id = $1`, id) }) return id } func uniqueSlug(prefix string) string { return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) } // Akzeptanzkriterium 1: LoadMasterKeyFromEnv liest ausschliesslich aus der // Umgebungsvariable, niemals aus Code/DB. func TestLoadMasterKeyFromEnv(t *testing.T) { const envVar = "NEXARCH_TEST_MASTER_KEY_API10" t.Cleanup(func() { os.Unsetenv(envVar) }) if _, err := LoadMasterKeyFromEnv(envVar); err == nil { t.Fatal("erwartet fehler, wenn umgebungsvariable nicht gesetzt ist") } os.Setenv(envVar, "zu-kurz") if _, err := LoadMasterKeyFromEnv(envVar); err == nil { t.Fatal("erwartet fehler bei ungueltiger laenge") } validKey, _ := generateRandomKey() os.Setenv(envVar, base64.StdEncoding.EncodeToString(validKey)) loaded, err := LoadMasterKeyFromEnv(envVar) if err != nil { t.Fatalf("laden mit gueltigem key: %v", err) } if !bytes.Equal(loaded, validKey) { t.Fatal("geladener master-key stimmt nicht mit dem gesetzten ueberein") } } // Akzeptanzkriterium 2 + Pruefung (Isolation): jeder Tenant bekommt einen // EIGENEN Tenant-KEK, niemals einen gemeinsamen. func TestCreateForTenant_EachTenantGetsDistinctKEK(t *testing.T) { store, pool, cleanup := setupTest(t) defer cleanup() ctx := context.Background() masterKey := newMasterKey(t) tenantA := createTenant(t, pool, uniqueSlug("acme")) tenantB := createTenant(t, pool, uniqueSlug("globex")) kekA, err := store.CreateForTenant(ctx, tenantA, masterKey) if err != nil { t.Fatalf("create a: %v", err) } kekB, err := store.CreateForTenant(ctx, tenantB, masterKey) if err != nil { t.Fatalf("create b: %v", err) } if bytes.Equal(kekA, kekB) { t.Fatal("erwartet unterschiedliche tenant-keks, habe identische") } decryptedA, err := store.GetDecrypted(ctx, tenantA, masterKey) if err != nil { t.Fatalf("decrypt a: %v", err) } if !bytes.Equal(decryptedA, kekA) { t.Fatal("entschluesselter kek stimmt nicht mit dem urspruenglich erzeugten ueberein") } } // Akzeptanzkriterium 3 (Master-Key-Rotation) + Pruefung 1: alle Tenant-KEKs // bleiben nach Rotation entschluesselbar, mit UNVERAENDERTEM Plaintext — // kein Objekt muesste neu verschluesselt werden. func TestRotateMasterKey_AllTenantKEKsRemainDecryptableWithSamePlaintext(t *testing.T) { store, pool, cleanup := setupTest(t) defer cleanup() ctx := context.Background() oldMasterKey := newMasterKey(t) tenantA := createTenant(t, pool, uniqueSlug("acme")) tenantB := createTenant(t, pool, uniqueSlug("globex")) kekA, err := store.CreateForTenant(ctx, tenantA, oldMasterKey) if err != nil { t.Fatalf("create a: %v", err) } kekB, err := store.CreateForTenant(ctx, tenantB, oldMasterKey) if err != nil { t.Fatalf("create b: %v", err) } newMasterKeyVal := newMasterKey(t) _, failed, err := store.RotateMasterKey(ctx, oldMasterKey, newMasterKeyVal) if err != nil { t.Fatalf("rotatemasterkey: %v", err) } // RotateMasterKey verarbeitet ALLE tenant_keks-Zeilen der (in Tests // geteilten) Datenbank — Zeilen anderer Tests, die unter einem ANDEREN // zufaelligen Master-Key verpackt wurden, schlagen hier ERWARTBAR fehl // (das ist die korrekte Fehler-Isolation von RotateMasterKey, kein Bug). // Relevant ist nur, dass GENAU DIESE beiden Tenants NICHT scheitern. for _, id := range failed { if id == tenantA || id == tenantB { t.Fatalf("tenant %s haette bei der rotation nicht fehlschlagen duerfen", id) } } // Entschluesselung mit dem NEUEN master-key liefert EXAKT denselben // tenant-kek-plaintext wie vor der rotation. afterA, err := store.GetDecrypted(ctx, tenantA, newMasterKeyVal) if err != nil { t.Fatalf("decrypt a nach rotation: %v", err) } if !bytes.Equal(afterA, kekA) { t.Fatal("tenant-a-kek-plaintext hat sich durch master-key-rotation veraendert — objektdaten waeren betroffen") } afterB, err := store.GetDecrypted(ctx, tenantB, newMasterKeyVal) if err != nil { t.Fatalf("decrypt b nach rotation: %v", err) } if !bytes.Equal(afterB, kekB) { t.Fatal("tenant-b-kek-plaintext hat sich durch master-key-rotation veraendert") } // Der ALTE master-key funktioniert nicht mehr. if _, err := store.GetDecrypted(ctx, tenantA, oldMasterKey); err == nil { t.Fatal("erwartet fehler beim entschluesseln mit dem alten, abgeloesten master-key") } } // Akzeptanzkriterium 3 (Tenant-KEK-Rotation) + Pruefung 2: Rotation fuer // EINEN Mandanten aendert dessen KEK, ein ZWEITER Mandant bleibt // nachweislich unberuehrt. func TestRotateTenantKEK_OnlyAffectsThatTenant(t *testing.T) { store, pool, cleanup := setupTest(t) defer cleanup() ctx := context.Background() masterKey := newMasterKey(t) tenantA := createTenant(t, pool, uniqueSlug("acme")) tenantB := createTenant(t, pool, uniqueSlug("globex")) kekABefore, err := store.CreateForTenant(ctx, tenantA, masterKey) if err != nil { t.Fatalf("create a: %v", err) } kekBBefore, err := store.CreateForTenant(ctx, tenantB, masterKey) if err != nil { t.Fatalf("create b: %v", err) } kekAAfter, err := store.RotateTenantKEK(ctx, tenantA, masterKey) if err != nil { t.Fatalf("rotatetenantkek: %v", err) } if bytes.Equal(kekAAfter, kekABefore) { t.Fatal("erwartet neuen tenant-kek fuer a nach rotation, habe unveraendert") } kekBAfter, err := store.GetDecrypted(ctx, tenantB, masterKey) if err != nil { t.Fatalf("decrypt b nach rotation von a: %v", err) } if !bytes.Equal(kekBAfter, kekBBefore) { t.Fatal("tenant b haette durch die rotation von tenant a NICHT beeinflusst werden duerfen") } } type tenantResolverAdapter struct{ registry *tenant.Registry } func (a tenantResolverAdapter) ResolveTenantID(ctx context.Context, tenantSlug string) (string, error) { t, err := a.registry.GetBySlug(ctx, tenantSlug) if err != nil { return "", err } return t.ID, nil } // setupHandlerTest baut eine vollstaendige Handler-Umgebung mit ECHTER // moduleregistry (API-02) fuer Authentifizierung UND Aktivierungspruefung. func setupHandlerTest(t *testing.T) (*Handler, *pgxpool.Pool, *moduleregistry.Registry, string, string) { t.Helper() store, pool, _ := setupTest(t) ctx := context.Background() masterKey := newMasterKey(t) flagService := flag.NewService(flag.NewStore(pool), 10*time.Millisecond) moduleRegistry := moduleregistry.NewRegistry(pool, flagService) tenantRegistry := tenant.NewRegistry(pool) moduleName := fmt.Sprintf("dms-%d", time.Now().UnixNano()) if _, err := moduleRegistry.Register(ctx, moduleName, "1.0.0", nil); err != nil { t.Fatalf("modul registrieren: %v", err) } clientID, secret, err := moduleRegistry.Provision(ctx, moduleName) if err != nil { t.Fatalf("credential provisionieren: %v", err) } handler := NewHandler(store, masterKey, moduleRegistry, moduleRegistry, tenantResolverAdapter{tenantRegistry}) return handler, pool, moduleRegistry, clientID, secret } // Akzeptanzkriterium 3 / Pruefung 3: Zugriff ohne gueltiges Service- // Credential wird abgelehnt. func TestTenantKEKHandler_RejectsMissingCredential(t *testing.T) { handler, pool, _, _, _ := setupHandlerTest(t) slug := uniqueSlug("acme") createTenant(t, pool, slug) req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil) rec := httptest.NewRecorder() handler.TenantKEKHandler(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403 ohne credential", rec.Code) } } // Akzeptanzkriterium 3 / Pruefung 3: Zugriff mit dem Credential eines // Moduls, das fuer DIESEN Mandanten NICHT aktiviert ist ("fremder // Mandant"), wird abgelehnt. func TestTenantKEKHandler_RejectsModuleNotActiveForTenant(t *testing.T) { handler, pool, _, clientID, secret := setupHandlerTest(t) ctx := context.Background() slug := uniqueSlug("fremder_mandant") tenantID := createTenant(t, pool, slug) if _, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey); err != nil { t.Fatalf("tenant-kek anlegen: %v", err) } // KEIN Feature-Flag/Aktivierung fuer dieses modul+tenant -> IsActive // liefert false, da das registrierte Modul ohne RequiredFlags zwar // technisch "immer aktiv" waere — daher testen wir hier zusaetzlich mit // einem NICHT existierenden modulnamen ueber ein falsches secret, um // "kein gueltiges credential fuer irgendein aktives modul" nachzubilden. req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil) req.Header.Set("X-Nexarch-Client-Id", clientID) req.Header.Set("X-Nexarch-Client-Secret", "falsches-secret") rec := httptest.NewRecorder() handler.TenantKEKHandler(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403 mit ungueltigem secret", rec.Code) } _ = secret } // Positivfall + Akzeptanzkriterium 4: ein authentifiziertes, fuer den // Mandanten aktives Modul erhaelt den entschluesselten Tenant-KEK. func TestTenantKEKHandler_AllowsActiveModuleForTenant(t *testing.T) { handler, pool, _, clientID, secret := setupHandlerTest(t) ctx := context.Background() slug := uniqueSlug("acme") tenantID := createTenant(t, pool, slug) expectedKEK, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey) if err != nil { t.Fatalf("tenant-kek anlegen: %v", err) } req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil) req.Header.Set("X-Nexarch-Client-Id", clientID) req.Header.Set("X-Nexarch-Client-Secret", secret) rec := httptest.NewRecorder() handler.TenantKEKHandler(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "tenant_kek_base64") { t.Fatalf("antwort enthaelt kein tenant_kek_base64-feld: %s", rec.Body.String()) } _ = expectedKEK _ = pool }