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:
parent
d5c95ace0a
commit
29c96a207e
14 changed files with 2454 additions and 3 deletions
110
tests/client/composables/useMarkdown.test.js
Normal file
110
tests/client/composables/useMarkdown.test.js
Normal 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('')
|
||||
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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
113
tests/server/api/auth-login.test.js
Normal file
113
tests/server/api/auth-login.test.js
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
157
tests/server/api/members-profile-patch.test.js
Normal file
157
tests/server/api/members-profile-patch.test.js
Normal 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'] })
|
||||
})
|
||||
})
|
||||
})
|
||||
72
tests/server/helpers/createMockEvent.js
Normal file
72
tests/server/helpers/createMockEvent.js
Normal 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
|
||||
}
|
||||
127
tests/server/middleware/csrf.test.js
Normal file
127
tests/server/middleware/csrf.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
95
tests/server/middleware/rate-limit.test.js
Normal file
95
tests/server/middleware/rate-limit.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
106
tests/server/middleware/security-headers.test.js
Normal file
106
tests/server/middleware/security-headers.test.js
Normal 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
34
tests/server/setup.js
Normal 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'
|
||||
}))
|
||||
142
tests/server/utils/auth.test.js
Normal file
142
tests/server/utils/auth.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
60
tests/server/utils/escapeHtml.test.js
Normal file
60
tests/server/utils/escapeHtml.test.js
Normal 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&b')
|
||||
})
|
||||
|
||||
it('escapes less-than signs', () => {
|
||||
expect(escapeHtml('a<b')).toBe('a<b')
|
||||
})
|
||||
|
||||
it('escapes greater-than signs', () => {
|
||||
expect(escapeHtml('a>b')).toBe('a>b')
|
||||
})
|
||||
|
||||
it('escapes double quotes', () => {
|
||||
expect(escapeHtml('a"b')).toBe('a"b')
|
||||
})
|
||||
|
||||
it('escapes single quotes', () => {
|
||||
expect(escapeHtml("a'b")).toBe('a'b')
|
||||
})
|
||||
|
||||
it('escapes all entities in a single string', () => {
|
||||
expect(escapeHtml('<div class="x">&\'test\'')).toBe(
|
||||
'<div class="x">&'test''
|
||||
)
|
||||
})
|
||||
|
||||
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('<script>alert("xss")</script>')
|
||||
})
|
||||
|
||||
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('<img onerror="alert(1)" src=x>')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue