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).
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user