Add Zod validation, fix mass assignment, remove test endpoints and dead code
- Add centralized Zod schemas (server/utils/schemas.js) and validateBody utility for all API endpoints - Fix critical mass assignment in member creation: raw body no longer passed to new Member(), only validated fields (email, name, circle, contributionTier) are accepted - Apply Zod validation to login, profile patch, event registration, updates, verify-payment, and admin event creation endpoints - Fix logout cookie flags to match login (httpOnly: true, secure conditional on NODE_ENV) - Delete unauthenticated test/debug endpoints (test-connection, test-subscription, test-bot) - Remove sensitive console.log statements from Helcim and member endpoints - Remove unused bcryptjs dependency - Add 10MB file size limit on image uploads - Use runtime config for JWT secret across all endpoints - Add 38 validation tests (117 total, all passing)
This commit is contained in:
parent
26c300c357
commit
b7279f57d1
41 changed files with 467 additions and 321 deletions
|
|
@ -107,7 +107,7 @@ describe('auth login endpoint', () => {
|
|||
|
||||
await expect(loginHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Email is required'
|
||||
statusMessage: 'Validation failed'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
273
tests/server/api/validation.test.js
Normal file
273
tests/server/api/validation.test.js
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
import {
|
||||
emailSchema,
|
||||
memberCreateSchema,
|
||||
memberProfileUpdateSchema,
|
||||
eventRegistrationSchema,
|
||||
updateCreateSchema,
|
||||
paymentVerifySchema,
|
||||
adminEventCreateSchema
|
||||
} from '../../../server/utils/schemas.js'
|
||||
import { validateBody } from '../../../server/utils/validateBody.js'
|
||||
|
||||
// --- Schema unit tests ---
|
||||
|
||||
describe('emailSchema', () => {
|
||||
it('accepts a valid email', () => {
|
||||
const result = emailSchema.safeParse({ email: 'test@example.com' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data.email).toBe('test@example.com')
|
||||
})
|
||||
|
||||
it('rejects a malformed email', () => {
|
||||
const result = emailSchema.safeParse({ email: 'not-an-email' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing email', () => {
|
||||
const result = emailSchema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('trims and lowercases email', () => {
|
||||
const result = emailSchema.safeParse({ email: ' Test@EXAMPLE.COM ' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data.email).toBe('test@example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('memberCreateSchema', () => {
|
||||
const validMember = {
|
||||
email: 'new@example.com',
|
||||
name: 'Test User',
|
||||
circle: 'community',
|
||||
contributionTier: '0'
|
||||
}
|
||||
|
||||
it('accepts valid member data', () => {
|
||||
const result = memberCreateSchema.safeParse(validMember)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects role field (mass assignment)', () => {
|
||||
const result = memberCreateSchema.safeParse({ ...validMember, role: 'admin' })
|
||||
expect(result.success).toBe(true)
|
||||
// role should NOT be in the output
|
||||
expect(result.data).not.toHaveProperty('role')
|
||||
})
|
||||
|
||||
it('rejects status field (mass assignment)', () => {
|
||||
const result = memberCreateSchema.safeParse({ ...validMember, status: 'active' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).not.toHaveProperty('status')
|
||||
})
|
||||
|
||||
it('rejects helcimCustomerId field (mass assignment)', () => {
|
||||
const result = memberCreateSchema.safeParse({ ...validMember, helcimCustomerId: 'cust_123' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).not.toHaveProperty('helcimCustomerId')
|
||||
})
|
||||
|
||||
it('rejects _id field (mass assignment)', () => {
|
||||
const result = memberCreateSchema.safeParse({ ...validMember, _id: '507f1f77bcf86cd799439011' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).not.toHaveProperty('_id')
|
||||
})
|
||||
|
||||
it('rejects invalid circle enum', () => {
|
||||
const result = memberCreateSchema.safeParse({ ...validMember, circle: 'superadmin' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid contributionTier enum', () => {
|
||||
const result = memberCreateSchema.safeParse({ ...validMember, contributionTier: '999' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing required fields', () => {
|
||||
const result = memberCreateSchema.safeParse({ email: 'test@example.com' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('lowercases email', () => {
|
||||
const result = memberCreateSchema.safeParse({ ...validMember, email: 'NEW@Example.COM' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data.email).toBe('new@example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('eventRegistrationSchema', () => {
|
||||
it('accepts valid registration', () => {
|
||||
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'jane@example.com' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects missing name', () => {
|
||||
const result = eventRegistrationSchema.safeParse({ email: 'jane@example.com' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed email', () => {
|
||||
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'bad' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('lowercases email', () => {
|
||||
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'JANE@Example.COM' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data.email).toBe('jane@example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCreateSchema', () => {
|
||||
it('accepts valid content', () => {
|
||||
const result = updateCreateSchema.safeParse({ content: 'Hello world' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty content', () => {
|
||||
const result = updateCreateSchema.safeParse({ content: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects content exceeding 50000 chars', () => {
|
||||
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50001) })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts content at exactly 50000 chars', () => {
|
||||
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50000) })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('validates images are URLs', () => {
|
||||
const result = updateCreateSchema.safeParse({
|
||||
content: 'test',
|
||||
images: ['not-a-url']
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid images array', () => {
|
||||
const result = updateCreateSchema.safeParse({
|
||||
content: 'test',
|
||||
images: ['https://example.com/img.png']
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects more than 20 images', () => {
|
||||
const images = Array.from({ length: 21 }, (_, i) => `https://example.com/img${i}.png`)
|
||||
const result = updateCreateSchema.safeParse({ content: 'test', images })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('validates privacy enum', () => {
|
||||
const result = updateCreateSchema.safeParse({ content: 'test', privacy: 'invalid' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paymentVerifySchema', () => {
|
||||
it('accepts valid card token and customer ID', () => {
|
||||
const result = paymentVerifySchema.safeParse({ cardToken: 'tok_123', customerId: 'cust_456' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects missing cardToken', () => {
|
||||
const result = paymentVerifySchema.safeParse({ customerId: 'cust_456' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty cardToken', () => {
|
||||
const result = paymentVerifySchema.safeParse({ cardToken: '', customerId: 'cust_456' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('adminEventCreateSchema', () => {
|
||||
const validEvent = {
|
||||
title: 'Test Event',
|
||||
description: 'A test event',
|
||||
startDate: '2026-04-01T10:00:00Z',
|
||||
endDate: '2026-04-01T12:00:00Z'
|
||||
}
|
||||
|
||||
it('accepts valid event data', () => {
|
||||
const result = adminEventCreateSchema.safeParse(validEvent)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects missing title', () => {
|
||||
const { title, ...rest } = validEvent
|
||||
const result = adminEventCreateSchema.safeParse(rest)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing dates', () => {
|
||||
const { startDate, endDate, ...rest } = validEvent
|
||||
const result = adminEventCreateSchema.safeParse({ ...rest, title: 'Test' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('memberProfileUpdateSchema', () => {
|
||||
it('rejects role in profile update', () => {
|
||||
const result = memberProfileUpdateSchema.safeParse({ role: 'admin', bio: 'test' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).not.toHaveProperty('role')
|
||||
})
|
||||
|
||||
it('rejects status in profile update', () => {
|
||||
const result = memberProfileUpdateSchema.safeParse({ status: 'active', bio: 'test' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).not.toHaveProperty('status')
|
||||
})
|
||||
|
||||
it('validates privacy enum values', () => {
|
||||
const result = memberProfileUpdateSchema.safeParse({ bioPrivacy: 'invalid' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts valid privacy values', () => {
|
||||
const result = memberProfileUpdateSchema.safeParse({ bioPrivacy: 'public' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// --- validateBody integration tests ---
|
||||
|
||||
describe('validateBody', () => {
|
||||
it('returns validated data on success', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
body: { email: 'test@example.com' }
|
||||
})
|
||||
const data = await validateBody(event, emailSchema)
|
||||
expect(data.email).toBe('test@example.com')
|
||||
})
|
||||
|
||||
it('throws 400 on validation failure', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
body: { email: 'bad' }
|
||||
})
|
||||
await expect(validateBody(event, emailSchema)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Validation failed'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips unknown fields from output', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
body: { email: 'test@example.com', name: 'Test', circle: 'community', contributionTier: '0', role: 'admin', _id: 'fake' }
|
||||
})
|
||||
const data = await validateBody(event, memberCreateSchema)
|
||||
expect(data).not.toHaveProperty('role')
|
||||
expect(data).not.toHaveProperty('_id')
|
||||
expect(data.email).toBe('test@example.com')
|
||||
expect(data.name).toBe('Test')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue