OnboardingService.Onboard: ein API-Aufruf legt Tenant (via bestehendem Provisioner) UND ersten Administrator-Account (via IAM-01 TenantUserStore) an. CREATE DATABASE erlaubt keine echte cross-database Transaktion, daher Saga-Kompensation: schlaegt die Admin-Anlage nach erfolgreichem Provisioning fehl, wird der Tenant per neuem Provisioner.Deprovision wieder vollstaendig entfernt (Registry.Delete + DB-Drop). Provisioner.Provision mappt Duplikat-Faelle (42P04 duplicate_database und den bei echt parallelen CREATE DATABASE moeglichen 23505-Unique-Konflikt auf pg_database) jetzt auf ErrTenantExists statt einer rohen Postgres-Fehlermeldung. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Onboarding-Vorgang zweimal parallel mit gleichem Slug ausgeloest — TestOnboarding_RejectsDuplicateSlugConcurrently: genau ein Erfolg, kein Doppel-Tenant, zweiter Aufruf bekommt ErrTenantExists. PASS. 2. Fehleingaben (leere Pflichtfelder, ungueltige E-Mail, ungueltiger Slug) — TestOnboarding_ValidationErrors deckt alle vier Faelle mit klaren Fehlern ab, bevor irgendein DB-Zugriff stattfindet. PASS. 3. Erfolgreicher Durchlauf von zweiter Person end-to-end nachvollzogen — NICHT durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
170 lines
4.7 KiB
Go
170 lines
4.7 KiB
Go
package tenant
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
)
|
|
|
|
// schemaPathForTest liefert den Pfad zur echten Migration, damit Tests exakt
|
|
// dasselbe Schema anwenden wie die Produktion (keine Zweitkopie des SQL).
|
|
const schemaPathForTest = "../../migrations/tenant/0001_users.up.sql"
|
|
|
|
func TestOnboarding_ValidationErrors(t *testing.T) {
|
|
// Reine Eingabevalidierung, braucht keine Datenbank.
|
|
svc := NewOnboardingService(nil, schemaPathForTest)
|
|
ctx := context.Background()
|
|
|
|
cases := []struct {
|
|
name string
|
|
slug string
|
|
tenantName string
|
|
email string
|
|
adminName string
|
|
wantErr error
|
|
}{
|
|
{"leerer name", "acme", "", "a@b.de", "Admin", ErrMissingField},
|
|
{"leerer admin-name", "acme", "Acme", "a@b.de", "", ErrMissingField},
|
|
{"ungueltiger slug", "Acme!", "Acme", "a@b.de", "Admin", ErrInvalidSlug},
|
|
{"ungueltige email", "acme", "Acme", "keine-email", "Admin", user.ErrInvalidEmail},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
_, err := svc.Onboard(ctx, c.slug, c.tenantName, c.email, c.adminName)
|
|
if !errors.Is(err, c.wantErr) {
|
|
t.Fatalf("erwartet %v, habe %v", c.wantErr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newTestOnboardingService(t *testing.T) (*OnboardingService, func()) {
|
|
t.Helper()
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
if adminDSN == "" {
|
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
adminPool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("admin pool: %v", err)
|
|
}
|
|
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("registry pool: %v", err)
|
|
}
|
|
|
|
if _, err := registryPool.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("registry-schema: %v", err)
|
|
}
|
|
|
|
registry := NewRegistry(registryPool)
|
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
svc := NewOnboardingService(provisioner, schemaPathForTest)
|
|
|
|
cleanup := func() {
|
|
registryPool.Close()
|
|
adminPool.Close()
|
|
}
|
|
return svc, cleanup
|
|
}
|
|
|
|
// Akzeptanzkriterien 1+2: Mandant + Erst-Admin in einem Vorgang.
|
|
func TestOnboarding_CreatesTenantAndAdmin(t *testing.T) {
|
|
svc, cleanup := newTestOnboardingService(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
t.Cleanup(func() {
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
p, _ := pgxpool.New(ctx, adminDSN)
|
|
defer p.Close()
|
|
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'onboardtest'`)
|
|
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_onboardtest`)
|
|
})
|
|
|
|
result, err := svc.Onboard(ctx, "onboardtest", "Onboard Test GmbH", "admin@onboardtest.example", "Erste Admin")
|
|
if err != nil {
|
|
t.Fatalf("onboard: %v", err)
|
|
}
|
|
if result.Tenant.Slug != "onboardtest" {
|
|
t.Fatalf("tenant slug = %q", result.Tenant.Slug)
|
|
}
|
|
if result.Admin.Email != "admin@onboardtest.example" {
|
|
t.Fatalf("admin email = %q", result.Admin.Email)
|
|
}
|
|
|
|
pool, err := pgxpool.New(ctx, result.Tenant.DBDSN)
|
|
if err != nil {
|
|
t.Fatalf("connect tenant db: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
var count int
|
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "admin@onboardtest.example").Scan(&count); err != nil {
|
|
t.Fatalf("admin in tenant-db pruefen: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("erwartet genau einen admin in der tenant-db, habe %d", count)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 1: doppelte Registrierung wird abgewiesen,
|
|
// auch bei zwei parallelen Anfragen entsteht kein Doppel-Tenant.
|
|
func TestOnboarding_RejectsDuplicateSlugConcurrently(t *testing.T) {
|
|
svc, cleanup := newTestOnboardingService(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
t.Cleanup(func() {
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
p, _ := pgxpool.New(ctx, adminDSN)
|
|
defer p.Close()
|
|
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'dupetest'`)
|
|
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_dupetest`)
|
|
})
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]error, 2)
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
_, err := svc.Onboard(ctx, "dupetest", "Dupe Test", "admin@dupetest.example", "Admin")
|
|
results[i] = err
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
successCount := 0
|
|
for _, err := range results {
|
|
if err == nil {
|
|
successCount++
|
|
} else if !errors.Is(err, ErrTenantExists) {
|
|
t.Fatalf("unerwarteter fehler: %v", err)
|
|
}
|
|
}
|
|
if successCount != 1 {
|
|
t.Fatalf("erwartet genau einen erfolgreichen onboarding-vorgang, habe %d", successCount)
|
|
}
|
|
}
|