feat: add testing infrastructure — Vitest, Playwright, CI, git hooks
Add comprehensive testing covering 420 unit/handler tests across 24 Vitest files, 9 Playwright E2E specs, accessibility scans, and visual regression. Includes GitHub Actions CI, Husky pre-push hook, and TESTING.md docs.
This commit is contained in:
parent
036af95e00
commit
1e30ba23cd
35 changed files with 3637 additions and 5 deletions
85
tests/server/api/admin-auth-guards.test.js
Normal file
85
tests/server/api/admin-auth-guards.test.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const adminDir = resolve(import.meta.dirname, '../../../server/api/admin')
|
||||
|
||||
// All admin routes grouped by directory
|
||||
const adminRoutes = {
|
||||
'admin/': [
|
||||
'dashboard.get.js',
|
||||
'events.get.js',
|
||||
'events.post.js',
|
||||
'members.get.js',
|
||||
'members.post.js',
|
||||
'series.get.js',
|
||||
'series.post.js',
|
||||
'series.put.js'
|
||||
],
|
||||
'admin/events/': [
|
||||
'events/[id].delete.js',
|
||||
'events/[id].get.js',
|
||||
'events/[id].put.js'
|
||||
],
|
||||
'admin/members/': [
|
||||
'members/[id].put.js',
|
||||
'members/[id]/role.patch.js',
|
||||
'members/import.post.js',
|
||||
'members/invite.post.js'
|
||||
],
|
||||
'admin/series/': [
|
||||
'series/[id].delete.js',
|
||||
'series/[id].put.js',
|
||||
'series/tickets.put.js'
|
||||
]
|
||||
}
|
||||
|
||||
// Business logic markers that must appear after requireAdmin
|
||||
const businessLogicPatterns = [
|
||||
'readBody(event)',
|
||||
'validateBody(event',
|
||||
'fetch(',
|
||||
'connectDB()',
|
||||
'Member.find',
|
||||
'Member.findOne',
|
||||
'Member.findById',
|
||||
'Member.countDocuments',
|
||||
'Event.find',
|
||||
'Event.findOne',
|
||||
'Event.findById',
|
||||
'Event.countDocuments',
|
||||
'Series.find',
|
||||
'Series.findOne',
|
||||
'Series.findById'
|
||||
]
|
||||
|
||||
describe('Admin endpoint auth guards', () => {
|
||||
for (const [group, files] of Object.entries(adminRoutes)) {
|
||||
describe(group, () => {
|
||||
for (const file of files) {
|
||||
describe(file, () => {
|
||||
const source = readFileSync(resolve(adminDir, file), 'utf-8')
|
||||
|
||||
it('calls requireAdmin', () => {
|
||||
expect(source).toContain('requireAdmin(event)')
|
||||
})
|
||||
|
||||
it('calls requireAdmin before any business logic', () => {
|
||||
const adminIndex = source.indexOf('requireAdmin(event)')
|
||||
expect(adminIndex).toBeGreaterThan(-1)
|
||||
|
||||
for (const pattern of businessLogicPatterns) {
|
||||
const patternIndex = source.indexOf(pattern)
|
||||
if (patternIndex > -1) {
|
||||
expect(
|
||||
adminIndex,
|
||||
`requireAdmin must appear before ${pattern} in ${file}`
|
||||
).toBeLessThan(patternIndex)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
165
tests/server/api/admin-role-patch.test.js
Normal file
165
tests/server/api/admin-role-patch.test.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { findByIdAndUpdate: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||
connectDB: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../server/utils/validateBody.js', () => ({
|
||||
validateBody: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../server/utils/schemas.js', () => ({
|
||||
adminRoleUpdateSchema: {}
|
||||
}))
|
||||
|
||||
import handler from '../../../server/api/admin/members/[id]/role.patch.js'
|
||||
import Member from '../../../server/models/member.js'
|
||||
import { validateBody } from '../../../server/utils/validateBody.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
describe('admin role PATCH endpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireAdmin.mockResolvedValue({
|
||||
_id: { toString: () => 'admin-123' }
|
||||
})
|
||||
validateBody.mockResolvedValue({ role: 'member' })
|
||||
vi.stubGlobal('getRouterParam', vi.fn().mockReturnValue('target-member-id'))
|
||||
})
|
||||
|
||||
describe('source inspection', () => {
|
||||
const source = readFileSync(
|
||||
resolve(import.meta.dirname, '../../../server/api/admin/members/[id]/role.patch.js'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
it('calls requireAdmin before validateBody', () => {
|
||||
const adminIndex = source.indexOf('requireAdmin(event)')
|
||||
const validateIndex = source.indexOf('validateBody(event')
|
||||
|
||||
expect(adminIndex).toBeGreaterThan(-1)
|
||||
expect(validateIndex).toBeGreaterThan(-1)
|
||||
expect(adminIndex).toBeLessThan(validateIndex)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth', () => {
|
||||
it('rejects non-admin users', async () => {
|
||||
requireAdmin.mockRejectedValue(
|
||||
createError({ statusCode: 403, statusMessage: 'Forbidden' })
|
||||
)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'PATCH',
|
||||
path: '/api/admin/members/target-member-id/role',
|
||||
body: { role: 'member' }
|
||||
})
|
||||
|
||||
await expect(handler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('self-demotion', () => {
|
||||
it('returns 400 when admin tries to remove their own admin role', async () => {
|
||||
requireAdmin.mockResolvedValue({
|
||||
_id: { toString: () => 'admin-123' }
|
||||
})
|
||||
validateBody.mockResolvedValue({ role: 'member' })
|
||||
vi.stubGlobal('getRouterParam', vi.fn().mockReturnValue('admin-123'))
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'PATCH',
|
||||
path: '/api/admin/members/admin-123/role',
|
||||
body: { role: 'member' }
|
||||
})
|
||||
|
||||
await expect(handler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'You cannot remove your own admin role.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation', () => {
|
||||
it('rejects invalid role via validateBody', async () => {
|
||||
validateBody.mockRejectedValue(
|
||||
createError({ statusCode: 400, statusMessage: 'Invalid role' })
|
||||
)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'PATCH',
|
||||
path: '/api/admin/members/target-member-id/role',
|
||||
body: { role: 'superadmin' }
|
||||
})
|
||||
|
||||
await expect(handler(event)).rejects.toMatchObject({ statusCode: 400 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('member not found', () => {
|
||||
it('returns 404 when member does not exist', async () => {
|
||||
Member.findByIdAndUpdate.mockResolvedValue(null)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'PATCH',
|
||||
path: '/api/admin/members/nonexistent-id/role',
|
||||
body: { role: 'member' }
|
||||
})
|
||||
|
||||
await expect(handler(event)).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Member not found.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('successful role changes', () => {
|
||||
it('promotes a member to admin', async () => {
|
||||
validateBody.mockResolvedValue({ role: 'admin' })
|
||||
const updatedMember = { _id: 'target-member-id', role: 'admin', name: 'Test User' }
|
||||
Member.findByIdAndUpdate.mockResolvedValue(updatedMember)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'PATCH',
|
||||
path: '/api/admin/members/target-member-id/role',
|
||||
body: { role: 'admin' }
|
||||
})
|
||||
|
||||
const result = await handler(event)
|
||||
|
||||
expect(result).toEqual({ success: true, member: updatedMember })
|
||||
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||
'target-member-id',
|
||||
{ role: 'admin' },
|
||||
{ new: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('demotes a member to regular role', async () => {
|
||||
validateBody.mockResolvedValue({ role: 'member' })
|
||||
const updatedMember = { _id: 'target-member-id', role: 'member', name: 'Test User' }
|
||||
Member.findByIdAndUpdate.mockResolvedValue(updatedMember)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'PATCH',
|
||||
path: '/api/admin/members/target-member-id/role',
|
||||
body: { role: 'member' }
|
||||
})
|
||||
|
||||
const result = await handler(event)
|
||||
|
||||
expect(result).toEqual({ success: true, member: updatedMember })
|
||||
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||
'target-member-id',
|
||||
{ role: 'member' },
|
||||
{ new: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { findOne: vi.fn() }
|
||||
default: { findOne: vi.fn(), findByIdAndUpdate: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||
|
|
|
|||
221
tests/server/api/auth-verify.test.js
Normal file
221
tests/server/api/auth-verify.test.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||
connectDB: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
default: {
|
||||
verify: vi.fn(),
|
||||
sign: vi.fn().mockReturnValue('mock-session-token')
|
||||
}
|
||||
}))
|
||||
|
||||
import jwt from 'jsonwebtoken'
|
||||
import Member from '../../../server/models/member.js'
|
||||
import verifyHandler from '../../../server/api/auth/verify.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
const baseMember = {
|
||||
_id: 'member-123',
|
||||
email: 'test@example.com',
|
||||
status: 'active',
|
||||
role: 'member',
|
||||
magicLinkJti: 'jti-abc',
|
||||
magicLinkJtiUsed: false,
|
||||
tokenVersion: 1
|
||||
}
|
||||
|
||||
describe('auth verify endpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('rejects missing token with 400', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: {}
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Token is required'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid JWT with 401', async () => {
|
||||
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'bad-token' }
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid or expired token'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects when member not found with 401', async () => {
|
||||
jwt.verify.mockReturnValue({ memberId: 'nonexistent', jti: 'jti-abc' })
|
||||
Member.findById.mockResolvedValue(null)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'valid-token' }
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid or expired token'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects suspended member with 403', async () => {
|
||||
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
|
||||
Member.findById.mockResolvedValue({ ...baseMember, status: 'suspended' })
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'valid-token' }
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Account is suspended'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects cancelled member with 403', async () => {
|
||||
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
|
||||
Member.findById.mockResolvedValue({ ...baseMember, status: 'cancelled' })
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'valid-token' }
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Account is cancelled'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects JTI mismatch with 401', async () => {
|
||||
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'wrong-jti' })
|
||||
Member.findById.mockResolvedValue({ ...baseMember })
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'valid-token' }
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid or expired token'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects already-used JTI with 401', async () => {
|
||||
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
|
||||
Member.findById.mockResolvedValue({ ...baseMember, magicLinkJtiUsed: true })
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'valid-token' }
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid or expired token'
|
||||
})
|
||||
})
|
||||
|
||||
it('burns token atomically via findByIdAndUpdate', async () => {
|
||||
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
|
||||
Member.findById.mockResolvedValue({ ...baseMember })
|
||||
Member.findByIdAndUpdate.mockResolvedValue({})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'valid-token' }
|
||||
})
|
||||
|
||||
await verifyHandler(event)
|
||||
|
||||
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||
'member-123',
|
||||
{ $set: { magicLinkJtiUsed: true, lastLogin: expect.any(Date) } },
|
||||
{ runValidators: false }
|
||||
)
|
||||
})
|
||||
|
||||
it('sets httpOnly session cookie with correct attributes', async () => {
|
||||
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
|
||||
Member.findById.mockResolvedValue({ ...baseMember })
|
||||
Member.findByIdAndUpdate.mockResolvedValue({})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'valid-token' }
|
||||
})
|
||||
|
||||
await verifyHandler(event)
|
||||
|
||||
const setCookieHeader = event._testSetHeaders['set-cookie']
|
||||
expect(setCookieHeader).toBeDefined()
|
||||
|
||||
const cookie = Array.isArray(setCookieHeader) ? setCookieHeader.join('; ') : setCookieHeader
|
||||
expect(cookie).toContain('auth-token=mock-session-token')
|
||||
expect(cookie).toContain('HttpOnly')
|
||||
expect(cookie).toContain('SameSite=Lax')
|
||||
expect(cookie).toContain('Path=/')
|
||||
expect(cookie).toContain('Max-Age=604800')
|
||||
})
|
||||
|
||||
it('returns admin redirect for admin role', async () => {
|
||||
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
|
||||
Member.findById.mockResolvedValue({ ...baseMember, role: 'admin' })
|
||||
Member.findByIdAndUpdate.mockResolvedValue({})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'valid-token' }
|
||||
})
|
||||
|
||||
const result = await verifyHandler(event)
|
||||
|
||||
expect(result).toEqual({ success: true, redirectUrl: '/admin' })
|
||||
})
|
||||
|
||||
it('returns member redirect for non-admin role', async () => {
|
||||
jwt.verify.mockReturnValue({ memberId: 'member-123', jti: 'jti-abc' })
|
||||
Member.findById.mockResolvedValue({ ...baseMember })
|
||||
Member.findByIdAndUpdate.mockResolvedValue({})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'valid-token' }
|
||||
})
|
||||
|
||||
const result = await verifyHandler(event)
|
||||
|
||||
expect(result).toEqual({ success: true, redirectUrl: '/member/dashboard' })
|
||||
})
|
||||
})
|
||||
168
tests/server/api/dev-endpoints.test.js
Normal file
168
tests/server/api/dev-endpoints.test.js
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { findOne: vi.fn(), create: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||
connectDB: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
default: { sign: vi.fn().mockReturnValue('mock-token') }
|
||||
}))
|
||||
|
||||
import Member from '../../../server/models/member.js'
|
||||
import testLoginHandler from '../../../server/api/dev/test-login.get.js'
|
||||
import memberLoginHandler from '../../../server/api/dev/member-login.get.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
const mockMember = {
|
||||
_id: 'member-123',
|
||||
email: 'test-admin@ghostguild.dev',
|
||||
name: 'Test Admin',
|
||||
circle: 'founder',
|
||||
role: 'admin',
|
||||
status: 'active'
|
||||
}
|
||||
|
||||
describe('dev endpoints', () => {
|
||||
let originalNodeEnv
|
||||
|
||||
beforeEach(() => {
|
||||
originalNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'development'
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
})
|
||||
|
||||
// ── Source inspection ──────────────────────────────────────────
|
||||
|
||||
describe('source inspection', () => {
|
||||
const devDir = resolve(import.meta.dirname, '../../../server/api/dev')
|
||||
|
||||
it('test-login.get.js first conditional is NODE_ENV production check', () => {
|
||||
const source = readFileSync(resolve(devDir, 'test-login.get.js'), 'utf-8')
|
||||
const handlerBody = source.slice(source.indexOf('defineEventHandler'))
|
||||
const firstIf = handlerBody.match(/if\s*\([^)]+\)/)?.[0]
|
||||
expect(firstIf).toContain("process.env.NODE_ENV === 'production'")
|
||||
})
|
||||
|
||||
it('member-login.get.js first conditional is NODE_ENV production check', () => {
|
||||
const source = readFileSync(resolve(devDir, 'member-login.get.js'), 'utf-8')
|
||||
const handlerBody = source.slice(source.indexOf('defineEventHandler'))
|
||||
const firstIf = handlerBody.match(/if\s*\([^)]+\)/)?.[0]
|
||||
expect(firstIf).toContain("process.env.NODE_ENV === 'production'")
|
||||
})
|
||||
})
|
||||
|
||||
// ── test-login.get.js ──────────────────────────────────────────
|
||||
|
||||
describe('test-login.get.js', () => {
|
||||
it('returns 404 in production', async () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
const event = createMockEvent({ method: 'GET', path: '/api/dev/test-login' })
|
||||
|
||||
await expect(testLoginHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 404
|
||||
})
|
||||
})
|
||||
|
||||
it('creates admin user when none exists', async () => {
|
||||
Member.findOne.mockResolvedValue(null)
|
||||
Member.create.mockResolvedValue(mockMember)
|
||||
|
||||
const event = createMockEvent({ method: 'GET', path: '/api/dev/test-login' })
|
||||
await testLoginHandler(event)
|
||||
|
||||
expect(Member.findOne).toHaveBeenCalledWith({ email: 'test-admin@ghostguild.dev' })
|
||||
expect(Member.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: 'test-admin@ghostguild.dev',
|
||||
role: 'admin',
|
||||
circle: 'founder'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses existing admin when found', async () => {
|
||||
Member.findOne.mockResolvedValue(mockMember)
|
||||
|
||||
const event = createMockEvent({ method: 'GET', path: '/api/dev/test-login' })
|
||||
await testLoginHandler(event)
|
||||
|
||||
expect(Member.findOne).toHaveBeenCalledWith({ email: 'test-admin@ghostguild.dev' })
|
||||
expect(Member.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets auth cookie', async () => {
|
||||
Member.findOne.mockResolvedValue(mockMember)
|
||||
|
||||
const event = createMockEvent({ method: 'GET', path: '/api/dev/test-login' })
|
||||
await testLoginHandler(event)
|
||||
|
||||
const cookieHeader = event._testSetHeaders['set-cookie']
|
||||
expect(cookieHeader).toBeDefined()
|
||||
expect(cookieHeader).toContain('auth-token=mock-token')
|
||||
})
|
||||
})
|
||||
|
||||
// ── member-login.get.js ────────────────────────────────────────
|
||||
|
||||
describe('member-login.get.js', () => {
|
||||
it('returns 404 in production', async () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
const event = createMockEvent({ method: 'GET', path: '/api/dev/member-login' })
|
||||
|
||||
await expect(memberLoginHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 404
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 400 when email query param is missing', async () => {
|
||||
const event = createMockEvent({ method: 'GET', path: '/api/dev/member-login' })
|
||||
|
||||
await expect(memberLoginHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 404 when member not found', async () => {
|
||||
Member.findOne.mockResolvedValue(null)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'GET',
|
||||
path: '/api/dev/member-login?email=nobody@example.com'
|
||||
})
|
||||
|
||||
await expect(memberLoginHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 404
|
||||
})
|
||||
})
|
||||
|
||||
it('sets auth cookie for found member', async () => {
|
||||
const foundMember = {
|
||||
_id: 'member-456',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
status: 'active'
|
||||
}
|
||||
Member.findOne.mockResolvedValue(foundMember)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'GET',
|
||||
path: '/api/dev/member-login?email=test@example.com'
|
||||
})
|
||||
await memberLoginHandler(event)
|
||||
|
||||
const cookieHeader = event._testSetHeaders['set-cookie']
|
||||
expect(cookieHeader).toBeDefined()
|
||||
expect(cookieHeader).toContain('auth-token=mock-token')
|
||||
})
|
||||
})
|
||||
})
|
||||
89
tests/server/api/event-registration.test.js
Normal file
89
tests/server/api/event-registration.test.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const eventsDir = resolve(import.meta.dirname, '../../../server/api/events/[id]')
|
||||
|
||||
describe('register.post.js', () => {
|
||||
const source = readFileSync(resolve(eventsDir, 'register.post.js'), 'utf-8')
|
||||
|
||||
it('uses validateBody for input validation', () => {
|
||||
expect(source).toContain('validateBody(event')
|
||||
})
|
||||
|
||||
it('checks for duplicate registration with case-insensitive email', () => {
|
||||
expect(source).toContain('email.toLowerCase()')
|
||||
})
|
||||
|
||||
it('checks membersOnly restriction', () => {
|
||||
expect(source).toContain('membersOnly')
|
||||
})
|
||||
|
||||
it('checks capacity via maxAttendees', () => {
|
||||
expect(source).toContain('maxAttendees')
|
||||
})
|
||||
|
||||
it('does not let email failure block registration', () => {
|
||||
// The await call (not the import) must be wrapped in try/catch
|
||||
const emailCallIndex = source.indexOf('await sendEventRegistrationEmail')
|
||||
expect(emailCallIndex).toBeGreaterThan(-1)
|
||||
|
||||
// A try block immediately precedes the email call
|
||||
const precedingSource = source.slice(0, emailCallIndex)
|
||||
const lastTryIndex = precedingSource.lastIndexOf('try')
|
||||
expect(lastTryIndex).toBeGreaterThan(-1)
|
||||
|
||||
// The catch after the email call should log but not re-throw
|
||||
const afterEmail = source.slice(emailCallIndex)
|
||||
const catchBlock = afterEmail.match(/catch\s*\(\w+\)\s*\{[^}]*\}/s)
|
||||
expect(catchBlock).not.toBeNull()
|
||||
expect(catchBlock[0]).toContain('console.error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('guest-register.post.js', () => {
|
||||
const source = readFileSync(resolve(eventsDir, 'guest-register.post.js'), 'utf-8')
|
||||
|
||||
it('uses validateBody for input validation', () => {
|
||||
expect(source).toContain('validateBody(event')
|
||||
})
|
||||
|
||||
it('checks membersOnly restriction with 403', () => {
|
||||
expect(source).toContain('membersOnly')
|
||||
expect(source).toContain('403')
|
||||
})
|
||||
|
||||
it('checks payment requirement with 402', () => {
|
||||
expect(source).toContain('paymentRequired')
|
||||
expect(source).toContain('402')
|
||||
})
|
||||
|
||||
it('checks capacity via maxAttendees', () => {
|
||||
expect(source).toContain('maxAttendees')
|
||||
})
|
||||
|
||||
it('does not require auth', () => {
|
||||
expect(source).not.toContain('requireAuth')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel-registration.post.js', () => {
|
||||
const source = readFileSync(resolve(eventsDir, 'cancel-registration.post.js'), 'utf-8')
|
||||
|
||||
it('uses validateBody for input validation', () => {
|
||||
expect(source).toContain('validateBody(event')
|
||||
})
|
||||
|
||||
it('finds registration by email', () => {
|
||||
expect(source).toContain('email.toLowerCase()')
|
||||
})
|
||||
|
||||
it('notifies waitlist after cancellation', () => {
|
||||
expect(source).toContain('waitlist')
|
||||
expect(source).toContain('sendWaitlistNotificationEmail')
|
||||
})
|
||||
|
||||
it('does not require auth', () => {
|
||||
expect(source).not.toContain('requireAuth')
|
||||
})
|
||||
})
|
||||
201
tests/server/api/helcim-payment.test.js
Normal file
201
tests/server/api/helcim-payment.test.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
|
||||
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
|
||||
|
||||
import { requireAuth } from '../../../server/utils/auth.js'
|
||||
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
|
||||
import initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js'
|
||||
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
|
||||
vi.stubGlobal('helcimInitializePaymentSchema', {})
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
describe('initialize-payment endpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
it('skips auth for event_ticket type', async () => {
|
||||
const body = {
|
||||
amount: 25,
|
||||
metadata: { type: 'event_ticket', eventTitle: 'Test Event', eventId: 'evt-1' }
|
||||
}
|
||||
globalThis.validateBody.mockResolvedValue(body)
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkoutToken: 'ct-123', secretToken: 'st-456' })
|
||||
})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
body
|
||||
})
|
||||
|
||||
await initPaymentHandler(event)
|
||||
|
||||
expect(requireAuth).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires auth for non-event_ticket types', async () => {
|
||||
const body = { amount: 0, customerCode: 'code-1' }
|
||||
globalThis.validateBody.mockResolvedValue(body)
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkoutToken: 'ct-123', secretToken: 'st-456' })
|
||||
})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
body
|
||||
})
|
||||
|
||||
await initPaymentHandler(event)
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledWith(event)
|
||||
})
|
||||
|
||||
it('returns checkoutToken and secretToken on success', async () => {
|
||||
const body = {
|
||||
amount: 10,
|
||||
metadata: { type: 'event_ticket', eventTitle: 'Workshop', eventId: 'evt-2' }
|
||||
}
|
||||
globalThis.validateBody.mockResolvedValue(body)
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkoutToken: 'ct-abc', secretToken: 'st-xyz' })
|
||||
})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
body
|
||||
})
|
||||
|
||||
const result = await initPaymentHandler(event)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
checkoutToken: 'ct-abc',
|
||||
secretToken: 'st-xyz'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-payment endpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
it('requires auth', async () => {
|
||||
requireAuth.mockRejectedValue(
|
||||
createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/verify-payment',
|
||||
body: { customerId: 'cust-1', cardToken: 'tok-1' }
|
||||
})
|
||||
|
||||
await expect(verifyPaymentHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized'
|
||||
})
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledWith(event)
|
||||
})
|
||||
|
||||
it('validates with paymentVerifySchema', async () => {
|
||||
const body = { customerId: 'cust-1', cardToken: 'tok-1' }
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
importedValidateBody.mockResolvedValue(body)
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [{ cardToken: 'tok-1' }]
|
||||
})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/verify-payment',
|
||||
body
|
||||
})
|
||||
|
||||
await verifyPaymentHandler(event)
|
||||
|
||||
expect(importedValidateBody).toHaveBeenCalledWith(event, expect.any(Object))
|
||||
})
|
||||
|
||||
it('returns success when card token found', async () => {
|
||||
const body = { customerId: 'cust-1', cardToken: 'tok-match' }
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
importedValidateBody.mockResolvedValue(body)
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ cardToken: 'tok-other' },
|
||||
{ cardToken: 'tok-match' }
|
||||
]
|
||||
})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/verify-payment',
|
||||
body
|
||||
})
|
||||
|
||||
const result = await verifyPaymentHandler(event)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
cardToken: 'tok-match',
|
||||
message: 'Payment verified with Helcim'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 400 when card token not found', async () => {
|
||||
const body = { customerId: 'cust-1', cardToken: 'tok-missing' }
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
importedValidateBody.mockResolvedValue(body)
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ cardToken: 'tok-aaa' },
|
||||
{ cardToken: 'tok-bbb' }
|
||||
]
|
||||
})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/verify-payment',
|
||||
body
|
||||
})
|
||||
|
||||
await expect(verifyPaymentHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Payment method not found or does not belong to this customer'
|
||||
})
|
||||
})
|
||||
})
|
||||
159
tests/server/api/helcim-subscription.test.js
Normal file
159
tests/server/api/helcim-subscription.test.js
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { findOneAndUpdate: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('../../../server/utils/slack.ts', () => ({
|
||||
getSlackService: vi.fn().mockReturnValue(null)
|
||||
}))
|
||||
vi.mock('../../../server/config/contributions.js', () => ({
|
||||
requiresPayment: vi.fn(),
|
||||
getHelcimPlanId: vi.fn(),
|
||||
getContributionTierByValue: vi.fn()
|
||||
}))
|
||||
|
||||
import Member from '../../../server/models/member.js'
|
||||
import { requireAuth } from '../../../server/utils/auth.js'
|
||||
import { requiresPayment, getHelcimPlanId, getContributionTierByValue } from '../../../server/config/contributions.js'
|
||||
import subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
// helcimSubscriptionSchema is a Nitro auto-import used by validateBody
|
||||
vi.stubGlobal('helcimSubscriptionSchema', {})
|
||||
|
||||
describe('helcim subscription endpoint', () => {
|
||||
const savedFetch = globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore fetch in case a test stubbed it
|
||||
globalThis.fetch = savedFetch
|
||||
})
|
||||
|
||||
it('requires auth', async () => {
|
||||
requireAuth.mockRejectedValue(
|
||||
createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/subscription',
|
||||
body: { customerId: 'cust-1', contributionTier: '0', customerCode: 'code-1' }
|
||||
})
|
||||
|
||||
await expect(subscriptionHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized'
|
||||
})
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledWith(event)
|
||||
})
|
||||
|
||||
it('free tier skips Helcim and activates member', async () => {
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
requiresPayment.mockReturnValue(false)
|
||||
|
||||
const mockMember = {
|
||||
_id: 'member-1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test',
|
||||
circle: 'community',
|
||||
contributionTier: '0',
|
||||
status: 'active',
|
||||
save: vi.fn()
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/subscription',
|
||||
body: { customerId: 'cust-1', contributionTier: '0', customerCode: 'code-1' }
|
||||
})
|
||||
|
||||
const result = await subscriptionHandler(event)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.subscription).toBeNull()
|
||||
expect(result.member).toEqual({
|
||||
id: 'member-1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test',
|
||||
circle: 'community',
|
||||
contributionTier: '0',
|
||||
status: 'active'
|
||||
})
|
||||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ helcimCustomerId: 'cust-1' },
|
||||
expect.objectContaining({ status: 'active', contributionTier: '0' }),
|
||||
{ new: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('paid tier without cardToken returns 400', async () => {
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
requiresPayment.mockReturnValue(true)
|
||||
getHelcimPlanId.mockReturnValue('plan-123')
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/subscription',
|
||||
body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1' }
|
||||
})
|
||||
|
||||
await expect(subscriptionHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Payment information is required for this contribution tier'
|
||||
})
|
||||
})
|
||||
|
||||
it('Helcim API failure still activates member', async () => {
|
||||
requireAuth.mockResolvedValue(undefined)
|
||||
requiresPayment.mockReturnValue(true)
|
||||
getHelcimPlanId.mockReturnValue('plan-123')
|
||||
getContributionTierByValue.mockReturnValue({ amount: '15' })
|
||||
|
||||
const mockMember = {
|
||||
_id: 'member-2',
|
||||
email: 'paid@example.com',
|
||||
name: 'Paid User',
|
||||
circle: 'founder',
|
||||
contributionTier: '15',
|
||||
status: 'active',
|
||||
save: vi.fn()
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')))
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/subscription',
|
||||
body: {
|
||||
customerId: 'cust-1',
|
||||
contributionTier: '15',
|
||||
customerCode: 'code-1',
|
||||
cardToken: 'tok-123'
|
||||
}
|
||||
})
|
||||
|
||||
const result = await subscriptionHandler(event)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.warning).toBeTruthy()
|
||||
expect(result.member.status).toBe('active')
|
||||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ helcimCustomerId: 'cust-1' },
|
||||
expect.objectContaining({ status: 'active', contributionTier: '15' }),
|
||||
{ new: true }
|
||||
)
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
// Re-stub the schema global after unstubAllGlobals
|
||||
vi.stubGlobal('helcimSubscriptionSchema', {})
|
||||
})
|
||||
})
|
||||
109
tests/server/api/members-create.test.js
Normal file
109
tests/server/api/members-create.test.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => {
|
||||
const mockSave = vi.fn().mockResolvedValue(undefined)
|
||||
function MockMember(data) {
|
||||
Object.assign(this, data)
|
||||
this._id = 'new-member-123'
|
||||
this.status = data.status || 'pending_payment'
|
||||
this.save = mockSave
|
||||
}
|
||||
MockMember.findOne = vi.fn()
|
||||
MockMember.findByIdAndUpdate = vi.fn()
|
||||
MockMember._mockSave = mockSave
|
||||
return { default: MockMember }
|
||||
})
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
|
||||
vi.mock('../../../server/utils/schemas.js', () => ({ memberCreateSchema: {} }))
|
||||
vi.mock('../../../server/utils/slack.ts', () => ({
|
||||
getSlackService: vi.fn().mockReturnValue(null)
|
||||
}))
|
||||
vi.mock('../../../server/utils/resend.js', () => ({
|
||||
sendWelcomeEmail: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
import Member from '../../../server/models/member.js'
|
||||
import { validateBody } from '../../../server/utils/validateBody.js'
|
||||
import { sendWelcomeEmail } from '../../../server/utils/resend.js'
|
||||
import createHandler from '../../../server/api/members/create.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
describe('members/create.post handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Member.findOne.mockResolvedValue(null)
|
||||
Member._mockSave.mockResolvedValue(undefined)
|
||||
validateBody.mockResolvedValue({
|
||||
email: 'new@example.com',
|
||||
name: 'New Member',
|
||||
circle: 'community',
|
||||
contributionTier: '0'
|
||||
})
|
||||
})
|
||||
|
||||
it('validates request body via memberCreateSchema', async () => {
|
||||
const event = createMockEvent({ method: 'POST', path: '/api/members/create' })
|
||||
|
||||
await createHandler(event)
|
||||
|
||||
expect(validateBody).toHaveBeenCalledOnce()
|
||||
expect(validateBody).toHaveBeenCalledWith(event, expect.any(Object))
|
||||
})
|
||||
|
||||
it('rejects duplicate email with 409', async () => {
|
||||
Member.findOne.mockResolvedValue({ _id: 'existing-123', email: 'new@example.com' })
|
||||
|
||||
const event = createMockEvent({ method: 'POST', path: '/api/members/create' })
|
||||
|
||||
await expect(createHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
statusMessage: 'A member with this email already exists'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose helcimCustomerId or role in response', async () => {
|
||||
validateBody.mockResolvedValue({
|
||||
email: 'new@example.com',
|
||||
name: 'New Member',
|
||||
circle: 'community',
|
||||
contributionTier: '0',
|
||||
helcimCustomerId: 'cust-999',
|
||||
role: 'admin'
|
||||
})
|
||||
|
||||
const event = createMockEvent({ method: 'POST', path: '/api/members/create' })
|
||||
const result = await createHandler(event)
|
||||
|
||||
expect(result.member).not.toHaveProperty('helcimCustomerId')
|
||||
expect(result.member).not.toHaveProperty('role')
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.member).toEqual({
|
||||
id: 'new-member-123',
|
||||
email: 'new@example.com',
|
||||
name: 'New Member',
|
||||
circle: 'community',
|
||||
contributionTier: '0',
|
||||
status: 'pending_payment'
|
||||
})
|
||||
})
|
||||
|
||||
it('succeeds when Slack service is unavailable', async () => {
|
||||
const event = createMockEvent({ method: 'POST', path: '/api/members/create' })
|
||||
const result = await createHandler(event)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.member.email).toBe('new@example.com')
|
||||
})
|
||||
|
||||
it('succeeds when welcome email fails', async () => {
|
||||
sendWelcomeEmail.mockRejectedValue(new Error('email service down'))
|
||||
|
||||
const event = createMockEvent({ method: 'POST', path: '/api/members/create' })
|
||||
const result = await createHandler(event)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.member.email).toBe('new@example.com')
|
||||
expect(sendWelcomeEmail).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
112
tests/server/api/updates-auth.test.js
Normal file
112
tests/server/api/updates-auth.test.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const updatesDir = resolve(import.meta.dirname, '../../../server/api/updates')
|
||||
|
||||
describe('Updates API auth guards', () => {
|
||||
describe('index.post.js (create)', () => {
|
||||
const source = readFileSync(resolve(updatesDir, 'index.post.js'), 'utf-8')
|
||||
|
||||
it('requires auth via requireAuth(event)', () => {
|
||||
expect(source).toContain('requireAuth(event)')
|
||||
})
|
||||
|
||||
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
|
||||
// The public GET routes wrap requireAuth in try/catch to make it optional.
|
||||
// The create route must NOT do that — auth failure should halt the request.
|
||||
const lines = source.split('\n')
|
||||
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
|
||||
expect(authLine).toBeGreaterThan(-1)
|
||||
// Check the line before requireAuth is not a try {
|
||||
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
|
||||
expect(preceding).not.toMatch(/try\s*\{/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('[id].patch.js (edit)', () => {
|
||||
const source = readFileSync(resolve(updatesDir, '[id].patch.js'), 'utf-8')
|
||||
|
||||
it('requires auth via requireAuth(event)', () => {
|
||||
expect(source).toContain('requireAuth(event)')
|
||||
})
|
||||
|
||||
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
|
||||
const lines = source.split('\n')
|
||||
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
|
||||
expect(authLine).toBeGreaterThan(-1)
|
||||
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
|
||||
expect(preceding).not.toMatch(/try\s*\{/)
|
||||
})
|
||||
|
||||
it('verifies ownership by comparing update.author with authenticated member ID', () => {
|
||||
expect(source).toContain('update.author.toString() !== memberId')
|
||||
})
|
||||
|
||||
it('throws 403 when user is not the author', () => {
|
||||
expect(source).toContain('statusCode: 403')
|
||||
})
|
||||
})
|
||||
|
||||
describe('[id].delete.js (delete)', () => {
|
||||
const source = readFileSync(resolve(updatesDir, '[id].delete.js'), 'utf-8')
|
||||
|
||||
it('requires auth via requireAuth(event)', () => {
|
||||
expect(source).toContain('requireAuth(event)')
|
||||
})
|
||||
|
||||
it('does not wrap requireAuth in a try/catch (auth is mandatory)', () => {
|
||||
const lines = source.split('\n')
|
||||
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
|
||||
expect(authLine).toBeGreaterThan(-1)
|
||||
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
|
||||
expect(preceding).not.toMatch(/try\s*\{/)
|
||||
})
|
||||
|
||||
it('verifies ownership by comparing update.author with authenticated member ID', () => {
|
||||
expect(source).toContain('update.author.toString() !== memberId')
|
||||
})
|
||||
|
||||
it('throws 403 when user is not the author', () => {
|
||||
expect(source).toContain('statusCode: 403')
|
||||
})
|
||||
})
|
||||
|
||||
describe('index.get.js (list — public)', () => {
|
||||
const source = readFileSync(resolve(updatesDir, 'index.get.js'), 'utf-8')
|
||||
|
||||
it('does NOT enforce requireAuth (public access allowed)', () => {
|
||||
// The route uses requireAuth inside a try/catch so unauthenticated
|
||||
// users can still access it — auth failure is caught and ignored.
|
||||
const lines = source.split('\n')
|
||||
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
|
||||
// If requireAuth is present, it must be wrapped in try/catch
|
||||
if (authLine > -1) {
|
||||
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
|
||||
expect(preceding).toMatch(/try\s*\{/)
|
||||
}
|
||||
// Either way, the route must not throw on unauthenticated access
|
||||
})
|
||||
|
||||
it('does not call requireAdmin', () => {
|
||||
expect(source).not.toContain('requireAdmin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('[id].get.js (get — public)', () => {
|
||||
const source = readFileSync(resolve(updatesDir, '[id].get.js'), 'utf-8')
|
||||
|
||||
it('does NOT enforce requireAuth (public access allowed)', () => {
|
||||
const lines = source.split('\n')
|
||||
const authLine = lines.findIndex(l => l.includes('requireAuth(event)'))
|
||||
if (authLine > -1) {
|
||||
const preceding = lines.slice(Math.max(0, authLine - 2), authLine).join(' ')
|
||||
expect(preceding).toMatch(/try\s*\{/)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not call requireAdmin', () => {
|
||||
expect(source).not.toContain('requireAdmin')
|
||||
})
|
||||
})
|
||||
})
|
||||
38
tests/server/api/upload-image.test.js
Normal file
38
tests/server/api/upload-image.test.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(
|
||||
resolve(import.meta.dirname, '../../../server/api/upload/image.post.js'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
describe('upload/image.post.js source inspection', () => {
|
||||
it('requires auth', () => {
|
||||
expect(source).toContain('requireAuth(event)')
|
||||
})
|
||||
|
||||
it('calls requireAuth before file processing', () => {
|
||||
const authIndex = source.indexOf('requireAuth(event)')
|
||||
const multipartIndex = source.indexOf('readMultipartFormData(event)')
|
||||
|
||||
expect(authIndex).toBeGreaterThan(-1)
|
||||
expect(multipartIndex).toBeGreaterThan(-1)
|
||||
expect(authIndex).toBeLessThan(multipartIndex)
|
||||
})
|
||||
|
||||
it('validates file type is an image', () => {
|
||||
expect(source).toContain("startsWith('image/')")
|
||||
})
|
||||
|
||||
it('validates file size with a 10MB limit', () => {
|
||||
expect(source).toMatch(/10\s*\*\s*1024\s*\*\s*1024/)
|
||||
})
|
||||
|
||||
it('only allows specific image formats', () => {
|
||||
expect(source).toContain('allowed_formats')
|
||||
for (const fmt of ['jpg', 'png', 'webp', 'gif']) {
|
||||
expect(source).toContain(fmt)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue