Registry-DB (nur Tenant-Metadaten), Provisioning-Routine legt pro Mandant
eine physisch isolierte Postgres-DB an und registriert sie transaktional
(Rollback der DB bei fehlgeschlagener Registrierung). Schlanker HTTP-Handler
als Schnittstellen-Vorbereitung fuer API-01/TEN-02, kein eigenes REST-Grundgerüst.
Pruefungen:
1. Migration up/down geschrieben (0001_tenant_registry.{up,down}.sql) — nicht
gegen echte DB ausgefuehrt, da auf dieser Maschine kein Go/Postgres-Test-
Setup verfuegbar ist. Offen zur Ausfuehrung.
2. Integrationstest TestProvision_CreatesIsolatedDatabases geschrieben (zwei
Mandanten, prueft unterschiedliche db_name und current_database()) —
ebenfalls nicht ausgefuehrt, guarded per TEST_ADMIN_DSN env var. Offen.
3. Slug-Validierung (unit test TestValidateSlug) deckt SQL-Injection-Versuch
im Datenbanknamen ab — ebenfalls nicht lokal ausgefuehrt, da kein Go
Compiler auf dieser Maschine vorhanden ist. Offen.
Alle drei Pruefungen sind vorbereitet, aber NICHT durchgefuehrt worden —
zaehlen laut Vorgabe als offen bis auf einer Maschine mit Go+Postgres verifiziert.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
106 lines
3.3 KiB
Go
106 lines
3.3 KiB
Go
package tenant
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Integrationstest fuer Akzeptanzkriterien 2 und 3. Benoetigt eine echte
|
|
// Postgres-Instanz und wird ohne TEST_ADMIN_DSN uebersprungen, nicht als
|
|
// fehlgeschlagen gewertet — siehe Pruefungen-Ergebnis im PR.
|
|
//
|
|
// TEST_ADMIN_DSN muss auf die Wartungsdatenbank zeigen, z. B.:
|
|
//
|
|
// postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable
|
|
func TestProvision_CreatesIsolatedDatabases(t *testing.T) {
|
|
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)
|
|
}
|
|
defer adminPool.Close()
|
|
|
|
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("registry pool: %v", err)
|
|
}
|
|
defer registryPool.Close()
|
|
|
|
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)
|
|
}
|
|
|
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
registry := NewRegistry(registryPool)
|
|
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('acme','globex')`)
|
|
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_acme`)
|
|
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_globex`)
|
|
})
|
|
|
|
tenantA, err := provisioner.Provision(ctx, "acme", "Acme GmbH")
|
|
if err != nil {
|
|
t.Fatalf("provision acme: %v", err)
|
|
}
|
|
tenantB, err := provisioner.Provision(ctx, "globex", "Globex AG")
|
|
if err != nil {
|
|
t.Fatalf("provision globex: %v", err)
|
|
}
|
|
|
|
if tenantA.DBName == tenantB.DBName {
|
|
t.Fatalf("erwartet unterschiedliche db_name, beide sind %q", tenantA.DBName)
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 / Pruefung 3: In der Datenbank von Tenant A existiert
|
|
// keine Verbindungsmoeglichkeit zu Tenant B, weil beide physisch getrennte
|
|
// Datenbanken sind, statt sich auf einen Query-Filter zu verlassen.
|
|
poolA, err := pgxpool.New(ctx, tenantA.DBDSN)
|
|
if err != nil {
|
|
t.Fatalf("connect tenant a: %v", err)
|
|
}
|
|
defer poolA.Close()
|
|
|
|
var globexVisible bool
|
|
err = poolA.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1
|
|
)
|
|
`, tenantB.DBName).Scan(&globexVisible)
|
|
if err != nil {
|
|
t.Fatalf("pruefung tenant-trennung: %v", err)
|
|
}
|
|
// pg_database ist clusterweit sichtbar (Existenz der DB), aber die
|
|
// eigentliche Pruefung ist: aus poolA (verbunden mit tenant_acme) ist keine
|
|
// Tabelle/Zeile aus tenant_globex erreichbar, da current_database() getrennt ist.
|
|
var currentDB string
|
|
if err := poolA.QueryRow(ctx, `SELECT current_database()`).Scan(¤tDB); err != nil {
|
|
t.Fatalf("current_database: %v", err)
|
|
}
|
|
if currentDB != tenantA.DBName {
|
|
t.Fatalf("current_database() = %q, want %q — keine physische Trennung", currentDB, tenantA.DBName)
|
|
}
|
|
if currentDB == tenantB.DBName {
|
|
t.Fatalf("tenant a verbindung zeigt auf tenant b datenbank")
|
|
}
|
|
}
|