Add Vitest security test suite and update security evaluation doc

Set up Vitest with server (node) and client (jsdom) test projects.
79 tests across 8 files verify all Phase 0-1 security controls:
escapeHtml sanitization, DOMPurify markdown XSS prevention, CSRF
enforcement, security headers, rate limiting, auth guards, profile
field allowlist, and login anti-enumeration. Updated SECURITY_EVALUATION.md
with remediation status, implementation summary, and automated test
coverage details.
This commit is contained in:
Jennie Robinson Faber 2026-03-01 12:30:06 +00:00
parent d5c95ace0a
commit 29c96a207e
14 changed files with 2454 additions and 3 deletions

View file

@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest'
import { useMarkdown } from '../../../app/composables/useMarkdown.js'
describe('useMarkdown', () => {
const { render } = useMarkdown()
describe('XSS prevention', () => {
it('strips script tags', () => {
const result = render('Hello <script>alert("xss")</script> world')
expect(result).not.toContain('<script>')
expect(result).not.toContain('</script>')
expect(result).toContain('Hello')
expect(result).toContain('world')
})
it('strips onerror attributes', () => {
const result = render('<img onerror="alert(1)" src="x">')
expect(result).not.toContain('onerror')
})
it('strips onclick attributes', () => {
const result = render('<a onclick="alert(1)" href="#">click</a>')
expect(result).not.toContain('onclick')
})
it('strips iframe tags', () => {
const result = render('<iframe src="https://evil.com"></iframe>')
expect(result).not.toContain('<iframe')
})
it('strips object tags', () => {
const result = render('<object data="exploit.swf"></object>')
expect(result).not.toContain('<object')
})
it('strips embed tags', () => {
const result = render('<embed src="exploit.swf">')
expect(result).not.toContain('<embed')
})
it('sanitizes javascript: URIs', () => {
const result = render('[click me](javascript:alert(1))')
expect(result).not.toContain('javascript:')
})
it('strips img tags (not in allowed list)', () => {
const result = render('![alt](https://example.com/img.png)')
expect(result).not.toContain('<img')
})
})
describe('preserves safe markdown', () => {
it('renders bold and italic', () => {
const result = render('**bold** and *italic*')
expect(result).toContain('<strong>bold</strong>')
expect(result).toContain('<em>italic</em>')
})
it('renders links with href', () => {
const result = render('[Ghost Guild](https://ghostguild.org)')
expect(result).toContain('<a')
expect(result).toContain('href="https://ghostguild.org"')
})
it('preserves headings h1-h6', () => {
for (let i = 1; i <= 6; i++) {
const hashes = '#'.repeat(i)
const result = render(`${hashes} Heading ${i}`)
expect(result).toContain(`<h${i}>`)
}
})
it('preserves code blocks', () => {
const result = render('`inline code` and\n\n```\nblock code\n```')
expect(result).toContain('<code>')
expect(result).toContain('<pre>')
})
it('preserves blockquotes', () => {
const result = render('> This is a quote')
expect(result).toContain('<blockquote>')
})
it('preserves lists', () => {
const result = render('- item 1\n- item 2')
expect(result).toContain('<ul>')
expect(result).toContain('<li>')
})
it('preserves allowed attributes: href, target, rel, class', () => {
// DOMPurify allows href on <a> tags
const result = render('[link](https://example.com)')
expect(result).toContain('href=')
})
})
describe('edge cases', () => {
it('returns empty string for null', () => {
expect(render(null)).toBe('')
})
it('returns empty string for undefined', () => {
expect(render(undefined)).toBe('')
})
it('returns empty string for empty string', () => {
expect(render('')).toBe('')
})
})
})

View file

