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