TEN-01: tenant-registry-datenbank-provisioning
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c895a67c4b
commit
8da9c67d08
@@ -0,0 +1,105 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user