@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
vi.mock('jsonwebtoken', () => ({
default: { sign: vi.fn().mockReturnValue('mock-jwt-token') }
}))
vi.mock('resend', () => ({
Resend: class MockResend {
constructor() {
this.emails = { send: vi.fn().mockResolvedValue({ id: 'email-123' }) }
}
}
}))
import Member from '../../../server/models/member.js'
import loginHandler from '../../../server/api/auth/login.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('auth login endpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns generic success message for existing member', async () => {
Member.findOne.mockResolvedValue({
_id: 'member-123',
email: 'exists@example.com'
})
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'exists@example.com' },
headers: { host: 'localhost:3000' }
})
const result = await loginHandler(event)
expect(result).toEqual({
success: true,
message: "If this email is registered, we've sent a login link."
})
})
it('returns identical response for non-existing member (anti-enumeration)', async () => {
Member.findOne.mockResolvedValue(null)
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'nonexistent@example.com' },
headers: { host: 'localhost:3000' }
})
const result = await loginHandler(event)
expect(result).toEqual({
success: true,
message: "If this email is registered, we've sent a login link."
})
})
it('both existing and non-existing produce same shape and message', async () => {
// Existing member
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'a@b.com' })
const event1 = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'a@b.com' },
headers: { host: 'localhost:3000' }
})
const result1 = await loginHandler(event1)
vi.clearAllMocks()
// Non-existing member
Member.findOne.mockResolvedValue(null)
const event2 = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'nobody@example.com' },
headers: { host: 'localhost:3000' }
})
const result2 = await loginHandler(event2)
// Response shape and message must be identical
expect(Object.keys(result1).sort()).toEqual(Object.keys(result2).sort())
expect(result1.success).toBe(result2.success)
expect(result1.message).toBe(result2.message)
})
it('throws 400 when email is missing from body', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: {},
headers: { host: 'localhost:3000' }
})
await expect(loginHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Email is required'
})
})
})

View file

@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/utils/auth.js', () => ({
requireAuth: vi.fn()
}))
vi.mock('../../../server/models/member.js', () => ({
default: { findByIdAndUpdate: vi.fn() }
}))
import { requireAuth } from '../../../server/utils/auth.js'
import Member from '../../../server/models/member.js'
import profilePatchHandler from '../../../server/api/members/profile.patch.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('members profile PATCH endpoint', () => {
const mockMember = {
_id: 'member-123',
email: 'test@example.com',
name: 'Test User',
circle: 'community',
contributionTier: 5,
pronouns: 'they/them',
timeZone: 'America/New_York',
avatar: 'https://example.com/avatar.jpg',
studio: 'Test Studio',
bio: 'Updated bio',
location: 'NYC',
socialLinks: { twitter: '@test' },
offering: { text: 'help', tags: ['code'] },
lookingFor: { text: 'feedback', tags: ['design'] },
showInDirectory: true
}
beforeEach(() => {
vi.clearAllMocks()
requireAuth.mockResolvedValue({ _id: 'member-123' })
Member.findByIdAndUpdate.mockResolvedValue(mockMember)
})
describe('field allowlist - forbidden fields are rejected', () => {
it('does not pass helcimCustomerId to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', helcimCustomerId: 'hacked-id' }
})
await profilePatchHandler(event)
const updateCall = Member.findByIdAndUpdate.mock.calls[0]
const setData = updateCall[1].$set
expect(setData).not.toHaveProperty('helcimCustomerId')
expect(setData).toHaveProperty('bio', 'new bio')
})
it('does not pass role to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', role: 'admin' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('role')
})
it('does not pass status to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', status: 'active' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('status')
})
it('does not pass email to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', email: 'hacked@evil.com' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('email')
})
it('does not pass _id to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', _id: 'different-id' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('_id')
})
})
describe('field allowlist - allowed fields pass through', () => {
it('passes allowed profile fields through', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: {
pronouns: 'they/them',
bio: 'Updated bio',
studio: 'Test Studio',
location: 'NYC',
timeZone: 'America/New_York',
avatar: 'https://example.com/avatar.jpg',
showInDirectory: true,
socialLinks: { twitter: '@test' }
}
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).toHaveProperty('pronouns', 'they/them')
expect(setData).toHaveProperty('bio', 'Updated bio')
expect(setData).toHaveProperty('studio', 'Test Studio')
expect(setData).toHaveProperty('location', 'NYC')
expect(setData).toHaveProperty('timeZone', 'America/New_York')
expect(setData).toHaveProperty('avatar', 'https://example.com/avatar.jpg')
expect(setData).toHaveProperty('showInDirectory', true)
expect(setData).toHaveProperty('socialLinks')
})
it('passes offering and lookingFor nested objects through', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: {
offering: { text: 'mentoring', tags: ['code', 'design'] },
lookingFor: { text: 'feedback', tags: ['art'] }
}
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData.offering).toEqual({ text: 'mentoring', tags: ['code', 'design'] })
expect(setData.lookingFor).toEqual({ text: 'feedback', tags: ['art'] })
})
})
})

View file

