QA-02: echter End-to-End-Testlauf ueber TEN-01..07/IAM-01..14

Volle Merge-Verifikation aller 21 Vorbedingungs-Tickets: neue
internal/e2e-Tests (Tenant-Onboarding-Login-Flow, Cross-Tenant-Isolation)
gegen echte Postgres-Instanz, docs/QA-02-PRUEFPROTOKOLL.md mit
Ticket-zu-Test-Zuordnung. Dabei echten Merge-Konflikt zwischen IAM-12 und
IAM-13 gefunden und behoben (jwk/jwkSet-Typkollision in internal/oidc).
124/124 Tests gruen auf 131.
This commit is contained in:
sysops
2026-08-29 09:35:31 +02:00
parent 4f84ee23dc
commit 314b821929
6 changed files with 440 additions and 7 deletions
+116
View File
@@ -0,0 +1,116 @@
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)
}
defer 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")
}
}
+107
View File
@@ -0,0 +1,107 @@
// Package e2e enthaelt QA-02 (Pruefgate Identitaet & Mandanten): echte
// End-to-End-Tests gegen eine laufende Postgres-Instanz, die den vollen Weg
// "Mandant anlegen -> Benutzer anlegen -> anmelden" ueber die tatsaechlichen
// Produktionspakete (internal/tenant, internal/user, internal/auth) treiben,
// statt nur die Einzelpakete isoliert zu testen.
package e2e
import (
"context"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// requireAdminDSN liefert TEST_ADMIN_DSN oder ueberspringt den Test - wie in
// allen bestehenden Integrationstests dieses Repos (siehe
// internal/tenant/provisioner_test.go).
func requireAdminDSN(t *testing.T) string {
t.Helper()
dsn := os.Getenv("TEST_ADMIN_DSN")
if dsn == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, E2E-Test uebersprungen")
}
return dsn
}
// repoRoot findet den Modul-Root ausgehend vom Testverzeichnis, damit die
// SQL-Migrationsverzeichnisse unabhaengig vom Arbeitsverzeichnis (go test
// wechselt ins Paketverzeichnis) gefunden werden.
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
// internal/e2e -> zwei Ebenen hoch zum Modul-Root.
return filepath.Join(wd, "..", "..")
}
// applyRegistrySchema legt die Control-Plane-Tabellen (tenants, superadmins,
// lifecycle-Spalten) frisch an, indem alle migrations/*.up.sql in
// Dateinamen-Reihenfolge angewendet werden - identisch zur echten
// Rollout-Reihenfolge, nicht eine verkuerzte Testkopie des Schemas.
func applyRegistrySchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool, root string) {
t.Helper()
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations"))
}
// applyTenantSchema initialisiert eine frisch provisionierte Tenant-Datenbank
// mit allen migrations/tenant/*.up.sql (nicht nur 0001_users, damit
// password_hash, role_assignments etc. wie in Produktion vorhanden sind).
func applyTenantSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool, root string) {
t.Helper()
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations", "tenant"))
}
func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
sqlBytes, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("migration %q lesen: %v", name, err)
}
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
// Migrationen sind Produktionsartefakte ohne eingebaute
// Idempotenz (in Produktion durch eine Migrations-Tracking-
// Tabelle abgesichert, die dieser Test bewusst nicht nachbaut).
// Zwei E2E-Tests im selben Prozess teilen sich dieselbe
// Registry-Datenbank, daher ist "schon vorhanden" hier kein
// Fehler, sondern der Normalfall beim zweiten Testlauf.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710") {
continue
}
t.Fatalf("migration %q anwenden: %v", name, err)
}
}
}
// dropTenantDBs entfernt Test-Datenbanken bedingungslos, fuer Cleanup nach
// dem Test unabhaengig vom Testausgang.
func dropTenantDBs(ctx context.Context, adminPool *pgxpool.Pool, names ...string) {
for _, n := range names {
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(n))
}
}
func quoteIdent(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
+112
View File
@@ -0,0 +1,112 @@
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_TenantOnboardingLoginFlow ist der zentrale QA-02-Nachweis: der
// vollstaendige, echte Weg ueber TEN-01..07/IAM-01..14 hinweg -
//
// Mandant provisionieren (internal/tenant.Provisioner, physisch isolierte DB)
// -> Administrator anlegen (internal/user.TenantUserStore.Create +
// SetPasswordHash, wie es internal/tenant.OnboardingService fuer TEN-02
// tut, hier explizit nachgebaut um zusaetzlich ein Passwort zu setzen)
// -> Anmelden (internal/auth.LoginService.Login)
// -> Assert: gueltiges Token, richtiger Tenant.
func TestE2E_TenantOnboardingLoginFlow(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)
}
defer 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 = 'e2e_onboard'`)
dropTenantDBs(ctx, adminPool, "tenant_e2e_onboard")
})
// Schritt 1: Mandant provisionieren (TEN-01/TEN-02) - eigene, physisch
// getrennte Datenbank statt gemeinsamer tenant_id-Spalte.
tn, err := provisioner.Provision(ctx, "e2e_onboard", "E2E Onboarding GmbH")
if err != nil {
t.Fatalf("provision: %v", err)
}
tenantPool, err := pgxpool.New(ctx, tn.DBDSN)
if err != nil {
t.Fatalf("connect tenant db: %v", err)
}
defer tenantPool.Close()
// Schema in der frisch angelegten Tenant-Datenbank ausrollen (TEN-07-
// Migrationslogik, hier direkt statt ueber den Orchestrator, um im Test
// nicht von der Registry-Iteration abhaengig zu sein).
applyTenantSchema(t, ctx, tenantPool, root)
// Schritt 2: Benutzer anlegen (IAM-01/IAM-02) und Passwort setzen
// (IAM-03, bcrypt ueber internal/auth.HashPassword).
userStore := user.NewTenantUserStore(tenantPool)
created, err := userStore.Create(ctx, "admin@e2e_onboard.example", "E2E Admin")
if err != nil {
t.Fatalf("benutzer anlegen: %v", err)
}
hash, err := auth.HashPassword("korrekt-pferd-batterie-heftklammer")
if err != nil {
t.Fatalf("passwort hashen: %v", err)
}
if err := userStore.SetPasswordHash(ctx, created.ID, hash); err != nil {
t.Fatalf("passwort-hash setzen: %v", err)
}
// Schritt 3: Anmelden (IAM-04/IAM-05 - LoginService gegen genau diese
// Tenant-DB, kein tenant-uebergreifendes Login moeglich).
issuer := auth.NewTokenIssuer("test-signing-secret-fuer-qa-02-e2e")
login := auth.NewLoginService(userStore, issuer, tn.Slug)
token, err := login.Login(ctx, "admin@e2e_onboard.example", "korrekt-pferd-batterie-heftklammer")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("login lieferte leeres token")
}
// Assert: Token ist gueltig UND traegt den richtigen Tenant (Verify statt
// blindem String-Nichtleer-Check - sonst waere ein kaputtes Token auch
// "gruen").
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("token verifizieren: %v", err)
}
if claims.TenantSlug != "e2e_onboard" {
t.Fatalf("token tenant = %q, want %q", claims.TenantSlug, "e2e_onboard")
}
if claims.UserID != created.ID {
t.Fatalf("token user id = %q, want %q", claims.UserID, created.ID)
}
// Gegenprobe: falsches Passwort scheitert weiterhin (kein False-Positive
// durch einen zu freizuegigen Testaufbau).
if _, err := login.Login(ctx, "admin@e2e_onboard.example", "falsches-passwort"); err == nil {
t.Fatal("login mit falschem passwort haette scheitern muessen")
}
}