TEN-02: tenant-onboarding-api-self-service
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e4793303fc
commit
5915a4e7b1
@@ -0,0 +1,169 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user