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