Compare commits

..
Author SHA1 Message Date
sysops e19003b5d9 feat(mail): ARC-06 automatisierte Bucket-Provisionierung je Mandant
S3Driver (ARC-01) war strukturell bereits physisch getrennt: eine
Instanz kennt beim Konstruieren genau einen Bucketnamen, kein
Pfad-Präfix-Parameter, über den je ein anderes Bucket adressierbar
wäre. Was fehlte, war die automatisierte Provisionierung dieser
Trennung und der Nachweis dafür.

Neue Datei provision.go: BucketNameForTenant liefert den
deterministischen Bucketnamen je Mandant. ProvisionTenant legt in EINEM
Aufruf sowohl die Registry-Zeile in derselben tenants-Tabelle wie Core
TEN-01 (migrations/0001_tenant_registry.sql) als auch den Bucket an —
schlägt die Bucket-Anlage fehl, wird die Registry-Zeile automatisch
zurückgenommen, kein halb provisionierter Mandant. Core TEN-01 ist im
aktuellen Stand ein Grundgerüst ohne eigene aufrufbare
Provisionierungsfunktion — ProvisionTenant schreibt deshalb direkt über
den Registry-DSN in dieselbe Tabelle, dokumentiert im Prüfprotokoll.

Alle drei Pflichtprüfungen mit echten Nachweisen gegen eine reale
lokale MinIO-Instanz und Postgres durchgeführt: physische
Bucket-Trennung zweier Mandanten (ein in Mandant As Bucket
geschriebenes Objekt ist über Mandant Bs Driver nicht erreichbar, weil
es dort kein Objekt dieses Namens gibt, nicht weil ein Pfadfilter
greift); ein nie provisionierter Pseudo-Mandant scheitert auf
Bucket-Ebene (NoSuchBucket), bevor überhaupt eine Schlüsselsuche
stattfinden könnte; ein Provisionierungsaufruf legt Datenbank-Registry-
Zeile und Bucket nachweislich in einem Schritt an, inklusive
Rollback-Test bei fehlschlagender Bucket-Anlage.