@ -0,0 +1,72 @@
import { IncomingMessage, ServerResponse } from 'node:http'
import { Socket } from 'node:net'
import { createEvent } from 'h3'
/**
* Create a real h3 event backed by real Node.js request/response objects.
*
* Options:
* method - HTTP method (default 'GET')
* path - Request path (default '/')
* headers - Object of request headers
* cookies - Object of cookie key/value pairs (serialized to cookie header)
* body - Request body (will be JSON-serialized)
* remoteAddress - Client IP (default '127.0.0.1')
*/
export function createMockEvent(options = {}) {
const {
method = 'GET',
path = '/',
headers = {},
cookies = {},
body = undefined,
remoteAddress = '127.0.0.1'
} = options
// Build cookie header from cookies object
const cookiePairs = Object.entries(cookies).map(([k, v]) => `${k}=${v}`)
if (cookiePairs.length > 0) {
headers.cookie = [headers.cookie, cookiePairs.join('; ')].filter(Boolean).join('; ')
}
// Build a real IncomingMessage
const socket = new Socket()
// remoteAddress is a getter-only property on Socket, so use defineProperty
Object.defineProperty(socket, 'remoteAddress', {
value: remoteAddress,
writable: true,
configurable: true
})
const req = new IncomingMessage(socket)
req.method = method
req.url = path
req.headers = Object.fromEntries(
Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])
)
// If body is provided, push it into the request stream so readBody can consume it
if (body !== undefined) {
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body)
req.headers['content-type'] = req.headers['content-type'] || 'application/json'
req.headers['content-length'] = Buffer.byteLength(bodyStr).toString()
req.push(bodyStr)
req.push(null)
}
const res = new ServerResponse(req)
// Capture response headers for test assertions
const setHeaders = {}
const originalSetHeader = res.setHeader.bind(res)
res.setHeader = (name, value) => {
setHeaders[name.toLowerCase()] = value
return originalSetHeader(name, value)
}
const event = createEvent(req, res)
// Attach captured headers for test access
event._testSetHeaders = setHeaders
return event
}

View file

@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createMockEvent } from '../helpers/createMockEvent.js'
// Import the handler — defineEventHandler is a global passthrough,
// so the default export is the raw handler function.
import csrfMiddleware from '../../../server/middleware/01.csrf.js'
describe('CSRF middleware', () => {
describe('safe methods bypass', () => {
it.each(['GET', 'HEAD', 'OPTIONS'])('%s requests pass through', (method) => {
const event = createMockEvent({ method, path: '/api/test' })
expect(() => csrfMiddleware(event)).not.toThrow()
})
})
describe('non-API paths bypass', () => {
it('POST to non-/api/ path passes through', () => {
const event = createMockEvent({ method: 'POST', path: '/some/page' })
expect(() => csrfMiddleware(event)).not.toThrow()
})
})
describe('exempt routes bypass', () => {
it.each([
'/api/helcim/webhook',
'/api/slack/webhook',
'/api/auth/verify'
])('POST to %s passes through', (path) => {
const event = createMockEvent({ method: 'POST', path })
expect(() => csrfMiddleware(event)).not.toThrow()
})
})
describe('CSRF enforcement on state-changing API requests', () => {
it('throws 403 when POST to /api/* has no x-csrf-token header', () => {
const event = createMockEvent({
method: 'POST',
path: '/api/members/profile',
cookies: { 'csrf-token': 'abc123' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({
statusCode: 403,
statusMessage: 'CSRF token missing or invalid'
})
)
})
it('throws 403 when header and cookie tokens do not match', () => {
const event = createMockEvent({
method: 'POST',
path: '/api/members/profile',
cookies: { 'csrf-token': 'cookie-token' },
headers: { 'x-csrf-token': 'different-token' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({
statusCode: 403,
statusMessage: 'CSRF token missing or invalid'
})
)
})
it('passes when header and cookie tokens match', () => {
const token = 'matching-token-value'
const event = createMockEvent({
method: 'POST',
path: '/api/members/profile',
cookies: { 'csrf-token': token },
headers: { 'x-csrf-token': token }
})
expect(() => csrfMiddleware(event)).not.toThrow()
})
it('enforces CSRF on DELETE requests', () => {
const event = createMockEvent({
method: 'DELETE',
path: '/api/admin/events/123',
cookies: { 'csrf-token': 'abc' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({ statusCode: 403 })
)
})
it('enforces CSRF on PATCH requests', () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
cookies: { 'csrf-token': 'abc' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({ statusCode: 403 })
)
})
})
describe('cookie provisioning', () => {
it('sets csrf-token cookie when none exists', () => {
const event = createMockEvent({ method: 'GET', path: '/api/test' })
csrfMiddleware(event)
// The middleware calls setCookie which sets a Set-Cookie header
const setCookieHeader = event._testSetHeaders['set-cookie']
expect(setCookieHeader).toBeDefined()
expect(setCookieHeader).toContain('csrf-token=')
})
it('does not set a new cookie when one already exists', () => {
const event = createMockEvent({
method: 'GET',
path: '/api/test',
cookies: { 'csrf-token': 'existing-token' }
})
csrfMiddleware(event)
const setCookieHeader = event._testSetHeaders['set-cookie']
// Should not have set a new cookie
expect(setCookieHeader).toBeUndefined()
})
})
})

View file

@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('rate-limit middleware', () => {
// Fresh import per describe block to get fresh limiter instances
let rateLimitMiddleware
beforeEach(async () => {
vi.resetModules()
const mod = await import('../../../server/middleware/03.rate-limit.js')
rateLimitMiddleware = mod.default
})
describe('non-API paths', () => {
it('skips rate limiting for non-/api/ paths', async () => {
const event = createMockEvent({ path: '/about', remoteAddress: '10.0.0.1' })
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
})
})
describe('auth endpoint limiting (5 per 5 min)', () => {
it('allows 5 requests then blocks the 6th', async () => {
const ip = '10.0.1.1'
// First 5 should succeed
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
}
// 6th should be rate limited
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
statusCode: 429
})
// Check Retry-After header was set
expect(event._testSetHeaders['retry-after']).toBeDefined()
})
})
describe('payment endpoint limiting (10 per min)', () => {
it('allows 10 requests then blocks the 11th', async () => {
const ip = '10.0.2.1'
for (let i = 0; i < 10; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
}
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
statusCode: 429
})
})
})
describe('IP isolation', () => {
it('different IPs have separate rate limit counters', async () => {
// Exhaust limit for IP A
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: '10.0.3.1'
})
await rateLimitMiddleware(event)
}
// IP B should still be able to make requests
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: '10.0.3.2'
})
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
})
})
})

