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).
243 lines
8.9 KiB
Go
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)
|
|
}
|
|
}
|