feat: admin UX improvements with tenant switcher and email config

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 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-08 16:33:39 +00:00
parent b5f319bc99
commit 53f26e7349
12 changed files with 1766 additions and 10 deletions

View file

@ -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)*

View file

@ -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
}

View file

@ -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 },
)
}
}

View file

@ -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',
},
},
},
],
},
],

View file

@ -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);
}
}

View file

@ -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 (
<div className="email-deliverability-info">
<button
type="button"
className="email-deliverability-info__toggle"
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
>
<svg
className={`email-deliverability-info__icon ${isExpanded ? 'email-deliverability-info__icon--expanded' : ''}`}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span className="email-deliverability-info__title">
Hinweise zur E-Mail-Zustellbarkeit (SPF/DKIM/DMARC)
</span>
</button>
{isExpanded && (
<div className="email-deliverability-info__content">
<div className="email-deliverability-info__section">
<h4>SPF (Sender Policy Framework)</h4>
<p>
SPF definiert, welche Server E-Mails für Ihre Domain senden dürfen. Fügen Sie einen
TXT-Eintrag in Ihren DNS-Einstellungen hinzu:
</p>
<code className="email-deliverability-info__code">
v=spf1 include:_spf.ihremailserver.de ~all
</code>
<p className="email-deliverability-info__hint">
Ersetzen Sie <em>_spf.ihremailserver.de</em> mit dem SPF-Include Ihres E-Mail-Providers.
</p>
</div>
<div className="email-deliverability-info__section">
<h4>DKIM (DomainKeys Identified Mail)</h4>
<p>
DKIM signiert ausgehende E-Mails kryptografisch. Die Einrichtung erfolgt über Ihren
E-Mail-Provider. Typischerweise erhalten Sie einen TXT-Eintrag wie:
</p>
<code className="email-deliverability-info__code">
selector._domainkey.ihredomain.de v=DKIM1; k=rsa; p=MIGf...
</code>
</div>
<div className="email-deliverability-info__section">
<h4>DMARC (Domain-based Message Authentication)</h4>
<p>
DMARC kombiniert SPF und DKIM und definiert, wie mit fehlgeschlagenen Prüfungen
umgegangen werden soll:
</p>
<code className="email-deliverability-info__code">
_dmarc.ihredomain.de v=DMARC1; p=quarantine; rua=mailto:dmarc@ihredomain.de
</code>
</div>
<div className="email-deliverability-info__tips">
<h4>Tipps für bessere Zustellbarkeit</h4>
<ul>
<li>Verwenden Sie immer eine Absender-Adresse der konfigurierten Domain</li>
<li>Testen Sie die Konfiguration mit dem &quot;Test-E-Mail senden&quot; Button</li>
<li>
Prüfen Sie Ihre DNS-Einträge mit Tools wie{' '}
<a
href="https://mxtoolbox.com/spf.aspx"
target="_blank"
rel="noopener noreferrer"
>
MXToolbox
</a>{' '}
oder{' '}
<a
href="https://www.mail-tester.com/"
target="_blank"
rel="noopener noreferrer"
>
Mail-Tester
</a>
</li>
<li>Bei Problemen kontaktieren Sie Ihren E-Mail-Provider für DKIM-Keys</li>
</ul>
</div>
</div>
)}
</div>
)
}
export default EmailDeliverabilityInfo

View file

@ -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);
}
}

View file

@ -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 (
<div className="tenant-breadcrumb">
<span className="tenant-breadcrumb__label">Aktiver Tenant:</span>
<span className="tenant-breadcrumb__value">{currentTenant.label}</span>
</div>
)
}
export default TenantBreadcrumb

View file

@ -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);
}
}

View file

@ -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<string | null> {
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<TestStatus>('idle')
const [result, setResult] = useState<TestResult | null>(null)
const [recipientEmail, setRecipientEmail] = useState('')
const [csrfToken, setCsrfToken] = useState<string | null>(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 (
<div className="test-email-button">
<div className="test-email-button__header">
<h4 className="test-email-button__title">SMTP-Konfiguration testen</h4>
<p className="test-email-button__description">
Senden Sie eine Test-E-Mail um zu prüfen, ob die SMTP-Einstellungen korrekt sind.
</p>
</div>
<div className="test-email-button__form">
<div className="test-email-button__input-group">
<label htmlFor="test-email-recipient" className="test-email-button__label">
Empfänger-E-Mail
</label>
<input
id="test-email-recipient"
type="email"
className="test-email-button__input"
placeholder={defaultEmail || 'empfaenger@example.com'}
value={recipientEmail}
onChange={(e) => setRecipientEmail(e.target.value)}
disabled={status === 'loading'}
/>
</div>
<button
type="button"
className={`test-email-button__button test-email-button__button--${status}`}
onClick={handleSendTest}
disabled={status === 'loading' || !documentId}
>
{status === 'loading' ? (
<>
<span className="test-email-button__spinner" />
Sende...
</>
) : (
<>
<svg
className="test-email-button__icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 2L11 13" />
<path d="M22 2L15 22L11 13L2 9L22 2Z" />
</svg>
Test-E-Mail senden
</>
)}
</button>
</div>
{result && (
<div
className={`test-email-button__result test-email-button__result--${result.success ? 'success' : 'error'}`}
>
<span className="test-email-button__result-icon">
{result.success ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
)}
</span>
<span className="test-email-button__result-message">
{result.message}
{result.messageId && (
<small className="test-email-button__result-id">ID: {result.messageId}</small>
)}
</span>
</div>
)}
{!documentId && (
<p className="test-email-button__warning">
Speichern Sie den Tenant zuerst, um eine Test-E-Mail senden zu können.
</p>
)}
</div>
)
}
export default TestEmailButton

View file

@ -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: [],

View file

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