View file

@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createMockEvent } from '../helpers/createMockEvent.js'
import securityHeadersMiddleware from '../../../server/middleware/02.security-headers.js'
describe('security-headers middleware', () => {
const originalNodeEnv = process.env.NODE_ENV
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv
})
describe('always-present headers', () => {
beforeEach(() => {
process.env.NODE_ENV = 'development'
})
it('sets X-Content-Type-Options to nosniff', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['x-content-type-options']).toBe('nosniff')
})
it('sets X-Frame-Options to DENY', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['x-frame-options']).toBe('DENY')
})
it('sets X-XSS-Protection to 0', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['x-xss-protection']).toBe('0')
})
it('sets Referrer-Policy', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['referrer-policy']).toBe('strict-origin-when-cross-origin')
})
it('sets Permissions-Policy', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['permissions-policy']).toBe('camera=(), microphone=(), geolocation=()')
})
})
describe('production-only headers', () => {
it('sets HSTS in production', () => {
process.env.NODE_ENV = 'production'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['strict-transport-security']).toBe('max-age=31536000; includeSubDomains')
})
it('does not set HSTS in development', () => {
process.env.NODE_ENV = 'development'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['strict-transport-security']).toBeUndefined()
})
it('sets CSP in production', () => {
process.env.NODE_ENV = 'production'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['content-security-policy']).toBeDefined()
})
it('does not set CSP in development', () => {
process.env.NODE_ENV = 'development'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['content-security-policy']).toBeUndefined()
})
})
describe('CSP directives', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production'
})
it('includes Helcim sources in CSP', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
const csp = event._testSetHeaders['content-security-policy']
expect(csp).toContain('myposjs.helcim.com')
expect(csp).toContain('api.helcim.com')
expect(csp).toContain('secure.helcim.com')
})
it('includes Cloudinary sources in CSP', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
const csp = event._testSetHeaders['content-security-policy']
expect(csp).toContain('res.cloudinary.com')
})
it('includes Plausible sources in CSP', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
const csp = event._testSetHeaders['content-security-policy']
expect(csp).toContain('plausible.io')
})
})
})

34
tests/server/setup.js Normal file
View file

