cms.c2sgmbh/tests/int/email.int.spec.ts
Martin Porwoll 6ccb50c5f4 docs: consolidate and update documentation
- Remove obsolete instruction documents (PROMPT_*.md, SECURITY_FIXES.md)
- Update CLAUDE.md with security features, test suite, audit logs
- Merge Techstack_Dokumentation into INFRASTRUCTURE.md
- Update SECURITY.md with custom login route documentation
- Add changelog to TODO.md
- Update email service and data masking for SMTP error handling
- Extend test coverage for CSRF and data masking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 09:25:00 +00:00

230 lines
6.2 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { Payload } from 'payload'
import type { Tenant } from '@/payload-types'
const mockSendMail = vi.fn(async () => ({ messageId: 'mocked-id' }))
const mockCreateTransport = vi.fn(() => ({ sendMail: mockSendMail }))
vi.mock('nodemailer', () => ({
__esModule: true,
default: {
createTransport: (...args: unknown[]) => mockCreateTransport(...args),
},
}))
import {
sendTenantEmail,
invalidateTenantEmailCache,
invalidateGlobalEmailCache,
} from '@/lib/email/tenant-email-service'
describe('tenant email service', () => {
let payload: Payload
let mockFindByID: ReturnType<typeof vi.fn>
let mockCreate: ReturnType<typeof vi.fn>
beforeEach(() => {
mockSendMail.mockClear()
mockCreateTransport.mockClear()
mockFindByID = vi.fn()
mockCreate = vi.fn().mockResolvedValue({ id: 1 })
payload = {
findByID: mockFindByID,
create: mockCreate,
update: vi.fn().mockResolvedValue({}),
} as unknown as Payload
process.env.SMTP_HOST = 'smtp.global.test'
process.env.SMTP_PORT = '587'
process.env.SMTP_SECURE = 'false'
process.env.SMTP_USER = 'global-user'
process.env.SMTP_PASS = 'global-pass'
process.env.SMTP_FROM_ADDRESS = 'noreply@example.com'
invalidateGlobalEmailCache()
})
afterEach(() => {
// Restore NODE_ENV after each test
delete process.env.EMAIL_DELIVERY_DISABLED
})
describe('with EMAIL_DELIVERY_DISABLED=false (production mode)', () => {
beforeEach(() => {
// Override test environment to simulate production
vi.stubEnv('NODE_ENV', 'production')
vi.stubEnv('EMAIL_DELIVERY_DISABLED', 'false')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('falls back to global SMTP configuration when tenant has no custom SMTP', async () => {
const tenant = {
id: 1,
slug: 'tenant-a',
name: 'Tenant A',
email: {
useCustomSmtp: false,
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
const result = await sendTenantEmail(payload, tenant.id, {
to: 'user@example.com',
subject: 'Test',
text: 'Hello from test',
})
expect(result.success).toBe(true)
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
expect(mockCreateTransport).toHaveBeenCalledWith({
host: 'smtp.global.test',
port: 587,
secure: false,
auth: {
user: 'global-user',
pass: 'global-pass',
},
})
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
from: '"Tenant A" <noreply@example.com>',
to: 'user@example.com',
}),
)
})
it('uses cached tenant-specific transporter and re-creates it after invalidation', async () => {
const tenant = {
id: 42,
slug: 'tenant-b',
name: 'Tenant B',
email: {
useCustomSmtp: true,
fromAddress: 'info@tenant-b.de',
fromName: 'Tenant B',
smtp: {
host: 'smtp.tenant-b.de',
port: 465,
secure: true,
user: 'tenant-user',
pass: 'tenant-pass',
},
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'Hi',
text: 'First email',
})
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
expect(mockCreateTransport).toHaveBeenCalledWith({
host: 'smtp.tenant-b.de',
port: 465,
secure: true,
auth: {
user: 'tenant-user',
pass: 'tenant-pass',
},
})
mockCreateTransport.mockClear()
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'Hi again',
text: 'Second email',
})
expect(mockCreateTransport).not.toHaveBeenCalled()
invalidateTenantEmailCache(tenant.id)
await sendTenantEmail(payload, tenant.id, {
to: 'recipient@example.com',
subject: 'After invalidation',
text: 'Third email',
})
expect(mockCreateTransport).toHaveBeenCalledTimes(1)
})
})
describe('with EMAIL_DELIVERY_DISABLED=true (test mode)', () => {
it('skips SMTP delivery and returns synthetic message ID', async () => {
// NODE_ENV=test is default in vitest, which disables email delivery
const tenant = {
id: 1,
slug: 'tenant-a',
name: 'Tenant A',
email: {
useCustomSmtp: false,
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
const result = await sendTenantEmail(payload, tenant.id, {
to: 'user@example.com',
subject: 'Test',
text: 'Hello from test',
})
expect(result.success).toBe(true)
expect(result.messageId).toMatch(/^test-message-\d+$/)
// SMTP should NOT be called in test mode
expect(mockCreateTransport).not.toHaveBeenCalled()
expect(mockSendMail).not.toHaveBeenCalled()
})
it('creates email log even when delivery is disabled', async () => {
const tenant = {
id: 1,
slug: 'tenant-a',
name: 'Tenant A',
email: {
useCustomSmtp: false,
},
} as Tenant
mockFindByID.mockResolvedValue(tenant)
await sendTenantEmail(payload, tenant.id, {
to: 'user@example.com',
subject: 'Test',
text: 'Hello from test',
})
// Email log should still be created
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
collection: 'email-logs',
data: expect.objectContaining({
to: 'user@example.com',
subject: 'Test',
status: 'pending',
}),
}),
)
})
})
describe('cache invalidation', () => {
it('invalidates tenant-specific cache', () => {
// This is a simple function that clears cache - just verify it doesn't throw
expect(() => invalidateTenantEmailCache(42)).not.toThrow()
})
it('invalidates global cache', () => {
expect(() => invalidateGlobalEmailCache()).not.toThrow()
})
})
})