Files
nexarch/mail/internal/storage/provision_test.go
T
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

243 lines
8.9 KiB
Go

// 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)
}
}