From 53f26e7349246d488035077539ab0c1ad9c31b65 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Mon, 8 Dec 2025 16:33:39 +0000 Subject: [PATCH] feat: admin UX improvements with tenant switcher and email config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenant-Wechsel UI: - Add TenantBreadcrumb component showing active tenant in admin header - Add German translations for multi-tenant plugin selector - Integrate with existing plugin TenantSelector dropdown Email-Konfiguration UX: - Add SMTP field validation (host format, port range, required fields) - Add EmailDeliverabilityInfo component with SPF/DKIM/DMARC guidance - Add TestEmailButton component for SMTP configuration testing - Create /api/test-email endpoint with full security: - CSRF protection (double-submit cookie) - IP allowlist (same rules as /api/send-email) - Rate limiting (10/min per user) - Tenant access control with proper object normalization Security: - Add comprehensive integration tests for /api/test-email - Tests cover CSRF, IP blocking, auth, tenant access, input validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/anleitungen/TODO.md | 17 +- src/app/(payload)/admin/importMap.js | 6 + src/app/(payload)/api/test-email/route.ts | 223 ++++++ src/collections/Tenants.ts | 94 ++- .../admin/EmailDeliverabilityInfo.scss | 140 ++++ .../admin/EmailDeliverabilityInfo.tsx | 106 +++ src/components/admin/TenantBreadcrumb.scss | 37 + src/components/admin/TenantBreadcrumb.tsx | 30 + src/components/admin/TestEmailButton.scss | 173 +++++ src/components/admin/TestEmailButton.tsx | 257 +++++++ src/payload.config.ts | 21 + tests/int/security-api.int.spec.ts | 672 ++++++++++++++++++ 12 files changed, 1766 insertions(+), 10 deletions(-) create mode 100644 src/app/(payload)/api/test-email/route.ts create mode 100644 src/components/admin/EmailDeliverabilityInfo.scss create mode 100644 src/components/admin/EmailDeliverabilityInfo.tsx create mode 100644 src/components/admin/TenantBreadcrumb.scss create mode 100644 src/components/admin/TenantBreadcrumb.tsx create mode 100644 src/components/admin/TestEmailButton.scss create mode 100644 src/components/admin/TestEmailButton.tsx diff --git a/docs/anleitungen/TODO.md b/docs/anleitungen/TODO.md index 51378b6..e0e59f5 100644 --- a/docs/anleitungen/TODO.md +++ b/docs/anleitungen/TODO.md @@ -308,13 +308,14 @@ - [ ] Staging-Deployment #### Admin UX -- [ ] **Tenant-Wechsel UI** - - [ ] Dropdown in Admin-Leiste für schnellen Tenant-Wechsel - - [ ] Tenant-Kontext in Breadcrumbs anzeigen -- [ ] **Email-Konfiguration UX** - - [ ] Formularvalidierung für SMTP-Settings - - [ ] Tooltips für SPF/DKIM-Hinweise - - [ ] "Test-Email senden" Button +- [x] **Tenant-Wechsel UI** (Erledigt: 08.12.2025) + - [x] Dropdown in Admin-Leiste für schnellen Tenant-Wechsel (Multi-Tenant Plugin integriert) + - [x] Tenant-Kontext in Breadcrumbs anzeigen (Custom TenantBreadcrumb Komponente) + - [x] Deutsche Übersetzungen für Tenant-Selector hinzugefügt +- [x] **Email-Konfiguration UX** (Erledigt: 08.12.2025) + - [x] Formularvalidierung für SMTP-Settings (Host-Format, Port-Bereich, Pflichtfelder) + - [x] Tooltips für SPF/DKIM-Hinweise (aufklappbare Info-Komponente mit Beispielen) + - [x] "Test-Email senden" Button (Custom UI-Komponente + API-Endpoint) - [ ] **Tenant Self-Service** - [ ] API für Tenant-Admins zum Testen der SMTP-Settings - [ ] Email-Logs Einsicht für eigenen Tenant @@ -437,4 +438,4 @@ --- -*Letzte Aktualisierung: 08.12.2025 (Security Test Suite implementiert)* +*Letzte Aktualisierung: 08.12.2025 (Email-Konfiguration UX implementiert)* diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 4c05c6a..be43dbe 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -1,4 +1,6 @@ import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client' +import { EmailDeliverabilityInfo as EmailDeliverabilityInfo_2d3125262d0174dacf5e33938b9b89de } from '@/components/admin/EmailDeliverabilityInfo' +import { TestEmailButton as TestEmailButton_99635bc14de407531576022cd79284db } from '@/components/admin/TestEmailButton' import { WatchTenantCollection as WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client' import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' @@ -23,11 +25,14 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93 import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { TenantBreadcrumb as TenantBreadcrumb_565056ebfdbcabea98e506f7bdcb85b3 } from '@/components/admin/TenantBreadcrumb' import { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc' import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc' export const importMap = { "@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a, + "@/components/admin/EmailDeliverabilityInfo#EmailDeliverabilityInfo": EmailDeliverabilityInfo_2d3125262d0174dacf5e33938b9b89de, + "@/components/admin/TestEmailButton#TestEmailButton": TestEmailButton_99635bc14de407531576022cd79284db, "@payloadcms/plugin-multi-tenant/client#WatchTenantCollection": WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, @@ -52,6 +57,7 @@ export const importMap = { "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@/components/admin/TenantBreadcrumb#TenantBreadcrumb": TenantBreadcrumb_565056ebfdbcabea98e506f7bdcb85b3, "@payloadcms/plugin-multi-tenant/rsc#TenantSelector": TenantSelector_d6d5f193a167989e2ee7d14202901e62, "@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } diff --git a/src/app/(payload)/api/test-email/route.ts b/src/app/(payload)/api/test-email/route.ts new file mode 100644 index 0000000..aa33703 --- /dev/null +++ b/src/app/(payload)/api/test-email/route.ts @@ -0,0 +1,223 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import configPromise from '@payload-config' +import { sendTestEmail } from '@/lib/email/tenant-email-service' +import { + validateCsrf, + createSafeLogger, + emailLimiter, + rateLimitHeaders, + validateIpAccess, +} from '@/lib/security' + +const RATE_LIMIT_MAX = 10 +const logger = createSafeLogger('API:TestEmail') + +interface UserWithTenants { + id: number + email?: string + isSuperAdmin?: boolean + tenants?: Array<{ + tenant: { id: number } | number + }> +} + +/** + * Prüft ob User Zugriff auf den angegebenen Tenant hat + * Normalisiert tenant-Objekte korrekt (können { id: number } oder number sein) + */ +function userHasAccessToTenant(user: UserWithTenants, tenantId: number): boolean { + // Super Admins haben Zugriff auf alle Tenants + if (user.isSuperAdmin) { + return true + } + + // Prüfe ob User dem Tenant zugeordnet ist + if (!user.tenants || user.tenants.length === 0) { + return false + } + + return user.tenants.some((t) => { + // Normalisiere: tenant kann ein Objekt { id: number } oder direkt eine number sein + const userTenantId = typeof t.tenant === 'object' && t.tenant !== null + ? t.tenant.id + : t.tenant + return userTenantId === tenantId + }) +} + +/** + * POST /api/test-email + * + * Sendet eine Test-E-Mail um die SMTP-Konfiguration eines Tenants zu verifizieren. + * + * Request Body: + * - tenantId: number | string - ID des Tenants + * - recipientEmail: string - E-Mail-Adresse des Empfängers + * + * Security: + * - IP-Allowlist (optional via SEND_EMAIL_ALLOWED_IPS env) + * - CSRF-Schutz (Double Submit Cookie) + * - Authentifizierung erforderlich + * - Tenant-Zugriffskontrolle + * - Rate-Limiting (10/min pro User) + */ +export async function POST(req: NextRequest) { + try { + // IP-Allowlist prüfen (falls konfiguriert) - gleiche Regeln wie /api/send-email + const ipCheck = validateIpAccess(req, 'sendEmail') + if (!ipCheck.allowed) { + logger.warn(`IP blocked: ${ipCheck.ip}`, { reason: ipCheck.reason }) + return NextResponse.json( + { success: false, error: 'Access denied' }, + { status: 403 }, + ) + } + + // CSRF-Schutz für Browser-basierte Requests + const csrfResult = validateCsrf(req) + if (!csrfResult.valid) { + logger.warn('CSRF validation failed', { reason: csrfResult.reason }) + return NextResponse.json( + { success: false, error: 'CSRF validation failed' }, + { status: 403 }, + ) + } + + const payload = await getPayload({ config: configPromise }) + + // User aus Session prüfen + const { user } = await payload.auth({ headers: req.headers }) + + if (!user) { + return NextResponse.json( + { success: false, error: 'Nicht authentifiziert' }, + { status: 401 }, + ) + } + + const typedUser = user as UserWithTenants + + // Rate Limiting prüfen + const rateLimit = await emailLimiter.check(String(typedUser.id)) + if (!rateLimit.allowed) { + logger.warn('Rate limit exceeded', { userId: typedUser.id }) + return NextResponse.json( + { + success: false, + error: 'Rate limit exceeded', + message: `Maximum ${RATE_LIMIT_MAX} Test-E-Mails pro Minute. Versuchen Sie es in ${rateLimit.retryAfter || 60} Sekunden erneut.`, + }, + { + status: 429, + headers: rateLimitHeaders(rateLimit, RATE_LIMIT_MAX), + }, + ) + } + + // Request Body parsen + const body = await req.json() + const { tenantId, recipientEmail } = body + + if (!tenantId) { + return NextResponse.json( + { success: false, error: 'tenantId erforderlich' }, + { status: 400 }, + ) + } + + const numericTenantId = Number(tenantId) + if (isNaN(numericTenantId)) { + return NextResponse.json( + { success: false, error: 'tenantId muss eine Zahl sein' }, + { status: 400 }, + ) + } + + if (!recipientEmail) { + return NextResponse.json( + { success: false, error: 'recipientEmail erforderlich' }, + { status: 400 }, + ) + } + + // E-Mail-Format validieren + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(recipientEmail)) { + return NextResponse.json( + { success: false, error: 'Ungültiges E-Mail-Format' }, + { status: 400 }, + ) + } + + // Zugriffskontrolle: User muss Zugriff auf den Tenant haben + if (!userHasAccessToTenant(typedUser, numericTenantId)) { + logger.warn('Access denied to tenant', { + userId: typedUser.id, + tenantId: numericTenantId, + }) + return NextResponse.json( + { success: false, error: 'Kein Zugriff auf diesen Tenant' }, + { status: 403 }, + ) + } + + // Tenant prüfen - existiert er? + const tenant = await payload.findByID({ + collection: 'tenants', + id: numericTenantId, + depth: 0, + }) + + if (!tenant) { + return NextResponse.json( + { success: false, error: 'Tenant nicht gefunden' }, + { status: 404 }, + ) + } + + // Rate Limit Headers + const rlHeaders = rateLimitHeaders(rateLimit, RATE_LIMIT_MAX) + + // Test-E-Mail senden + const result = await sendTestEmail(payload, numericTenantId, recipientEmail) + + if (result.success) { + logger.info('Test email sent successfully', { + tenantId: numericTenantId, + recipient: recipientEmail, + messageId: result.messageId, + }) + return NextResponse.json( + { + success: true, + message: `Test-E-Mail erfolgreich an ${recipientEmail} gesendet`, + messageId: result.messageId, + logId: result.logId, + }, + { headers: rlHeaders }, + ) + } else { + logger.error('Test email failed', { + tenantId: numericTenantId, + error: result.error, + }) + return NextResponse.json( + { + success: false, + error: result.error || 'Fehler beim Senden der Test-E-Mail', + }, + { status: 500, headers: rlHeaders }, + ) + } + } catch (error) { + logger.error('test-email error', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Interner Server-Fehler', + }, + { status: 500 }, + ) + } +} diff --git a/src/collections/Tenants.ts b/src/collections/Tenants.ts index 5312058..98cd094 100644 --- a/src/collections/Tenants.ts +++ b/src/collections/Tenants.ts @@ -1,7 +1,39 @@ -import type { CollectionConfig } from 'payload' +import type { CollectionConfig, FieldHook } from 'payload' import { invalidateEmailCacheHook } from '../hooks/invalidateEmailCache' import { auditTenantAfterChange, auditTenantAfterDelete } from '../hooks/auditTenantChanges' +/** + * Validiert SMTP Host Format + */ +const validateSmtpHost: FieldHook = ({ value, siblingData }) => { + // Nur validieren wenn useCustomSmtp aktiv ist und ein Wert vorhanden + const emailData = siblingData as { useCustomSmtp?: boolean } + if (!emailData?.useCustomSmtp) return value + if (!value) return value + + // Basis-Validierung: Keine Protokoll-Präfixe erlaubt + if (value.includes('://')) { + throw new Error('SMTP Host ohne Protokoll angeben (z.B. smtp.example.com)') + } + + return value +} + +/** + * Validiert SMTP Port + */ +const validateSmtpPort: FieldHook = ({ value, siblingData }) => { + const emailData = siblingData as { useCustomSmtp?: boolean } + if (!emailData?.useCustomSmtp) return value + + const port = Number(value) + if (port && (port < 1 || port > 65535)) { + throw new Error('Port muss zwischen 1 und 65535 liegen') + } + + return value +} + export const Tenants: CollectionConfig = { slug: 'tenants', admin: { @@ -53,6 +85,8 @@ export const Tenants: CollectionConfig = { admin: { placeholder: 'info@domain.de', width: '50%', + description: + 'Tipp: Verwenden Sie eine E-Mail-Adresse der Domain, für die SPF/DKIM konfiguriert ist.', }, }, { @@ -74,11 +108,25 @@ export const Tenants: CollectionConfig = { placeholder: 'kontakt@domain.de (optional)', }, }, + // SPF/DKIM Info-Block + { + name: 'emailDeliverabilityInfo', + type: 'ui', + admin: { + components: { + Field: '@/components/admin/EmailDeliverabilityInfo#EmailDeliverabilityInfo', + }, + }, + }, { name: 'useCustomSmtp', type: 'checkbox', label: 'Eigenen SMTP-Server verwenden', defaultValue: false, + admin: { + description: + 'Aktivieren Sie diese Option, um einen eigenen SMTP-Server statt der globalen Einstellungen zu verwenden.', + }, }, { name: 'smtp', @@ -86,6 +134,8 @@ export const Tenants: CollectionConfig = { label: 'SMTP Einstellungen', admin: { condition: (_, siblingData) => siblingData?.useCustomSmtp, + description: + 'Hinweis: Stellen Sie sicher, dass SPF- und DKIM-Einträge für Ihre Domain konfiguriert sind, um eine optimale E-Mail-Zustellung zu gewährleisten.', }, fields: [ { @@ -95,9 +145,21 @@ export const Tenants: CollectionConfig = { name: 'host', type: 'text', label: 'SMTP Host', + required: true, admin: { placeholder: 'smtp.example.com', width: '50%', + description: 'Hostname ohne Protokoll (z.B. smtp.gmail.com)', + }, + hooks: { + beforeValidate: [validateSmtpHost], + }, + validate: (value, { siblingData }) => { + const emailData = siblingData as { useCustomSmtp?: boolean } + if (emailData?.useCustomSmtp && !value) { + return 'SMTP Host ist erforderlich' + } + return true }, }, { @@ -107,15 +169,22 @@ export const Tenants: CollectionConfig = { defaultValue: 587, admin: { width: '25%', + description: '587 (STARTTLS) oder 465 (SSL)', }, + hooks: { + beforeValidate: [validateSmtpPort], + }, + min: 1, + max: 65535, }, { name: 'secure', type: 'checkbox', - label: 'SSL/TLS', + label: 'SSL/TLS (Port 465)', defaultValue: false, admin: { width: '25%', + description: 'Für Port 465 aktivieren', }, }, ], @@ -127,8 +196,18 @@ export const Tenants: CollectionConfig = { name: 'user', type: 'text', label: 'SMTP Benutzername', + required: true, admin: { width: '50%', + description: 'Meist die E-Mail-Adresse', + }, + validate: (value, { siblingData }) => { + const smtpData = siblingData as { host?: string } + // Nur validieren wenn host gesetzt ist (d.h. SMTP aktiv) + if (smtpData?.host && !value) { + return 'SMTP Benutzername ist erforderlich' + } + return true }, }, { @@ -137,6 +216,7 @@ export const Tenants: CollectionConfig = { label: 'SMTP Passwort', admin: { width: '50%', + description: 'Leer lassen um bestehendes Passwort zu behalten', }, access: { read: () => false, // Passwort nie in API-Response @@ -155,6 +235,16 @@ export const Tenants: CollectionConfig = { }, ], }, + // Test-Email Button + { + name: 'testEmailButton', + type: 'ui', + admin: { + components: { + Field: '@/components/admin/TestEmailButton#TestEmailButton', + }, + }, + }, ], }, ], diff --git a/src/components/admin/EmailDeliverabilityInfo.scss b/src/components/admin/EmailDeliverabilityInfo.scss new file mode 100644 index 0000000..6d68614 --- /dev/null +++ b/src/components/admin/EmailDeliverabilityInfo.scss @@ -0,0 +1,140 @@ +.email-deliverability-info { + margin: 1rem 0; + border: 1px solid var(--theme-elevation-150); + border-radius: var(--style-radius-s, 4px); + background-color: var(--theme-elevation-50); + + &__toggle { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.75rem 1rem; + border: none; + background: none; + cursor: pointer; + text-align: left; + font-size: 0.875rem; + color: var(--theme-elevation-800); + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--theme-elevation-100); + } + } + + &__icon { + flex-shrink: 0; + transition: transform 0.2s ease; + color: var(--theme-elevation-500); + + &--expanded { + transform: rotate(90deg); + } + } + + &__title { + font-weight: 500; + } + + &__content { + padding: 0 1rem 1rem 1rem; + border-top: 1px solid var(--theme-elevation-150); + } + + &__section { + margin-top: 1rem; + + h4 { + margin: 0 0 0.5rem 0; + font-size: 0.8125rem; + font-weight: 600; + color: var(--theme-elevation-800); + } + + p { + margin: 0 0 0.5rem 0; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--theme-elevation-600); + } + } + + &__code { + display: block; + padding: 0.5rem 0.75rem; + margin: 0.5rem 0; + font-family: var(--font-mono); + font-size: 0.75rem; + background-color: var(--theme-elevation-100); + border-radius: var(--style-radius-s, 4px); + overflow-x: auto; + white-space: nowrap; + color: var(--theme-elevation-800); + } + + &__hint { + font-size: 0.75rem !important; + font-style: italic; + color: var(--theme-elevation-500) !important; + + em { + font-style: normal; + background-color: var(--theme-elevation-100); + padding: 0.125rem 0.25rem; + border-radius: 2px; + } + } + + &__tips { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px dashed var(--theme-elevation-200); + + h4 { + margin: 0 0 0.5rem 0; + font-size: 0.8125rem; + font-weight: 600; + color: var(--theme-elevation-800); + } + + ul { + margin: 0; + padding-left: 1.25rem; + font-size: 0.8125rem; + line-height: 1.6; + color: var(--theme-elevation-600); + } + + li { + margin-bottom: 0.25rem; + } + + a { + color: var(--theme-success-500); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +} + +// Dark mode support +:global(.dark) .email-deliverability-info { + background-color: var(--theme-elevation-100); + border-color: var(--theme-elevation-200); + + &__content { + border-top-color: var(--theme-elevation-200); + } + + &__code { + background-color: var(--theme-elevation-150); + } + + &__tips { + border-top-color: var(--theme-elevation-250); + } +} diff --git a/src/components/admin/EmailDeliverabilityInfo.tsx b/src/components/admin/EmailDeliverabilityInfo.tsx new file mode 100644 index 0000000..d0af9bf --- /dev/null +++ b/src/components/admin/EmailDeliverabilityInfo.tsx @@ -0,0 +1,106 @@ +'use client' + +import React, { useState } from 'react' +import './EmailDeliverabilityInfo.scss' + +/** + * Info-Komponente mit SPF/DKIM Hinweisen für E-Mail-Zustellbarkeit + */ +export const EmailDeliverabilityInfo: React.FC = () => { + const [isExpanded, setIsExpanded] = useState(false) + + return ( +
+ + + {isExpanded && ( +
+
+

SPF (Sender Policy Framework)

+

+ SPF definiert, welche Server E-Mails für Ihre Domain senden dürfen. Fügen Sie einen + TXT-Eintrag in Ihren DNS-Einstellungen hinzu: +

+ + v=spf1 include:_spf.ihremailserver.de ~all + +

+ Ersetzen Sie _spf.ihremailserver.de mit dem SPF-Include Ihres E-Mail-Providers. +

+
+ +
+

DKIM (DomainKeys Identified Mail)

+

+ DKIM signiert ausgehende E-Mails kryptografisch. Die Einrichtung erfolgt über Ihren + E-Mail-Provider. Typischerweise erhalten Sie einen TXT-Eintrag wie: +

+ + selector._domainkey.ihredomain.de → v=DKIM1; k=rsa; p=MIGf... + +
+ +
+

DMARC (Domain-based Message Authentication)

+

+ DMARC kombiniert SPF und DKIM und definiert, wie mit fehlgeschlagenen Prüfungen + umgegangen werden soll: +

+ + _dmarc.ihredomain.de → v=DMARC1; p=quarantine; rua=mailto:dmarc@ihredomain.de + +
+ +
+

Tipps für bessere Zustellbarkeit

+
    +
  • Verwenden Sie immer eine Absender-Adresse der konfigurierten Domain
  • +
  • Testen Sie die Konfiguration mit dem "Test-E-Mail senden" Button
  • +
  • + Prüfen Sie Ihre DNS-Einträge mit Tools wie{' '} + + MXToolbox + {' '} + oder{' '} + + Mail-Tester + +
  • +
  • Bei Problemen kontaktieren Sie Ihren E-Mail-Provider für DKIM-Keys
  • +
+
+
+ )} +
+ ) +} + +export default EmailDeliverabilityInfo diff --git a/src/components/admin/TenantBreadcrumb.scss b/src/components/admin/TenantBreadcrumb.scss new file mode 100644 index 0000000..c11eb8c --- /dev/null +++ b/src/components/admin/TenantBreadcrumb.scss @@ -0,0 +1,37 @@ +.tenant-breadcrumb { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + margin: 0 0.75rem; + border-radius: var(--style-radius-s, 4px); + background-color: var(--theme-elevation-50); + border: 1px solid var(--theme-elevation-100); + font-size: 0.8125rem; + line-height: 1; + white-space: nowrap; + + &__label { + color: var(--theme-elevation-500); + font-weight: 400; + } + + &__value { + color: var(--theme-elevation-800); + font-weight: 600; + } +} + +// Dark mode support +:global(.dark) .tenant-breadcrumb { + background-color: var(--theme-elevation-100); + border-color: var(--theme-elevation-200); + + .tenant-breadcrumb__label { + color: var(--theme-elevation-400); + } + + .tenant-breadcrumb__value { + color: var(--theme-elevation-700); + } +} diff --git a/src/components/admin/TenantBreadcrumb.tsx b/src/components/admin/TenantBreadcrumb.tsx new file mode 100644 index 0000000..efbfe61 --- /dev/null +++ b/src/components/admin/TenantBreadcrumb.tsx @@ -0,0 +1,30 @@ +'use client' + +import React from 'react' +import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client' +import './TenantBreadcrumb.scss' + +/** + * Custom Breadcrumb-Komponente die den aktuellen Tenant-Kontext anzeigt. + * Wird in der Admin-Header-Leiste eingefügt. + */ +export const TenantBreadcrumb: React.FC = () => { + const { selectedTenantID, options } = useTenantSelection() + + // Finde den aktuellen Tenant aus den Optionen + const currentTenant = options?.find((opt) => opt.value === selectedTenantID) + + // Zeige nichts wenn kein Tenant ausgewählt oder nur ein Tenant verfügbar + if (!selectedTenantID || !currentTenant || options.length <= 1) { + return null + } + + return ( +
+ Aktiver Tenant: + {currentTenant.label} +
+ ) +} + +export default TenantBreadcrumb diff --git a/src/components/admin/TestEmailButton.scss b/src/components/admin/TestEmailButton.scss new file mode 100644 index 0000000..40c34b1 --- /dev/null +++ b/src/components/admin/TestEmailButton.scss @@ -0,0 +1,173 @@ +.test-email-button { + margin: 1.5rem 0 0.5rem 0; + padding: 1rem; + border: 1px solid var(--theme-elevation-150); + border-radius: var(--style-radius-s, 4px); + background-color: var(--theme-elevation-50); + + &__header { + margin-bottom: 1rem; + } + + &__title { + margin: 0 0 0.25rem 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--theme-elevation-800); + } + + &__description { + margin: 0; + font-size: 0.8125rem; + color: var(--theme-elevation-500); + } + + &__form { + display: flex; + gap: 0.75rem; + align-items: flex-end; + } + + &__input-group { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + &__label { + font-size: 0.75rem; + font-weight: 500; + color: var(--theme-elevation-600); + } + + &__input { + padding: 0.5rem 0.75rem; + border: 1px solid var(--theme-elevation-200); + border-radius: var(--style-radius-s, 4px); + font-size: 0.875rem; + background-color: var(--theme-input-background); + color: var(--theme-elevation-800); + transition: border-color 0.15s ease; + + &:focus { + outline: none; + border-color: var(--theme-success-500); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &::placeholder { + color: var(--theme-elevation-400); + } + } + + &__button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border: none; + border-radius: var(--style-radius-s, 4px); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + background-color: var(--theme-success-500); + color: white; + + &:hover:not(:disabled) { + background-color: var(--theme-success-600); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &--loading { + background-color: var(--theme-elevation-400); + } + } + + &__icon { + flex-shrink: 0; + } + + &__spinner { + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: test-email-spin 0.8s linear infinite; + } + + &__result { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-top: 1rem; + padding: 0.75rem; + border-radius: var(--style-radius-s, 4px); + font-size: 0.8125rem; + + &--success { + background-color: rgba(var(--theme-success-500-rgb), 0.1); + border: 1px solid var(--theme-success-500); + color: var(--theme-success-600); + } + + &--error { + background-color: rgba(var(--theme-error-500-rgb), 0.1); + border: 1px solid var(--theme-error-500); + color: var(--theme-error-600); + } + } + + &__result-icon { + flex-shrink: 0; + margin-top: 0.125rem; + } + + &__result-message { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + &__result-id { + font-size: 0.75rem; + opacity: 0.8; + font-family: var(--font-mono); + } + + &__warning { + margin: 0.75rem 0 0 0; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + color: var(--theme-warning-600); + background-color: rgba(var(--theme-warning-500-rgb), 0.1); + border-radius: var(--style-radius-s, 4px); + } +} + +@keyframes test-email-spin { + to { + transform: rotate(360deg); + } +} + +// Dark mode support +:global(.dark) .test-email-button { + background-color: var(--theme-elevation-100); + border-color: var(--theme-elevation-200); + + &__input { + border-color: var(--theme-elevation-250); + } +} diff --git a/src/components/admin/TestEmailButton.tsx b/src/components/admin/TestEmailButton.tsx new file mode 100644 index 0000000..b172520 --- /dev/null +++ b/src/components/admin/TestEmailButton.tsx @@ -0,0 +1,257 @@ +'use client' + +import React, { useState, useCallback, useEffect } from 'react' +import { useDocumentInfo, useAuth } from '@payloadcms/ui' +import './TestEmailButton.scss' + +type TestStatus = 'idle' | 'loading' | 'success' | 'error' + +interface TestResult { + success: boolean + message?: string + messageId?: string +} + +// CSRF Constants (muss mit src/lib/security/csrf.ts übereinstimmen) +const CSRF_COOKIE_NAME = 'csrf-token' +const CSRF_HEADER_NAME = 'X-CSRF-Token' + +/** + * Liest einen Cookie-Wert + */ +function getCookie(name: string): string | null { + if (typeof document === 'undefined') return null + const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`)) + return match ? decodeURIComponent(match[2]) : null +} + +/** + * Holt ein CSRF-Token vom Server und setzt es als Cookie + */ +async function fetchCsrfToken(): Promise { + try { + const response = await fetch('/api/csrf-token', { + method: 'GET', + credentials: 'include', + }) + + if (response.ok) { + const data = await response.json() + return data.token || null + } + } catch (error) { + console.error('Failed to fetch CSRF token:', error) + } + return null +} + +/** + * Button zum Senden einer Test-E-Mail für SMTP-Konfigurationstest + * Implementiert CSRF-Schutz via Double Submit Cookie + */ +export const TestEmailButton: React.FC = () => { + const { id: documentId } = useDocumentInfo() + const { user } = useAuth() + const [status, setStatus] = useState('idle') + const [result, setResult] = useState(null) + const [recipientEmail, setRecipientEmail] = useState('') + const [csrfToken, setCsrfToken] = useState(null) + + // Standardmäßig die E-Mail des aktuellen Users verwenden + const defaultEmail = (user as { email?: string })?.email || '' + + // CSRF-Token beim Mount laden + useEffect(() => { + const initCsrf = async () => { + // Erst versuchen aus Cookie zu lesen + let token = getCookie(CSRF_COOKIE_NAME) + + // Falls kein Cookie vorhanden, Token vom Server holen + if (!token) { + token = await fetchCsrfToken() + } + + setCsrfToken(token) + } + + initCsrf() + }, []) + + const handleSendTest = useCallback(async () => { + const emailToUse = recipientEmail || defaultEmail + + if (!emailToUse) { + setResult({ success: false, message: 'Bitte eine Empfänger-E-Mail angeben' }) + setStatus('error') + return + } + + if (!documentId) { + setResult({ success: false, message: 'Tenant muss erst gespeichert werden' }) + setStatus('error') + return + } + + setStatus('loading') + setResult(null) + + try { + // CSRF-Token aus State oder frisch aus Cookie lesen + let token = csrfToken || getCookie(CSRF_COOKIE_NAME) + + // Falls immer noch kein Token, neues holen + if (!token) { + token = await fetchCsrfToken() + if (token) { + setCsrfToken(token) + } + } + + if (!token) { + setResult({ success: false, message: 'CSRF-Token konnte nicht geladen werden. Bitte Seite neu laden.' }) + setStatus('error') + return + } + + const response = await fetch('/api/test-email', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [CSRF_HEADER_NAME]: token, + }, + credentials: 'include', + body: JSON.stringify({ + tenantId: documentId, + recipientEmail: emailToUse, + }), + }) + + const data = await response.json() + + if (response.ok && data.success) { + setResult({ + success: true, + message: 'Test-E-Mail erfolgreich gesendet!', + messageId: data.messageId, + }) + setStatus('success') + } else { + // Bei CSRF-Fehler Token neu laden + if (response.status === 403 && data.error?.includes('CSRF')) { + const newToken = await fetchCsrfToken() + if (newToken) { + setCsrfToken(newToken) + } + setResult({ + success: false, + message: 'Sicherheitstoken abgelaufen. Bitte erneut versuchen.', + }) + } else { + setResult({ + success: false, + message: data.error || data.message || 'Fehler beim Senden der Test-E-Mail', + }) + } + setStatus('error') + } + } catch (error) { + setResult({ + success: false, + message: error instanceof Error ? error.message : 'Netzwerkfehler', + }) + setStatus('error') + } + }, [documentId, recipientEmail, defaultEmail, csrfToken]) + + return ( +
+
+

SMTP-Konfiguration testen

+

+ Senden Sie eine Test-E-Mail um zu prüfen, ob die SMTP-Einstellungen korrekt sind. +

+
+ +
+
+ + setRecipientEmail(e.target.value)} + disabled={status === 'loading'} + /> +
+ + +
+ + {result && ( +
+ + {result.success ? ( + + + + ) : ( + + + + + + )} + + + {result.message} + {result.messageId && ( + ID: {result.messageId} + )} + +
+ )} + + {!documentId && ( +

+ Speichern Sie den Tenant zuerst, um eine Test-E-Mail senden zu können. +

+ )} +
+ ) +} + +export default TestEmailButton diff --git a/src/payload.config.ts b/src/payload.config.ts index e00ed30..e71bb9d 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -58,6 +58,10 @@ export default buildConfig({ serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de', admin: { user: Users.slug, + components: { + // Tenant-Kontext in der Admin-Header-Leiste anzeigen + afterNavLinks: ['@/components/admin/TenantBreadcrumb#TenantBreadcrumb'], + }, }, // Multi-Tenant Email Adapter email: multiTenantEmailAdapter, @@ -165,6 +169,23 @@ export default buildConfig({ // Super Admins haben Zugriff auf alle Tenants userHasAccessToAllTenants: (user) => Boolean(user?.isSuperAdmin), debug: true, + // Deutsche Übersetzungen für den Tenant-Selector + i18n: { + translations: { + de: { + 'nav-tenantSelector-label': 'Nach Tenant filtern', + 'assign-tenant-button-label': 'Tenant zuweisen', + 'assign-tenant-modal-title': '"{{title}}" zuweisen', + 'field-assignedTenant-label': 'Zugewiesener Tenant', + }, + en: { + 'nav-tenantSelector-label': 'Filter by Tenant', + 'assign-tenant-button-label': 'Assign Tenant', + 'assign-tenant-modal-title': 'Assign "{{title}}"', + 'field-assignedTenant-label': 'Assigned Tenant', + }, + }, + }, }), seoPlugin({ collections: [], diff --git a/tests/int/security-api.int.spec.ts b/tests/int/security-api.int.spec.ts index a4c5927..8d118d1 100644 --- a/tests/int/security-api.int.spec.ts +++ b/tests/int/security-api.int.spec.ts @@ -515,4 +515,676 @@ describe('Security API Integration', () => { expect(response.status).toBe(403) }) }) + + // ============================================================================ + // Test Email Endpoint Security Tests + // ============================================================================ + + describe('Test Email Endpoint (/api/test-email)', () => { + describe('CSRF Protection', () => { + it('requires CSRF token for browser requests', async () => { + vi.resetModules() + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 1, + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toContain('CSRF') + }) + + it('accepts valid CSRF token', async () => { + vi.resetModules() + + // Mock authenticated user + vi.doMock('payload', () => ({ + getPayload: vi.fn().mockResolvedValue({ + auth: vi.fn().mockResolvedValue({ + user: { + id: 1, + email: 'admin@test.com', + isSuperAdmin: true, + tenants: [], + }, + }), + findByID: vi.fn().mockResolvedValue({ id: 1, name: 'Test Tenant' }), + }), + })) + + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 1, + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + // Should pass CSRF check (may fail on email send, but not 403) + expect(response.status).not.toBe(403) + }) + + it('rejects expired CSRF tokens', async () => { + vi.resetModules() + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const expiredToken = generateExpiredCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': expiredToken, + }, + cookies: { + 'csrf-token': expiredToken, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 1, + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + expect(response.status).toBe(403) + }) + }) + + describe('IP Blocking', () => { + it('blocks requests from blocked IPs', async () => { + vi.stubEnv('BLOCKED_IPS', '192.168.200.100') + vi.resetModules() + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: '192.168.200.100', + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 1, + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toContain('Access denied') + }) + + it('respects SEND_EMAIL_ALLOWED_IPS allowlist', async () => { + vi.stubEnv('SEND_EMAIL_ALLOWED_IPS', '10.0.0.1') + vi.resetModules() + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + // Request from IP not in allowlist + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: '192.168.1.100', // Not in allowlist + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 1, + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toContain('Access denied') + }) + }) + + describe('Authentication', () => { + it('requires authentication', async () => { + vi.resetModules() + + // Mock unauthenticated user + vi.doMock('payload', () => ({ + getPayload: vi.fn().mockResolvedValue({ + auth: vi.fn().mockResolvedValue({ user: null }), + }), + })) + + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 1, + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body.error).toContain('authentifiziert') + }) + }) + + describe('Tenant Access Control', () => { + it('allows super admin access to any tenant', async () => { + vi.resetModules() + + vi.doMock('payload', () => ({ + getPayload: vi.fn().mockResolvedValue({ + auth: vi.fn().mockResolvedValue({ + user: { + id: 1, + email: 'superadmin@test.com', + isSuperAdmin: true, + tenants: [], + }, + }), + findByID: vi.fn().mockResolvedValue({ id: 99, name: 'Any Tenant' }), + }), + })) + + // Mock sendTestEmail + vi.doMock('@/lib/email/tenant-email-service', () => ({ + sendTestEmail: vi.fn().mockResolvedValue({ + success: true, + messageId: 'test-123', + logId: 1, + }), + })) + + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 99, + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + // Super admin should have access + expect(response.status).not.toBe(403) + }) + + it('denies access to tenants user is not assigned to', async () => { + vi.resetModules() + + vi.doMock('payload', () => ({ + getPayload: vi.fn().mockResolvedValue({ + auth: vi.fn().mockResolvedValue({ + user: { + id: 2, + email: 'user@test.com', + isSuperAdmin: false, + tenants: [{ tenant: { id: 1 } }], // Only has access to tenant 1 + }, + }), + findByID: vi.fn().mockResolvedValue({ id: 99, name: 'Other Tenant' }), + }), + })) + + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 99, // Different tenant + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toContain('Zugriff') + }) + + it('normalizes tenant object format { tenant: { id } }', async () => { + vi.resetModules() + + vi.doMock('payload', () => ({ + getPayload: vi.fn().mockResolvedValue({ + auth: vi.fn().mockResolvedValue({ + user: { + id: 2, + email: 'user@test.com', + isSuperAdmin: false, + // Multi-tenant plugin format: { tenant: { id: number } } + tenants: [{ tenant: { id: 5 } }], + }, + }), + findByID: vi.fn().mockResolvedValue({ id: 5, name: 'User Tenant' }), + }), + })) + + vi.doMock('@/lib/email/tenant-email-service', () => ({ + sendTestEmail: vi.fn().mockResolvedValue({ + success: true, + messageId: 'test-456', + logId: 2, + }), + })) + + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 5, + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + // Should have access (tenant ID 5 matches) + expect(response.status).not.toBe(403) + }) + + it('normalizes tenant primitive format { tenant: number }', async () => { + vi.resetModules() + + vi.doMock('payload', () => ({ + getPayload: vi.fn().mockResolvedValue({ + auth: vi.fn().mockResolvedValue({ + user: { + id: 2, + email: 'user@test.com', + isSuperAdmin: false, + // Alternative format: { tenant: number } + tenants: [{ tenant: 7 }], + }, + }), + findByID: vi.fn().mockResolvedValue({ id: 7, name: 'User Tenant' }), + }), + })) + + vi.doMock('@/lib/email/tenant-email-service', () => ({ + sendTestEmail: vi.fn().mockResolvedValue({ + success: true, + messageId: 'test-789', + logId: 3, + }), + })) + + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 7, + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + // Should have access (tenant ID 7 matches) + expect(response.status).not.toBe(403) + }) + }) + + describe('Input Validation', () => { + it('requires tenantId', async () => { + vi.resetModules() + + vi.doMock('payload', () => ({ + getPayload: vi.fn().mockResolvedValue({ + auth: vi.fn().mockResolvedValue({ + user: { id: 1, isSuperAdmin: true }, + }), + }), + })) + + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + recipientEmail: 'test@example.com', + // Missing tenantId + }), + }) + + const response = await POST(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('tenantId') + }) + + it('requires recipientEmail', async () => { + vi.resetModules() + + vi.doMock('payload', () => ({ + getPayload: vi.fn().mockResolvedValue({ + auth: vi.fn().mockResolvedValue({ + user: { id: 1, isSuperAdmin: true }, + }), + }), + })) + + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 1, + // Missing recipientEmail + }), + }) + + const response = await POST(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('recipientEmail') + }) + + it('validates email format', async () => { + vi.resetModules() + + vi.doMock('payload', () => ({ + getPayload: vi.fn().mockResolvedValue({ + auth: vi.fn().mockResolvedValue({ + user: { id: 1, isSuperAdmin: true }, + }), + }), + })) + + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 1, + recipientEmail: 'invalid-email', // Invalid format + }), + }) + + const response = await POST(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('E-Mail') + }) + + it('validates tenantId is numeric', async () => { + vi.resetModules() + + vi.doMock('payload', () => ({ + getPayload: vi.fn().mockResolvedValue({ + auth: vi.fn().mockResolvedValue({ + user: { id: 1, isSuperAdmin: true }, + }), + }), + })) + + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 'not-a-number', + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('Zahl') + }) + }) + + describe('Rate Limiting', () => { + it('includes rate limit headers', async () => { + vi.resetModules() + + vi.doMock('payload', () => ({ + getPayload: vi.fn().mockResolvedValue({ + auth: vi.fn().mockResolvedValue({ + user: { id: 1, isSuperAdmin: true }, + }), + findByID: vi.fn().mockResolvedValue({ id: 1, name: 'Test Tenant' }), + }), + })) + + vi.doMock('@/lib/email/tenant-email-service', () => ({ + sendTestEmail: vi.fn().mockResolvedValue({ + success: true, + messageId: 'test-rate', + logId: 1, + }), + })) + + const { POST } = await import('@/app/(payload)/api/test-email/route') + + const token = generateTestCsrfToken('test-csrf-secret-for-testing') + const req = createMockRequest({ + method: 'POST', + url: 'https://test.example.com/api/test-email', + headers: { + 'content-type': 'application/json', + origin: 'https://test.example.com', + 'X-CSRF-Token': token, + }, + cookies: { + 'csrf-token': token, + }, + ip: randomIp(), + }) + + Object.defineProperty(req, 'json', { + value: vi.fn().mockResolvedValue({ + tenantId: 1, + recipientEmail: 'test@example.com', + }), + }) + + const response = await POST(req) + + // Should include rate limit headers on success + if (response.status === 200) { + expect(response.headers.has('X-RateLimit-Limit')).toBe(true) + expect(response.headers.has('X-RateLimit-Remaining')).toBe(true) + } + }) + }) + }) })