package e2e import ( "context" "strings" "testing" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/auth" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" "gitea.perlbach24.de/scripte/nexarch/internal/user" ) // TestE2E_CrossTenantIsolation ist der QA-02-Nachweis fuer Akzeptanzkriterium // "keine Datenlecks zwischen Mandanten": zwei vollstaendig provisionierte // Mandanten mit je einem Benutzer, dann drei unabhaengige Beweise, dass // weder Datenbank- noch Token-Ebene eine Vermischung zulassen. func TestE2E_CrossTenantIsolation(t *testing.T) { adminDSN := requireAdminDSN(t) ctx := context.Background() root := repoRoot(t) adminPool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("admin pool: %v", err) } // Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen // erst NACH allen defer-Aufrufen der Testfunktion (LIFO über alle // Cleanups hinweg), daher muss diese Registrierung vor der DELETE- // Cleanup unten stehen, damit der Pool beim Aufraeumen noch offen ist. t.Cleanup(func() { adminPool.Close() }) applyRegistrySchema(t, ctx, adminPool, root) registry := tenant.NewRegistry(adminPool) dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1) provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate) t.Cleanup(func() { _, _ = adminPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('e2e_tenant_a','e2e_tenant_b')`) dropTenantDBs(ctx, adminPool, "tenant_e2e_tenant_a", "tenant_e2e_tenant_b") }) setupTenantWithUser := func(slug, name, email, password string) (tenant.Tenant, user.User, *auth.LoginService, *pgxpool.Pool) { tn, err := provisioner.Provision(ctx, slug, name) if err != nil { t.Fatalf("provision %s: %v", slug, err) } pool, err := pgxpool.New(ctx, tn.DBDSN) if err != nil { t.Fatalf("connect %s: %v", slug, err) } applyTenantSchema(t, ctx, pool, root) store := user.NewTenantUserStore(pool) u, err := store.Create(ctx, email, "Benutzer "+slug) if err != nil { t.Fatalf("benutzer anlegen %s: %v", slug, err) } hash, err := auth.HashPassword(password) if err != nil { t.Fatalf("passwort hashen %s: %v", slug, err) } if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil { t.Fatalf("passwort setzen %s: %v", slug, err) } issuer := auth.NewTokenIssuer("qa-02-cross-tenant-secret") login := auth.NewLoginService(store, issuer, tn.Slug) return tn, u, login, pool } tenantA, userA, loginA, poolA := setupTenantWithUser("e2e_tenant_a", "Tenant A GmbH", "user@tenant-a.example", "passwort-a-sicher") defer poolA.Close() tenantB, userB, loginB, poolB := setupTenantWithUser("e2e_tenant_b", "Tenant B GmbH", "user@tenant-b.example", "passwort-b-sicher") defer poolB.Close() // Beweis 1: physische Trennung - Tenant Bs Benutzer existiert in der // users-Tabelle von Tenant A schlicht nicht (keine Zeile, kein Filter). var count int if err := poolA.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "user@tenant-b.example").Scan(&count); err != nil { t.Fatalf("query tenant a fuer tenant-b-email: %v", err) } if count != 0 { t.Fatalf("tenant a datenbank enthaelt %d zeile(n) fuer tenant-b-benutzer, erwartet 0", count) } if err := poolB.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "user@tenant-a.example").Scan(&count); err != nil { t.Fatalf("query tenant b fuer tenant-a-email: %v", err) } if count != 0 { t.Fatalf("tenant b datenbank enthaelt %d zeile(n) fuer tenant-a-benutzer, erwartet 0", count) } // Beweis 2: Login schlaegt fehl, wenn ein Benutzer versucht, sich ueber // den falschen Tenant-LoginService anzumelden - er existiert dort nicht. if _, err := loginA.Login(ctx, "user@tenant-b.example", "passwort-b-sicher"); err == nil { t.Fatal("tenant-b-benutzer konnte sich ueber tenant-a-loginservice anmelden") } if _, err := loginB.Login(ctx, "user@tenant-a.example", "passwort-a-sicher"); err == nil { t.Fatal("tenant-a-benutzer konnte sich ueber tenant-b-loginservice anmelden") } // Beweis 3: Token tragen den korrekten Tenant-Slug und die korrekte // User-ID, keine Vermischung ueber gemeinsame IDs oder Slugs. tokenA, err := loginA.Login(ctx, "user@tenant-a.example", "passwort-a-sicher") if err != nil { t.Fatalf("login tenant a: %v", err) } issuer := auth.NewTokenIssuer("qa-02-cross-tenant-secret") claimsA, err := issuer.Verify(tokenA) if err != nil { t.Fatalf("verify token a: %v", err) } if claimsA.TenantSlug != tenantA.Slug || claimsA.UserID != userA.ID { t.Fatalf("token a claims = %+v, want tenant %q user %q", claimsA, tenantA.Slug, userA.ID) } if claimsA.TenantSlug == tenantB.Slug || claimsA.UserID == userB.ID { t.Fatal("token a claims stimmen mit tenant-b-daten ueberein") } }