Files
zpan/server/http/site/email-config.integration.test.ts
T
Jasper Van a5c281b26a refactor(api)!: make site routes RESTful (#526)
Move site configuration under settings, model analytics and licensing as resources, and isolate scheduler runs under the internal API.

BREAKING CHANGE: site email, branding, analytics, licensing, WebDAV verification, and scheduler endpoint paths have changed.

Refs #451
2026-07-27 15:22:28 -04:00

558 lines
20 KiB
TypeScript

import { eq } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as schema from '../../db/schema.js'
import { adminHeaders, authedHeaders, createTestApp } from '../../test/setup.js'
async function seedSmtpConfig(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'smtp' },
{ key: 'email_from', value: 'no-reply@example.com' },
{ key: 'email_smtp_host', value: 'smtp.example.com' },
{ key: 'email_smtp_port', value: '587' },
{ key: 'email_smtp_user', value: 'user@example.com' },
{ key: 'email_smtp_pass', value: 'supersecret' },
{ key: 'email_smtp_secure', value: 'true' },
])
}
async function seedHttpConfig(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'http' },
{ key: 'email_from', value: 'no-reply@example.com' },
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
{ key: 'email_http_api_key', value: 'my-secret-key' },
])
}
async function seedCloudflareConfig(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_provider', value: 'cloudflare' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
}
describe('Admin Email Config API — auth', () => {
it('GET returns 401 without auth [spec: email-config/auth-required]', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/site/settings/email')
expect(res.status).toBe(401)
})
it('GET returns 403 for non-admin user [spec: email-config/admin-only]', async () => {
const { app } = await createTestApp()
await authedHeaders(app, 'admin@example.com')
await authedHeaders(app, 'regular@example.com')
const signInRes = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'regular@example.com', password: 'password123456' }),
})
const freshHeaders = { Cookie: signInRes.headers.getSetCookie().join('; ') }
const res = await app.request('/api/site/settings/email', { headers: freshHeaders })
expect(res.status).toBe(403)
})
it('PUT returns 401 without auth', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, provider: 'smtp', from: 'a@b.com' }),
})
expect(res.status).toBe(401)
})
it('POST /test returns 401 without auth', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/site/settings/email/test-messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'a@b.com' }),
})
expect(res.status).toBe(401)
})
})
describe('Admin Email Config API — GET', () => {
it('returns disabled empty state when no config exists [spec: email-config/empty-state]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/site/settings/email', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body).toEqual({ enabled: false, requireEmailVerification: false, provider: null })
})
it('returns enabled with null provider when email is enabled but sender/provider are incomplete [spec: email-config/incomplete-provider]', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await db.insert(schema.systemOptions).values([{ key: 'email_enabled', value: 'true' }])
const res = await app.request('/api/site/settings/email', { headers })
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({
enabled: true,
requireEmailVerification: false,
provider: null,
})
})
it('returns masked SMTP config after SMTP config is saved [spec: email-config/mask-smtp]', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await seedSmtpConfig(db)
const res = await app.request('/api/site/settings/email', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.enabled).toBe(true)
expect(body.provider).toBe('smtp')
expect(body.from).toBe('no-reply@example.com')
const smtp = body.smtp as Record<string, unknown>
expect(smtp.host).toBe('smtp.example.com')
expect(smtp.port).toBe(587)
expect(smtp.user).toBe('user@example.com')
expect(smtp.secure).toBe(true)
// Password must be masked — last 4 chars visible, rest are asterisks
expect(smtp.pass).not.toBe('supersecret')
expect(String(smtp.pass).endsWith('cret')).toBe(true)
expect(String(smtp.pass)).toMatch(/^\*+cret$/)
})
it('returns masked HTTP config after HTTP config is saved [spec: email-config/mask-http]', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await seedHttpConfig(db)
const res = await app.request('/api/site/settings/email', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.enabled).toBe(true)
expect(body.provider).toBe('http')
expect(body.from).toBe('no-reply@example.com')
const http = body.http as Record<string, unknown>
expect(http.url).toBe('https://api.mail.example.com/send')
// apiKey must be masked
expect(http.apiKey).not.toBe('my-secret-key')
expect(String(http.apiKey).endsWith('-key')).toBe(true)
expect(String(http.apiKey)).toMatch(/^\*+-key$/)
})
it('returns provider null when enabled and email_from exist but provider is unset', async () => {
const sendMock = vi.fn().mockResolvedValue({ messageId: 'msg_123' })
const { app, db } = await createTestApp({}, { EMAIL: { send: sendMock } })
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const headers = await adminHeaders(app)
const res = await app.request('/api/site/settings/email', { headers })
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({
enabled: true,
requireEmailVerification: false,
provider: null,
})
})
})
describe('Admin Email Config API — PUT', () => {
it('saves SMTP config and returns success [spec: email-config/save-smtp]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'smtp',
enabled: true,
requireEmailVerification: true,
from: 'no-reply@example.com',
smtp: {
host: 'smtp.example.com',
port: 587,
user: 'user@example.com',
pass: 'secret',
secure: true,
},
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.success).toBe(true)
})
it('persists SMTP config so GET reflects the saved values', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'smtp',
enabled: true,
requireEmailVerification: false,
from: 'sender@example.com',
smtp: {
host: 'mail.example.com',
port: 465,
user: '',
pass: '',
secure: false,
},
}),
})
const res = await app.request('/api/site/settings/email', { headers })
const body = (await res.json()) as Record<string, unknown>
expect(body.provider).toBe('smtp')
expect(body.from).toBe('sender@example.com')
const smtp = body.smtp as Record<string, unknown>
expect(smtp.host).toBe('mail.example.com')
expect(smtp.port).toBe(465)
})
it('preserves the SMTP password when only settings around it are changed', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await seedSmtpConfig(db)
const currentResponse = await app.request('/api/site/settings/email', { headers })
const current = (await currentResponse.json()) as {
smtp: { host: string; port: number; user: string; pass: string; secure: boolean }
}
await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'smtp',
enabled: true,
requireEmailVerification: true,
from: 'no-reply@example.com',
smtp: current.smtp,
}),
})
const [stored] = await db
.select({ value: schema.systemOptions.value })
.from(schema.systemOptions)
.where(eq(schema.systemOptions.key, 'email_smtp_pass'))
expect(stored.value).toBe('supersecret')
})
it('saves HTTP config and returns success [spec: email-config/save-http]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'http',
enabled: true,
requireEmailVerification: false,
from: 'no-reply@example.com',
http: {
url: 'https://api.mail.example.com/send',
apiKey: 'my-api-key',
},
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.success).toBe(true)
})
it('persists HTTP config so GET reflects the saved values', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'http',
enabled: true,
requireEmailVerification: false,
from: 'http-from@example.com',
http: {
url: 'https://api.sendgrid.com/v3/mail/send',
apiKey: 'SG.key12345',
},
}),
})
const res = await app.request('/api/site/settings/email', { headers })
const body = (await res.json()) as Record<string, unknown>
expect(body.provider).toBe('http')
expect(body.from).toBe('http-from@example.com')
const http = body.http as Record<string, unknown>
expect(http.url).toBe('https://api.sendgrid.com/v3/mail/send')
})
it('returns 400 for invalid provider value [spec: email-config/invalid-provider]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, provider: 'sendgrid', from: 'a@b.com' }),
})
expect(res.status).toBe(400)
})
it('returns 400 for invalid from email [spec: email-config/invalid-from]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, provider: 'smtp', from: 'not-an-email' }),
})
expect(res.status).toBe(400)
})
it('rejects required verification when email delivery is disabled', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: false,
requireEmailVerification: true,
provider: 'smtp',
from: 'sender@example.com',
smtp: { host: 'mail.example.com', port: 587, user: '', pass: '', secure: true },
}),
})
expect(res.status).toBe(400)
})
it('updates existing config when PUT is called a second time [spec: email-config/update]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'smtp',
enabled: true,
requireEmailVerification: false,
from: 'first@example.com',
smtp: { host: 'first.smtp.com', port: 25, user: '', pass: '', secure: false },
}),
})
await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'smtp',
enabled: true,
requireEmailVerification: false,
from: 'second@example.com',
smtp: { host: 'second.smtp.com', port: 587, user: '', pass: '', secure: true },
}),
})
const res = await app.request('/api/site/settings/email', { headers })
const body = (await res.json()) as Record<string, unknown>
expect(body.from).toBe('second@example.com')
const smtp = body.smtp as Record<string, unknown>
expect(smtp.host).toBe('second.smtp.com')
expect(smtp.port).toBe(587)
})
it('saves Cloudflare config and returns success [spec: email-config/save-cloudflare]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'cloudflare',
enabled: true,
requireEmailVerification: false,
from: 'no-reply@zpan.space',
}),
})
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ success: true })
})
it('persists Cloudflare config so GET reflects the saved values', async () => {
const { app } = await createTestApp({}, { EMAIL: { send: vi.fn() } })
const headers = await adminHeaders(app)
await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: 'cloudflare',
enabled: true,
requireEmailVerification: true,
from: 'no-reply@zpan.space',
}),
})
const res = await app.request('/api/site/settings/email', { headers })
await expect(res.json()).resolves.toEqual({
enabled: true,
requireEmailVerification: true,
provider: 'cloudflare',
from: 'no-reply@zpan.space',
})
})
it('persists disabled state even when provider config exists [spec: email-config/persist-disabled]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: false,
requireEmailVerification: false,
provider: 'smtp',
from: 'sender@example.com',
smtp: { host: 'mail.example.com', port: 587, user: '', pass: '', secure: true },
}),
})
const res = await app.request('/api/site/settings/email', { headers })
const body = (await res.json()) as Record<string, unknown>
expect(body.enabled).toBe(false)
expect(body.provider).toBe('smtp')
})
})
describe('Admin Email Config API — POST /test', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns success when sendEmail succeeds [spec: email-config/test-success]', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await seedHttpConfig(db)
const res = await app.request('/api/site/settings/email/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'recipient@example.com' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.success).toBe(true)
})
it('returns 400 with error message when sendEmail fails [spec: email-config/test-failure]', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Internal Server Error'),
})
vi.stubGlobal('fetch', fetchMock)
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await seedHttpConfig(db)
const res = await app.request('/api/site/settings/email/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'recipient@example.com' }),
})
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { message: string } }
expect(typeof body.error.message).toBe('string')
})
it('returns 400 when no email config is set [spec: email-config/test-no-config]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/site/settings/email/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'recipient@example.com' }),
})
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toContain('Email is disabled')
})
it('returns 400 when email is disabled even if provider config exists', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
await app.request('/api/site/settings/email', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: false,
requireEmailVerification: false,
provider: 'http',
from: 'no-reply@example.com',
http: { url: 'https://api.mail.example.com/send', apiKey: 'key' },
}),
})
const res = await app.request('/api/site/settings/email/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'recipient@example.com' }),
})
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toContain('Email is disabled')
})
it('returns 400 for invalid to email', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await seedSmtpConfig(db)
const res = await app.request('/api/site/settings/email/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'not-an-email' }),
})
expect(res.status).toBe(400)
})
it('uses Cloudflare EMAIL binding when provider is cloudflare', async () => {
const sendMock = vi.fn().mockResolvedValue({ messageId: 'msg_123' })
const { app, db } = await createTestApp({}, { EMAIL: { send: sendMock } })
const headers = await adminHeaders(app)
await seedCloudflareConfig(db)
const res = await app.request('/api/site/settings/email/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'recipient@example.com' }),
})
expect(res.status).toBe(200)
expect(sendMock).toHaveBeenCalledOnce()
})
})