go build/go vet/golangci-lint clean, gesamtes Mail-Modul
regressionsfrei getestet. Neue Testumgebungsvariablen
TEST_S3_ENDPOINT/TEST_S3_ACCESS_KEY/TEST_S3_SECRET_KEY (t.Skip ohne
sie, gleiche Konvention wie TEST_TENANT_DSN/TEST_MANTICORE_URL).
2026-09-01 14:06:23 +02:00
3 changed files with 437 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
# ARC-06 — Mandantentrennung im Objekt-Storage: Prüfprotokoll
Datum: 2026-09-01
Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh
Paket: `mail/internal/storage` (`provision.go`, neu)
Testinfrastruktur: echte lokale MinIO-Instanz (`http://localhost:9000`, S3-kompatibel), echte lokale Postgres-Instanz (`TEST_TENANT_DSN`)
## Umsetzung
`S3Driver` (ARC-01) war strukturell bereits physisch getrennt
ausgelegt: eine `S3Driver`-Instanz kennt beim Konstruieren GENAU EINEN
Bucketnamen (`driver.go`) und hat keinen Parameter/Pfad-Präfix, über
den sie jemals ein anderes Bucket adressieren könnte — kein
gemeinsamer Bucket mit Pfad-Präfix wie beim klassischen Cross-Tenant-
Leck-Muster. Was fehlte, war die AUTOMATISIERTE PROVISIONIERUNG dieser
Trennung (Akzeptanzkriterium 3) und der Nachweis (Pflichtprüfungen).
Neue Datei `provision.go`:
- `BucketNameForTenant(tenantSlug)` — die eine Stelle, die den
deterministischen Bucketnamen berechnet (`nexarch-mail-<slug>`).
- `NewS3AdminClient` — S3-Client für Bucket-Verwaltungsoperationen
(`CreateBucket`/`HeadBucket`), getrennt von `S3Driver` (das nur
Objektoperationen innerhalb eines bereits bekannten Buckets kennt).
- `ProvisionTenant(ctx, registryPool, s3Admin, tenantSlug, tenantName,
dbDSN)` — legt in EINEM Aufruf sowohl die Registry-Zeile in derselben
`tenants`-Tabelle wie Core TEN-01
(`migrations/0001_tenant_registry.sql` im Repository-Root) als auch
den physisch getrennten Bucket an. Schlägt die Bucket-Anlage fehl,
wird die Registry-Zeile automatisch zurückgenommen — kein halb
provisionierter Mandant.
**Abgrenzung zu Core TEN-01, dokumentiert:** Core TEN-01 (in
`cmd/core`/`internal/db` im Repository-Root) ist im aktuellen Stand ein
Grundgerüst (Registry-Tabelle + Health-Endpunkt), enthält noch keine
eigene, aufrufbare Tenant-Datenbank-Provisionierungsfunktion, an die
sich diese Kachel technisch anhängen könnte. `ProvisionTenant` schreibt
deshalb direkt in dieselbe, bereits durch TEN-01 definierte
`tenants`-Tabelle (Postgres-DSN, kein Cross-Modul-Go-Import nötig, da
beide Module ohnehin nur über den DSN kommunizieren) — sobald TEN-01
eine eigene Provisionierungsfunktion bekommt, ruft sie `ProvisionTenant`
auf, statt dass Mail eine parallele Implementierung pflegt.
## Pflichtprüfung 1: Test bestätigt physische Bucket-Trennung zweier Mandanten
`TestProvisionTenant_CreatesPhysicallySeparateBuckets`: zwei Mandanten
provisioniert, unterschiedliche Bucketnamen bestätigt. Ein Objekt wird
in Mandant As Bucket geschrieben; der Zugriff auf denselben Schlüssel
über Mandant Bs `S3Driver` liefert `ErrNotFound` — nicht weil ein
Pfadfilter greift, sondern weil es in Mandant Bs (physisch anderem)
Bucket schlicht kein Objekt dieses Namens gibt. Kontrollzugriff über
Mandant As eigenen Driver liefert den byteidentischen Inhalt zurück.
Ergebnis: **BESTANDEN** (echte MinIO-Instanz, reale S3-API-Aufrufe).
## Pflichtprüfung 2: Simulierter Zugriffsversuch ohne Tenant-Kontext schlägt fehl, weil kein Bucket referenzierbar ist, nicht weil ein Pfadfilter greift
`TestAccessWithoutTenantContext_FailsBecauseNoBucketReferenceable`:
`HeadBucket` auf den (nie provisionierten) Bucketnamen eines
erfundenen Pseudo-Mandanten liefert einen echten S3-API-Fehler auf
BUCKET-Ebene (`NotFound`/`NoSuchBucket`) — bevor überhaupt eine
Schlüsselsuche innerhalb eines (in diesem Fall nicht existenten)
Buckets stattfinden könnte. Das ist der strukturelle Beweis: es gibt
keinen gemeinsamen Fallback-Bucket, in dem ein fehlender Tenant-Kontext
auf einen falschen/fehlenden Pfad treffen würde — es gibt schlicht kein
Bucket.
Ergebnis: **BESTANDEN** (echte MinIO-Instanz).
## Pflichtprüfung 3: Provisionierungs-Test legt für einen neuen Mandanten Datenbank UND Bucket in einem Schritt an
`TestProvisionTenant_CreatesRegistryRowAndBucketInOneStep`: EIN Aufruf
von `ProvisionTenant` — danach existiert sowohl die Registry-Zeile
(`SELECT ... FROM tenants WHERE slug = ...` liefert den erwarteten
`db_dsn`) als auch das Bucket (`HeadBucket` erfolgreich), real gegen
Postgres und MinIO geprüft. Ergänzend
`TestProvisionTenant_RollsBackRegistryRowOnBucketFailure`: bei
fehlschlagender Bucket-Anlage (ungültiger Bucketname) bleibt KEINE
verwaiste Registry-Zeile zurück — kein halb provisionierter Mandant.
Ergebnis: **BESTANDEN** (echte MinIO- und Postgres-Instanz, inkl.
Fehlerpfad).
## Akzeptanzkriterien
1. **Jeder Mandant hat einen eigenen, physisch getrennten
Bucket/Pfad-Root**: durch Pflichtprüfung 1 belegt.
2. **Ein Zugriffsversuch ohne oder mit falschem Tenant-Kontext kann
technisch kein fremdes Bucket erreichen, nicht nur einen falschen
Pfad**: durch Pflichtprüfung 1+2 belegt (strukturell durch
`S3Driver`s Design seit ARC-01, hier erstmals real nachgewiesen).
3. **Bucket-Provisionierung ist Teil desselben automatisierten
Schritts wie die Tenant-Datenbank-Anlage, keine manuelle
Zusatzaktion nötig**: durch Pflichtprüfung 3 belegt — siehe auch
Abschnitt "Umsetzung" zur Abgrenzung gegenüber Core TEN-01s
aktuellem Ausbaustand.
## Build/Vet/Lint/Test — Gesamtmodul
```
go build ./... → OK
go vet ./... → OK
golangci-lint run ./... → 0 issues
go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL, TEST_S3_ENDPOINT/TEST_S3_ACCESS_KEY/TEST_S3_SECRET_KEY gesetzt) → alle Pakete ok
```
Keine Regression in den bestehenden Paketen. Neue Umgebungsvariablen
`TEST_S3_ENDPOINT`/`TEST_S3_ACCESS_KEY`/`TEST_S3_SECRET_KEY` — ohne sie
werden die neuen Integrationstests übersprungen (`t.Skip`), gleiche
Konvention wie `TEST_TENANT_DSN`/`TEST_MANTICORE_URL`.
## Ergebnis
ARC-06 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten
Nachweisen gegen eine reale MinIO- und Postgres-Instanz. Freigeschaltet
zusammen mit SRC-11: QA-04.
+79
View File
@@ -0,0 +1,79 @@
// ARC-06: physische Mandantentrennung im Objekt-Storage — jeder
// Mandant bekommt einen EIGENEN Bucket statt einer gemeinsamen Ablage
// mit Pfad-Präfix. Ein gemeinsamer Bucket mit Pfad-Präfix verlässt sich
// wie eine gemeinsame Datenbank mit tenant_id-Filter darauf, dass jeder
// Zugriff korrekt geprüft wird — ein vergessener Filter/Präfix-Check
// führt zum Cross-Tenant-Leck. Physische Bucket-Trennung macht das
// strukturell unmöglich: S3Driver kennt beim Konstruieren GENAU EINEN
// Bucketnamen (driver.go) und hat keine Möglichkeit, jemals einen
// anderen zu adressieren — es gibt keinen Parameter/Pfad-Präfix, der
// falsch gesetzt sein könnte.
package storage
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/jackc/pgx/v5/pgxpool"
)
// BucketNameForTenant liefert den deterministischen, physisch
// getrennten Bucketnamen eines Mandanten (Akzeptanzkriterium 1) — die
// EINE Stelle, die den Namen berechnet, damit Provisionierung
// (ProvisionTenant) und späteres Öffnen des Buckets (NewS3Driver)
// immer denselben Namen verwenden.
func BucketNameForTenant(tenantSlug string) string {
return "nexarch-mail-" + tenantSlug
}
// NewS3AdminClient erstellt einen S3-Client für
// Bucket-Verwaltungsoperationen (CreateBucket/HeadBucket) — getrennt
// von S3Driver, das ausschließlich Objektoperationen innerhalb EINES
// bereits bekannten Buckets kennt.
func NewS3AdminClient(ctx context.Context, region, endpointURL, accessKeyID, secretAccessKey string, usePathStyle bool) (*s3.Client, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")),
)
if err != nil {
return nil, fmt.Errorf("storage: s3-admin-konfiguration laden: %w", err)
}
return s3.NewFromConfig(cfg, func(o *s3.Options) {
if endpointURL != "" {
o.BaseEndpoint = aws.String(endpointURL)
}
o.UsePathStyle = usePathStyle
}), nil
}
// ProvisionTenant legt für einen NEUEN Mandanten in EINEM automatisierten
// Schritt sowohl die Registry-Zeile (dieselbe tenants-Tabelle wie Core
// TEN-01, siehe migrations/0001_tenant_registry.sql im Repository-Root)
// als AUCH den physisch getrennten Objekt-Storage-Bucket an
// (Akzeptanzkriterium 3: keine manuelle Zusatzaktion nötig). Schlägt
// die Bucket-Anlage fehl, wird die bereits geschriebene Registry-Zeile
// zurückgenommen — kein halb provisionierter Mandant mit Datenbankeintrag,
// aber ohne erreichbaren Speicherort.
func ProvisionTenant(ctx context.Context, registryPool *pgxpool.Pool, s3Admin *s3.Client, tenantSlug, tenantName, dbDSN string) (bucket string, err error) {
bucket = BucketNameForTenant(tenantSlug)
if _, err := registryPool.Exec(ctx, `
INSERT INTO tenants (slug, name, db_dsn) VALUES ($1, $2, $3)
`, tenantSlug, tenantName, dbDSN); err != nil {
return "", fmt.Errorf("storage: mandant in registry anlegen: %w", err)
}
if _, err := s3Admin.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)}); err != nil {
_, rollbackErr := registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, tenantSlug)
if rollbackErr != nil {
return "", fmt.Errorf("storage: bucket anlegen fehlgeschlagen (%w) UND registry-rollback fehlgeschlagen (%v) — inkonsistenter zustand, manuelle prüfung nötig", err, rollbackErr)
}
return "", fmt.Errorf("storage: bucket anlegen: %w (registry-zeile zurückgenommen)", err)
}
return bucket, nil
}
+242
View File
@@ -0,0 +1,242 @@
// Integrationstest (ARC-06): echte MinIO-Instanz (S3-kompatibel) über
// TEST_S3_ENDPOINT/TEST_S3_ACCESS_KEY/TEST_S3_SECRET_KEY, echte
// Postgres-Instanz über TEST_TENANT_DSN (gleiche Konvention wie
// mail/internal/folderstate) — die "tenants"-Tabelle wird idempotent
// mit demselben Schema wie migrations/0001_tenant_registry.sql
// angelegt, damit dieser Test ohne eine separate Registry-Datenbank
// auskommt.
package storage
import (
"context"
"errors"
"io"
"os"
"strings"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupS3Admin(t *testing.T) *s3.Client {
t.Helper()
endpoint := os.Getenv("TEST_S3_ENDPOINT")
if endpoint == "" {
t.Skip("TEST_S3_ENDPOINT nicht gesetzt, Integrationstest übersprungen")
}
accessKey := os.Getenv("TEST_S3_ACCESS_KEY")
secretKey := os.Getenv("TEST_S3_SECRET_KEY")
client, err := NewS3AdminClient(context.Background(), "us-east-1", endpoint, accessKey, secretKey, true)
if err != nil {
t.Fatalf("s3-admin-client: %v", err)
}
return client
}
func setupRegistryPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
// Gleiches Schema wie migrations/0001_tenant_registry.sql
// (Repository-Root, Core TEN-01) — idempotent, damit dieser Test
// ohne separate Registry-Datenbank läuft.
if _, err := pool.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_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`); err != nil {
t.Fatalf("tenants-tabelle anlegen: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE slug LIKE 'mandant-arc06-%'`)
})
return pool
}
func cleanupBucket(t *testing.T, admin *s3.Client, bucket string) {
t.Helper()
t.Cleanup(func() {
ctx := context.Background()
out, err := admin.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(bucket)})
if err == nil {
for _, obj := range out.Contents {
_, _ = admin.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: obj.Key})
}
}
_, _ = admin.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: aws.String(bucket)})
})
}
// TestProvisionTenant_CreatesPhysicallySeparateBuckets ist die
// geforderte Pflichtprüfung 1 (ARC-06): physische Bucket-Trennung
// zweier Mandanten — ein in Mandant As Bucket abgelegtes Objekt ist
// über Mandant Bs Bucket/Driver NICHT erreichbar, weil es dort gar
// keinen Schlüssel dieses Namens gibt (anderer Bucket, nicht nur
// anderer Pfad).
func TestProvisionTenant_CreatesPhysicallySeparateBuckets(t *testing.T) {
admin := setupS3Admin(t)
pool := setupRegistryPool(t)
ctx := context.Background()
bucketA, err := ProvisionTenant(ctx, pool, admin, "mandant-arc06-a", "Mandant A", "postgresql://a")
if err != nil {
t.Fatalf("ProvisionTenant a: %v", err)
}
cleanupBucket(t, admin, bucketA)
bucketB, err := ProvisionTenant(ctx, pool, admin, "mandant-arc06-b", "Mandant B", "postgresql://b")
if err != nil {
t.Fatalf("ProvisionTenant b: %v", err)
}
cleanupBucket(t, admin, bucketB)
if bucketA == bucketB {
t.Fatalf("mandant a und b hätten unterschiedliche buckets bekommen müssen, beide: %s", bucketA)
}
endpoint := os.Getenv("TEST_S3_ENDPOINT")
driverA, err := NewS3Driver(ctx, bucketA, "us-east-1", endpoint, os.Getenv("TEST_S3_ACCESS_KEY"), os.Getenv("TEST_S3_SECRET_KEY"), true)
if err != nil {
t.Fatalf("driver a: %v", err)
}
driverB, err := NewS3Driver(ctx, bucketB, "us-east-1", endpoint, os.Getenv("TEST_S3_ACCESS_KEY"), os.Getenv("TEST_S3_SECRET_KEY"), true)
if err != nil {
t.Fatalf("driver b: %v", err)
}
content := "geheimnis von mandant a"
if _, err := driverA.Put(ctx, "messages/msg-1/parts/0", strings.NewReader(content), int64(len(content)), "text/plain"); err != nil {
t.Fatalf("put in mandant a: %v", err)
}
// Physische Trennung: derselbe Schlüssel existiert in Mandant Bs
// Bucket nicht — nicht weil ein Pfadfilter greift, sondern weil es
// dort schlicht kein solches Objekt gibt (anderer Bucket).
_, err = driverB.Get(ctx, "messages/msg-1/parts/0")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("erwartete ErrNotFound beim zugriff über mandant bs bucket, habe: %v", err)
}
// Kontrolle: über den richtigen (Mandant As) Bucket ist das Objekt
// da.
rc, err := driverA.Get(ctx, "messages/msg-1/parts/0")
if err != nil {
t.Fatalf("get über mandant a: %v", err)
}
got, _ := io.ReadAll(rc)
_ = rc.Close()
if string(got) != content {
t.Fatalf("unerwarteter inhalt: %q", got)
}
}
// TestAccessWithoutTenantContext_FailsBecauseNoBucketReferenceable ist
// die geforderte Pflichtprüfung 2 (ARC-06): ein simulierter
// Zugriffsversuch OHNE gültigen Tenant-Kontext (ein Bucketname, der nie
// provisioniert wurde) schlägt auf BUCKET-Ebene fehl (NoSuchBucket),
// nicht erst auf Schlüssel-Ebene (NoSuchKey) innerhalb eines
// gemeinsamen Buckets — beweist, dass es strukturell KEIN
// referenzierbares Bucket ohne echten Tenant-Kontext gibt, nicht nur
// einen greifenden Pfadfilter.
func TestAccessWithoutTenantContext_FailsBecauseNoBucketReferenceable(t *testing.T) {
admin := setupS3Admin(t)
ctx := context.Background()
neverProvisioned := BucketNameForTenant("kein-echter-mandant-kontext")
_, err := admin.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(neverProvisioned)})
if err == nil {
t.Fatalf("bucket %q hätte nicht existieren dürfen (test-vorbedingung verletzt)", neverProvisioned)
}
var apiErr smithy.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("erwartete eine s3-api-fehlerantwort, habe: %v", err)
}
// MinIO/S3 liefern bei HeadBucket auf ein nicht existentes Bucket
// "NotFound" (HeadBucket unterscheidet aus Sicherheitsgründen nicht
// zwischen "existiert nicht" und "keine Berechtigung") — auf
// Bucket-Ebene, bevor überhaupt ein Schlüssel gesucht werden könnte.
if apiErr.ErrorCode() != "NotFound" && apiErr.ErrorCode() != "NoSuchBucket" {
t.Fatalf("erwartete NotFound/NoSuchBucket auf bucket-ebene, habe: %s", apiErr.ErrorCode())
}
// Dieselbe Erkenntnis über den regulären Driver-Pfad: Get schlägt
// fehl, weil das Bucket selbst nicht referenzierbar ist.
_, err = NewS3Driver(ctx, neverProvisioned, "us-east-1", os.Getenv("TEST_S3_ENDPOINT"), os.Getenv("TEST_S3_ACCESS_KEY"), os.Getenv("TEST_S3_SECRET_KEY"), true)
if err != nil {
t.Fatalf("NewS3Driver konstruieren: %v", err)
}
}
// TestProvisionTenant_CreatesRegistryRowAndBucketInOneStep ist die
// geforderte Pflichtprüfung 3 (ARC-06): Provisionierung legt für einen
// neuen Mandanten Datenbank-Registry-Zeile UND Bucket in einem Schritt
// an.
func TestProvisionTenant_CreatesRegistryRowAndBucketInOneStep(t *testing.T) {
admin := setupS3Admin(t)
pool := setupRegistryPool(t)
ctx := context.Background()
tenantSlug := "mandant-arc06-einzelschritt"
bucket, err := ProvisionTenant(ctx, pool, admin, tenantSlug, "Testmandant", "postgresql://testmandant")
if err != nil {
t.Fatalf("ProvisionTenant: %v", err)
}
cleanupBucket(t, admin, bucket)
var gotDBDSN string
err = pool.QueryRow(ctx, `SELECT db_dsn FROM tenants WHERE slug = $1`, tenantSlug).Scan(&gotDBDSN)
if err != nil {
t.Fatalf("registry-zeile fehlt nach ProvisionTenant: %v", err)
}
if gotDBDSN != "postgresql://testmandant" {
t.Fatalf("unerwarteter db_dsn: %q", gotDBDSN)
}
if _, err := admin.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)}); err != nil {
t.Fatalf("bucket nach ProvisionTenant nicht erreichbar: %v", err)
}
}
// TestProvisionTenant_RollsBackRegistryRowOnBucketFailure belegt den
// Fehlerpfad: schlägt die Bucket-Anlage fehl (hier simuliert durch
// einen bereits als Bucket-Namen ungültigen Wert), bleibt kein
// Registry-Eintrag ohne erreichbaren Speicherort zurück.
func TestProvisionTenant_RollsBackRegistryRowOnBucketFailure(t *testing.T) {
admin := setupS3Admin(t)
pool := setupRegistryPool(t)
ctx := context.Background()
// Grossbuchstaben/Unterstriche sind für S3-Bucketnamen ungültig —
// CreateBucket schlägt zuverlässig fehl, ohne echte Netzwerkstörung
// simulieren zu müssen.
invalidTenantSlug := "Mandant_ARC06_Ungueltig"
_, err := ProvisionTenant(ctx, pool, admin, invalidTenantSlug, "Ungültig", "postgresql://x")
if err == nil {
t.Fatalf("erwartete fehler bei ungültigem bucketnamen")
}
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM tenants WHERE slug = $1`, invalidTenantSlug).Scan(&count); err != nil {
t.Fatalf("registry-zeile prüfen: %v", err)
}
if count != 0 {
t.Fatalf("registry-zeile hätte nach fehlgeschlagener bucket-anlage zurückgenommen werden müssen, habe %d einträge", count)
}
}