// Integrationstest (ARC-09): echte Postgres-Instanz (mailboxconfig.Store) // und echte MinIO-Instanz (storage.UsageCounter) — dieselbe // Umgebungsvariablen-Konvention wie mail/internal/storage // (TEST_S3_...) und mail/internal/mailboxconfig (TEST_TENANT_DSN). package smtp import ( "context" "net" "os" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto" "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailboxconfig" "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" "gitea.perlbach24.de/scripte/nexarch/mail/internal/quota" "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" ) type fixedKEKProvider struct{} func (fixedKEKProvider) TenantKEK(context.Context, string) ([]byte, error) { return make([]byte, crypto.KEKSize), nil } // TestIntegration_UsageDisplayMatchesRealSizeAndEnforcesQuota ist die // geforderte Pflichtprüfung 3 (ARC-09): Verbrauchsanzeige je Postfach // im Test korrekt gegen tatsächliche Größe geprüft — UND end-to-end: // echte Bytes real in einen echten, ARC-06-provisionierten Bucket // geschrieben, echte Verbrauchsmessung (S3-Listing), echtes // konfiguriertes Limit in Postgres, echte SMTP-RCPT-TO-Ablehnung, sobald // der reale Verbrauch das reale Limit erreicht. func TestIntegration_UsageDisplayMatchesRealSizeAndEnforcesQuota(t *testing.T) { dsn := os.Getenv("TEST_TENANT_DSN") s3Endpoint := os.Getenv("TEST_S3_ENDPOINT") if dsn == "" || s3Endpoint == "" { t.Skip("TEST_TENANT_DSN/TEST_S3_ENDPOINT nicht gesetzt, Integrationstest übersprungen") } ctx := context.Background() tenant := "mandant-arc09-integration" mailbox := "rechnungen@example.com" pool, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(pool.Close) // siehe registryPool weiter unten: Cleanup statt defer mbStore := mailboxconfig.NewStore(pool, crypto.NewService(fixedKEKProvider{})) if err := mbStore.EnsureSchema(ctx); err != nil { t.Fatalf("mailboxconfig schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM mail_mailboxes WHERE tenant_slug = $1`, tenant) }) mailboxID, err := mbStore.Create(ctx, tenant, mailboxconfig.CreateInput{ Name: mailbox, IMAPHost: "imap.example.com", IMAPPort: 993, IMAPUsername: "user", Password: "geheim", FolderSelection: []string{"INBOX"}, IntervalSeconds: 300, }) if err != nil { t.Fatalf("postfach anlegen: %v", err) } _ = mailboxID s3Admin, err := storage.NewS3AdminClient(ctx, "us-east-1", s3Endpoint, os.Getenv("TEST_S3_ACCESS_KEY"), os.Getenv("TEST_S3_SECRET_KEY"), true) if err != nil { t.Fatalf("s3-admin-client: %v", err) } registryPool, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("registry-pool: %v", err) } // t.Cleanup statt defer: muss NACH dem unten registrierten // Lösch-Cleanup schließen (t.Cleanup läuft LIFO, defer würde VOR // jedem t.Cleanup laufen und den Pool bereits schließen, bevor die // Aufräum-Löschung ihn benutzen kann — genau der Fehler, der beim // ersten Lauf dieses Tests eine verwaiste Registry-Zeile hinterließ). t.Cleanup(registryPool.Close) if _, err := registryPool.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: %v", err) } bucket, err := storage.ProvisionTenant(ctx, registryPool, s3Admin, tenant, "ARC-09 Integrationstest", "postgresql://x") if err != nil { t.Fatalf("ProvisionTenant: %v", err) } t.Cleanup(func() { _, _ = registryPool.Exec(context.Background(), `DELETE FROM tenants WHERE slug = $1`, tenant) }) t.Cleanup(func() { ctx := context.Background() out, err := s3Admin.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(bucket)}) if err == nil { for _, obj := range out.Contents { _, _ = s3Admin.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: obj.Key}) } } _, _ = s3Admin.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: aws.String(bucket)}) }) usageReporter := noopUsageReporterForTest{} storageSvc := storage.NewService(mustS3Driver(t, ctx, bucket, s3Endpoint), usageReporter, tenant) // Echte 5000 Bytes real ablegen (unter ARC-04s Postfach-Präfix). const contentSize = 5000 content := strings.Repeat("A", contentSize) key := storage.ArchiveKey(mailbox, time.Now(), "msg-arc09-1", 0) if _, err := storageSvc.Put(ctx, key, strings.NewReader(content), int64(len(content)), "text/plain"); err != nil { t.Fatalf("objekt ablegen: %v", err) } t.Cleanup(func() { _ = storageSvc.Delete(context.Background(), key, int64(len(content))) }) usageCounter := storage.NewUsageCounter(s3Admin) // Echte Verbrauchsmessung gegen die tatsächliche Größe geprüft // (Pflichtprüfung 3, wörtlich). measured, err := usageCounter.UsageBytes(ctx, tenant, mailbox) if err != nil { t.Fatalf("UsageBytes: %v", err) } if measured < contentSize { t.Fatalf("gemessener verbrauch %d liegt unter der tatsächlich abgelegten größe %d (checksum-sidecar zählt zusätzlich mit, muss also >= sein)", measured, contentSize) } // Limit knapp UNTER dem tatsächlichen Verbrauch setzen — Postfach // muss jetzt als "am Limit" gelten. if err := mbStore.SetQuotaBytes(ctx, tenant, mailbox, measured-1); err != nil { t.Fatalf("quota setzen: %v", err) } checker := quota.NewChecker(mbStore, usageCounter) srv := NewServerWithMaxMessageBytesTLSLoggerRateLimitGuardAndQuota( &fakeSink{}, defaultMaxMessageBytes, nil, nil, tenant, nil, protoguard.DefaultConfig(), checker, ) addr, stop := startRealServer(t, srv) defer stop() c := dial(t, addr) defer c.close() c.send(t, "EHLO client.example.com") for { line := c.readLine(t) if strings.HasPrefix(line, "250 ") { break } } c.send(t, "MAIL FROM:") resp := c.send(t, "RCPT TO:<"+mailbox+">") if code(resp) != "552" { t.Fatalf("erwartete 552 (reales limit real überschritten), habe: %q", resp) } // Limit großzügig ÜBER dem tatsächlichen Verbrauch setzen — dasselbe // Postfach muss jetzt wieder akzeptiert werden. if err := mbStore.SetQuotaBytes(ctx, tenant, mailbox, measured*10); err != nil { t.Fatalf("quota erhöhen: %v", err) } c2 := dial(t, addr) defer c2.close() c2.send(t, "EHLO client.example.com") for { line := c2.readLine(t) if strings.HasPrefix(line, "250 ") { break } } c2.send(t, "MAIL FROM:") resp2 := c2.send(t, "RCPT TO:<"+mailbox+">") if code(resp2) != "250" { t.Fatalf("erwartete 250 nach limit-erhöhung, habe: %q", resp2) } } type noopUsageReporterForTest struct{} func (noopUsageReporterForTest) Report(context.Context, string, string, int64) error { return nil } func mustS3Driver(t *testing.T, ctx context.Context, bucket, endpoint string) *storage.S3Driver { t.Helper() driver, err := storage.NewS3Driver(ctx, bucket, "us-east-1", endpoint, os.Getenv("TEST_S3_ACCESS_KEY"), os.Getenv("TEST_S3_SECRET_KEY"), true) if err != nil { t.Fatalf("s3-driver: %v", err) } return driver } func startRealServer(t *testing.T, srv *Server) (addr string, stop func()) { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listener: %v", err) } ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { _ = srv.Serve(ctx, listener) close(done) }() return listener.Addr().String(), func() { cancel() <-done } }