@ -0,0 +1,34 @@
import { vi } from 'vitest'
import {
getCookie,
setCookie,
getMethod,
getHeader,
getHeaders,
setHeader,
getRequestURL,
createError,
defineEventHandler,
readBody,
getQuery,
getRouterParam
} from 'h3'
// Register real h3 functions as globals so server code that relies on
// Nitro auto-imports can find them in the test environment.
vi.stubGlobal('getCookie', getCookie)
vi.stubGlobal('setCookie', setCookie)
vi.stubGlobal('getMethod', getMethod)
vi.stubGlobal('getHeader', getHeader)
vi.stubGlobal('getHeaders', getHeaders)
vi.stubGlobal('setHeader', setHeader)
vi.stubGlobal('getRequestURL', getRequestURL)
vi.stubGlobal('createError', createError)
vi.stubGlobal('defineEventHandler', defineEventHandler)
vi.stubGlobal('readBody', readBody)
vi.stubGlobal('getQuery', getQuery)
vi.stubGlobal('getRouterParam', getRouterParam)
vi.stubGlobal('useRuntimeConfig', () => ({
jwtSecret: 'test-jwt-secret'
}))

View file

@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/models/member.js', () => ({
default: { findById: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() }
}))
import Member from '../../../server/models/member.js'
import jwt from 'jsonwebtoken'
import { requireAuth, requireAdmin } from '../../../server/utils/auth.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('requireAuth', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws 401 when no auth-token cookie', async () => {
const event = createMockEvent({ path: '/api/test' })
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Authentication required'
})
})
it('throws 401 when JWT is invalid', async () => {
jwt.verify.mockImplementation(() => { throw new Error('invalid token') })
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'bad-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
})
it('throws 401 when member not found in DB', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue(null)
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Member not found'
})
})
it('throws 403 when member is suspended', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue({ _id: 'member-123', status: 'suspended', role: 'member' })
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Account is suspended'
})
})
it('throws 403 when member is cancelled', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue({ _id: 'member-123', status: 'cancelled', role: 'member' })
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Account is cancelled'
})
})
it('returns member when token and status are valid', async () => {
const mockMember = { _id: 'member-123', status: 'active', role: 'member', email: 'test@example.com' }
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
const result = await requireAuth(event)
expect(result).toEqual(mockMember)
})
})
describe('requireAdmin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws 403 when member is not admin', async () => {
const mockMember = { _id: 'member-123', status: 'active', role: 'member' }
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({
path: '/api/admin/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAdmin(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Admin access required'
})
})
it('returns member when role is admin', async () => {
const mockMember = { _id: 'admin-123', status: 'active', role: 'admin', email: 'admin@example.com' }
jwt.verify.mockReturnValue({ memberId: 'admin-123' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({
path: '/api/admin/test',
cookies: { 'auth-token': 'valid-token' }
})
const result = await requireAdmin(event)
expect(result).toEqual(mockMember)
})
})

View file

@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { escapeHtml } from '../../../server/utils/escapeHtml.js'
describe('escapeHtml', () => {
it('escapes ampersands', () => {
expect(escapeHtml('a&b')).toBe('a&amp;b')
})
it('escapes less-than signs', () => {
expect(escapeHtml('a<b')).toBe('a&lt;b')
})
it('escapes greater-than signs', () => {
expect(escapeHtml('a>b')).toBe('a&gt;b')
})
it('escapes double quotes', () => {
expect(escapeHtml('a"b')).toBe('a&quot;b')
})
it('escapes single quotes', () => {
expect(escapeHtml("a'b")).toBe('a&#39;b')
})
it('escapes all entities in a single string', () => {
expect(escapeHtml('<div class="x">&\'test\'')).toBe(
'&lt;div class=&quot;x&quot;&gt;&amp;&#39;test&#39;'
)
})
it('returns empty string for null', () => {
expect(escapeHtml(null)).toBe('')
})
it('returns empty string for undefined', () => {
expect(escapeHtml(undefined)).toBe('')
})
it('converts numbers to string', () => {
expect(escapeHtml(42)).toBe('42')
})
it('passes safe strings through unchanged', () => {
expect(escapeHtml('hello world')).toBe('hello world')
})
it('neutralizes script tag XSS payload', () => {
const payload = '<script>alert("xss")</script>'
const result = escapeHtml(payload)
expect(result).not.toContain('<script>')
expect(result).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')
})
it('neutralizes img onerror XSS payload', () => {
const payload = '<img onerror="alert(1)" src=x>'
const result = escapeHtml(payload)
expect(result).not.toContain('<img')
expect(result).toBe('&lt;img onerror=&quot;alert(1)&quot; src=x&gt;')
})
})