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
282
docs/SECURITY_EVALUATION.md
Normal file
282
docs/SECURITY_EVALUATION.md
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
# Ghost Guild Security Evaluation
|
||||
|
||||
**Date:** 2026-02-28 (updated 2026-03-01)
|
||||
**Framework:** OWASP ASVS v4.0, Level 1
|
||||
**Scope:** Full application stack (Nuxt 4 + Nitro server + MongoDB)
|
||||
|
||||
---
|
||||
|
||||
## Framework Rationale
|
||||
|
||||
Ghost Guild handles payments (Helcim), PII, and user-generated content. We evaluated four frameworks before selecting ASVS:
|
||||
|
||||
| Framework | Why Not |
|
||||
|---|---|
|
||||
| PCI-DSS | Too narrow -- only covers payment surface, misses auth/XSS/access control |
|
||||
| NIST CSF / ISO 27001 | Organizational frameworks, not application-level |
|
||||
| OWASP Top 10 | Too coarse -- categories without testable requirements |
|
||||
| **OWASP ASVS L1** | **Selected** -- purpose-built for web apps, testable pass/fail criteria across 14 chapters |
|
||||
|
||||
ASVS Level 1 targets "all software" and is achievable for a small team.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### Severity Summary
|
||||
|
||||
| Severity | Count | Key Areas |
|
||||
|----------|-------|-----------|
|
||||
| CRITICAL | 5 | Admin auth disabled, payment verification stub, XSS (markdown + emails), cookie flags |
|
||||
| HIGH | 9 | No rate limiting, no CSRF, JWT secret fallback, unauthenticated endpoints, mass assignment |
|
||||
| MEDIUM | 5 | Long-lived tokens, Slack secrets unused, devtools, data logging, regex injection |
|
||||
| LOW | 2 | User enumeration, email format validation |
|
||||
|
||||
---
|
||||
|
||||
### CRITICAL
|
||||
|
||||
#### C1. Admin endpoints completely unprotected
|
||||
- **ASVS:** V4.1.1 (Trusted enforcement)
|
||||
- **Files:** All routes under `server/api/admin/`
|
||||
- **Evidence:** Auth code is commented out with `// TODO: Temporarily disabled auth for testing`. Anyone can list all members, create/delete events, and view revenue dashboard. The Member model has no `role` or `isAdmin` field.
|
||||
|
||||
#### C2. Payment verification is a stub
|
||||
- **ASVS:** V10.2.1 (Business logic integrity)
|
||||
- **File:** `server/api/helcim/verify-payment.post.js`
|
||||
- **Evidence:** Returns `{ success: true, cardToken: body.cardToken }` without calling the Helcim API.
|
||||
|
||||
#### C3. XSS via unsanitized markdown
|
||||
- **ASVS:** V5.3.3 (Output encoding for HTML)
|
||||
- **Files:** `app/composables/useMarkdown.js`, `app/pages/members.vue:247`
|
||||
- **Evidence:** `marked()` output rendered via `v-html` with no DOMPurify.
|
||||
|
||||
#### C4. XSS in email templates
|
||||
- **ASVS:** V5.2.6 (Server-side injection)
|
||||
- **File:** `server/utils/resend.js` (11+ interpolation points)
|
||||
- **Evidence:** User-supplied values interpolated directly into HTML email bodies.
|
||||
|
||||
#### C5. Session cookie allows JavaScript access
|
||||
- **ASVS:** V3.2.1 (Cookie attributes)
|
||||
- **File:** `server/api/auth/verify.get.js:40-45`
|
||||
- **Evidence:** `httpOnly: false, secure: false`. Session token accessible to any JavaScript.
|
||||
|
||||
---
|
||||
|
||||
### HIGH
|
||||
|
||||
#### H1. No rate limiting on any endpoint
|
||||
- **ASVS:** V13.2.5
|
||||
- **Evidence:** No rate limiting middleware anywhere. Login, registration, uploads, and payment endpoints are unlimited.
|
||||
|
||||
#### H2. No CSRF protection
|
||||
- **ASVS:** V3.5.2
|
||||
- **Evidence:** No CSRF tokens, double-submit cookies, or CSRF middleware.
|
||||
|
||||
#### H3. Hardcoded JWT fallback secret
|
||||
- **ASVS:** V6.4.1 (Key management)
|
||||
- **File:** `nuxt.config.ts:17`
|
||||
- **Evidence:** `jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production'`
|
||||
|
||||
#### H4. File upload endpoint unauthenticated
|
||||
- **ASVS:** V4.1.3
|
||||
- **File:** `server/api/upload/image.post.js`
|
||||
- **Evidence:** No auth check. Anyone can upload images.
|
||||
|
||||
#### H5. Helcim payment endpoints mostly unauthenticated
|
||||
- **ASVS:** V4.1.1
|
||||
- **Files:** `verify-payment.post.js`, `initialize-payment.post.js`, `update-billing.post.js`, `subscription.post.js`
|
||||
|
||||
#### H6. Mass assignment: helcimCustomerId in allowedFields
|
||||
- **ASVS:** V5.1.3
|
||||
- **File:** `server/api/members/profile.patch.js:40`
|
||||
- **Evidence:** A member can change their own Helcim customer ID.
|
||||
|
||||
#### H7. No input validation
|
||||
- **ASVS:** V5.1.3
|
||||
- **Evidence:** Zod is installed but has zero imports. All validation is ad-hoc.
|
||||
|
||||
#### H8. User enumeration on login
|
||||
- **ASVS:** V2.1.7
|
||||
- **File:** `server/api/auth/login.post.js:24-28`
|
||||
- **Evidence:** Returns 404 "No account found" vs success.
|
||||
|
||||
#### H9. No security headers
|
||||
- **ASVS:** V9.1.1
|
||||
- **Evidence:** No CSP, HSTS, X-Frame-Options, or X-Content-Type-Options.
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM
|
||||
|
||||
| ID | ASVS | Finding | File |
|
||||
|----|------|---------|------|
|
||||
| M1 | V2.5.2 | 30-day static session token, no rotation | `verify.get.js:36` |
|
||||
| M2 | V10.2.2 | Slack signing secret defined but never used | `nuxt.config.ts:21` |
|
||||
| M3 | V7.4.1 | DevTools enabled unconditionally | `nuxt.config.ts:4` |
|
||||
| M4 | V7.1.1 | Console logging of payment data and API token substrings | `helcim/customer.post.js:42` |
|
||||
| M5 | V5.3.4 | Unescaped regex in directory search | `members/directory.get.js:49-51` |
|
||||
|
||||
### LOW
|
||||
|
||||
| ID | ASVS | Finding | File |
|
||||
|----|------|---------|------|
|
||||
| L1 | V2.1.7 | Timing-based enumeration via DB lookup | `auth/login.post.js` |
|
||||
| L2 | V5.1.3 | No email format validation on login | `auth/login.post.js:15` |
|
||||
|
||||
---
|
||||
|
||||
## Future Feature Risk Assessment
|
||||
|
||||
| Planned Feature | Risk | Must Address First |
|
||||
|---|---|---|
|
||||
| Rich text member updates | Same XSS pattern as C3 | Fix markdown sanitization (C3) |
|
||||
| Resource library with downloads | Unauthenticated upload (H4), malware distribution | Add upload auth (H4), file validation |
|
||||
| Etherpad integration | External content rendered unsanitized | Build sanitization utility (C3) |
|
||||
| Cal.com integration | API credential exposure | Fix secret management (H3) |
|
||||
| Member-proposed events | No admin role model, no approval workflow | Build RBAC (C1) |
|
||||
| Advanced search/analytics | Regex injection (M5), privacy leakage | Fix regex escaping (M5) |
|
||||
|
||||
---
|
||||
|
||||
## Remediation Summary (Phases 0-1 + partial Phase 2)
|
||||
|
||||
All work lives on branch `security/asvs-remediation`.
|
||||
|
||||
### Auth guards (`server/utils/auth.js`)
|
||||
- `requireAuth(event)` -- Reads JWT from `auth-token` cookie, verifies against `jwtSecret`, loads member from DB, rejects suspended/cancelled accounts (403). Auto-imported by Nitro.
|
||||
- `requireAdmin(event)` -- Calls `requireAuth`, then checks `member.role === 'admin'` (403 if not). Member model gained `role` field (enum: `member`/`admin`, default `member`).
|
||||
- Applied to all `server/api/admin/`, `server/api/upload/`, and `server/api/helcim/` endpoints.
|
||||
|
||||
### CSRF (`server/middleware/01.csrf.js` + `app/plugins/csrf.client.js`)
|
||||
- Double-submit cookie pattern. Middleware generates a random token cookie on first request, then enforces that POST/PATCH/DELETE to `/api/*` include a matching `x-csrf-token` header.
|
||||
- Client plugin reads the cookie and attaches the header on every `$fetch` request.
|
||||
- Exempt routes: `/api/helcim/webhook`, `/api/slack/webhook`, `/api/auth/verify`.
|
||||
|
||||
### Security headers (`server/middleware/02.security-headers.js`)
|
||||
- Always: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`.
|
||||
- Production only: HSTS (1 year, includeSubDomains) and CSP allowing Helcim, Cloudinary, and Plausible sources.
|
||||
|
||||
### Rate limiting (`server/middleware/03.rate-limit.js`)
|
||||
- `rate-limiter-flexible` with in-memory stores, keyed by client IP.
|
||||
- Auth endpoints: 5 requests per 5 minutes. Payment endpoints: 10 per minute. Uploads: 10 per minute. General API: 100 per minute.
|
||||
- Returns 429 with `Retry-After` header on exhaustion.
|
||||
|
||||
### XSS prevention
|
||||
- **Markdown** (`app/composables/useMarkdown.js`): `marked()` output sanitized through DOMPurify with explicit allowlists for tags and attributes. Strips script/iframe/object/embed/img and all event handler attributes.
|
||||
- **Email templates** (`server/utils/escapeHtml.js`): Pure function escaping `& < > " '` to HTML entities. Applied to all user-supplied interpolations in `server/utils/resend.js`.
|
||||
|
||||
### Anti-enumeration (`server/api/auth/login.post.js`)
|
||||
- Login returns identical `{ success: true, message: "If this email is registered, we've sent a login link." }` for both existing and non-existing emails.
|
||||
|
||||
### Mass assignment (`server/api/members/profile.patch.js`)
|
||||
- Explicit allowlist of profile fields. `helcimCustomerId`, `role`, `status`, `email`, `_id` are excluded from the `$set` update.
|
||||
|
||||
### Session management
|
||||
- 7-day token expiry with refresh endpoint at `/api/auth/refresh`.
|
||||
|
||||
---
|
||||
|
||||
## Remediation Roadmap
|
||||
|
||||
### Phase 0: Emergency (before any production traffic) -- COMPLETE
|
||||
|
||||
| # | Finding | Fix | ASVS | Status |
|
||||
|---|---------|-----|------|--------|
|
||||
| 1 | C5 | Set `httpOnly: true`, `secure` conditional on NODE_ENV | V3.2.1 | Done |
|
||||
| 2 | C3 | Install `isomorphic-dompurify`, sanitize `marked()` output | V5.3.3 | Done |
|
||||
| 3 | C1 | Add `role` field to Member model, re-enable admin auth with role check | V4.1.1 | Done |
|
||||
| 4 | C2 | Call Helcim API in verify-payment to confirm transactions server-side | V10.2.1 | Done |
|
||||
| 5 | H3 | Throw on missing JWT_SECRET instead of fallback | V6.4.1 | Done |
|
||||
|
||||
### Phase 1: Pre-launch (before public access) -- COMPLETE
|
||||
|
||||
| # | Finding | Fix | ASVS | Status |
|
||||
|---|---------|-----|------|--------|
|
||||
| 6 | C4 | Create `escapeHtml()` utility, apply to all email template interpolations | V5.2.6 | Done |
|
||||
| 7 | H4 | Add JWT verification to upload endpoint | V4.1.3 | Done |
|
||||
| 8 | H5 | Add JWT verification to payment endpoints | V4.1.1 | Done |
|
||||
| 9 | H6 | Remove `helcimCustomerId` from `allowedFields` | V5.1.3 | Done |
|
||||
| 10 | H2 | Add CSRF double-submit cookie middleware (exempt webhooks) | V3.5.2 | Done |
|
||||
| 11 | H9 | Add CSP, HSTS, X-Frame-Options, X-Content-Type-Options headers | V9.1.1 | Done |
|
||||
| 12 | H1 | Add rate limiting to login, registration, upload, payment | V13.2.5 | Done |
|
||||
| 13 | H8 | Return identical response for existing/non-existing accounts | V2.1.7 | Done |
|
||||
| 14 | -- | Add `status: 'active'` check to auth endpoints | V4.1.1 | Done |
|
||||
|
||||
### Phase 2: Hardening (within 30 days of launch) -- PARTIAL
|
||||
|
||||
| # | Finding | Fix | ASVS | Status |
|
||||
|---|---------|-----|------|--------|
|
||||
| 15 | H7 | Implement Zod validation across all API endpoints | V5.1.3 | Open |
|
||||
| 16 | M5 | Escape regex in directory search | V5.3.4 | Open |
|
||||
| 17 | M4 | Remove sensitive console.log statements | V7.1.1 | Open |
|
||||
| 18 | M3 | Make devtools conditional on NODE_ENV | V7.4.1 | Open |
|
||||
| 19 | M1 | Shorter session tokens (7d) with refresh endpoint | V2.5.2 | Done |
|
||||
| 20 | -- | Create shared `requireAuth()`/`requireAdmin()` utilities | V4.1.1 | Done |
|
||||
|
||||
### Phase 3: Before building planned features
|
||||
|
||||
| # | Fix | Status |
|
||||
|---|-----|--------|
|
||||
| 21 | Build sanitization utility (DOMPurify wrapper) for all user-generated HTML | Open |
|
||||
| 22 | Design admin role model with granular permissions | Open |
|
||||
| 23 | Implement file validation pipeline (type, size, virus scanning) | Open |
|
||||
| 24 | Design credential management patterns (encrypted at rest) | Open |
|
||||
|
||||
---
|
||||
|
||||
## Automated Testing
|
||||
|
||||
### Framework
|
||||
|
||||
Vitest with two test projects:
|
||||
|
||||
- **Server tests** (`tests/server/`): Node.js environment, h3 globals stubbed from real h3 functions
|
||||
- **Client tests** (`tests/client/`): jsdom environment for browser-side composables
|
||||
|
||||
```bash
|
||||
npm run test # Watch mode
|
||||
npm run test:run # Single run (CI)
|
||||
```
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- `tests/server/setup.js` -- Stubs real h3 functions (`getCookie`, `setCookie`, `getMethod`, `getHeader`, `setHeader`, `getRequestURL`, `createError`, `defineEventHandler`, `readBody`, etc.) as globals to simulate Nitro auto-imports. Also stubs `useRuntimeConfig`.
|
||||
- `tests/server/helpers/createMockEvent.js` -- Factory that builds real h3 events from Node.js `IncomingMessage`/`ServerResponse` pairs. Accepts `method`, `path`, `headers`, `cookies`, `body`, and `remoteAddress`. Captures response headers via `event._testSetHeaders` for assertions.
|
||||
|
||||
### Test Coverage (79 tests across 8 files)
|
||||
|
||||
| File | Tests | Security Controls Verified |
|
||||
|------|-------|---------------------------|
|
||||
| `tests/server/utils/escapeHtml.test.js` | 12 | All 5 HTML entity escapes, null/undefined handling, `<script>` and `<img onerror>` XSS payloads (C4, V5.2.6) |
|
||||
| `tests/client/composables/useMarkdown.test.js` | 18 | Script/iframe/object/embed tags stripped, onerror/onclick attrs stripped, javascript: URIs sanitized, safe markdown preserved (C3, V5.3.3) |
|
||||
| `tests/server/middleware/csrf.test.js` | 14 | GET/HEAD/OPTIONS bypass, non-API bypass, webhook exemptions, 403 on missing/mismatched token, PATCH/DELETE enforcement, cookie provisioning (H2, V3.5.2) |
|
||||
| `tests/server/middleware/security-headers.test.js` | 12 | X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy always set; HSTS + CSP production-only; CSP includes Helcim/Cloudinary/Plausible sources (H9, V9.1.1) |
|
||||
| `tests/server/middleware/rate-limit.test.js` | 4 | Auth endpoint 5/5min limit, payment endpoint 10/min limit, IP isolation between clients (H1, V13.2.5) |
|
||||
| `tests/server/utils/auth.test.js` | 8 | No cookie = 401, invalid JWT = 401, member not found = 401, suspended = 403, cancelled = 403, active = returns member, non-admin = 403, admin = returns member (C1, V4.1.1) |
|
||||
| `tests/server/api/auth-login.test.js` | 4 | Existing and non-existing emails return identical response shape and message, missing email = 400 (H8, V2.1.7) |
|
||||
| `tests/server/api/members-profile-patch.test.js` | 7 | `helcimCustomerId`, `role`, `status`, `email`, `_id` blocked from `$set`; allowed fields (`pronouns`, `bio`, `studio`, etc.) and nested objects (`offering`, `lookingFor`) pass through (H6, V5.1.3) |
|
||||
|
||||
### Manual Test Cases
|
||||
|
||||
These items require browser or network-level verification and are not covered by automated tests:
|
||||
|
||||
**Item 1 (Cookie flags):** Open DevTools > Application > Cookies. Verify `auth-token` has `HttpOnly: true`. Run `document.cookie` in console -- `auth-token` must NOT appear. Auth flow must still work.
|
||||
|
||||
**Item 4 (Payment verification):** Fake `cardToken` = failure. Real HelcimPay.js flow in test mode = success.
|
||||
|
||||
**Item 5 (JWT fallback):** Unset `JWT_SECRET`, start server = crash with clear error. Set it = normal startup.
|
||||
|
||||
**Item 7 (Upload auth):** POST `/api/upload/image` with no cookie = 401. With valid auth = success.
|
||||
|
||||
**Item 8 (Payment auth):** Each endpoint with no cookie = 401. Full join flow still completes.
|
||||
|
||||
### Integration verification (after each phase)
|
||||
|
||||
- `npm run test:run` -- all 79 tests pass
|
||||
- `npm run build` succeeds
|
||||
- Full join flow: free tier + paid tier
|
||||
- Full login flow: magic link request, click, redirect to `/members`
|
||||
- Profile editing: avatar upload, bio update, privacy settings
|
||||
- Admin pages: access control verified
|
||||
- `curl` against hardened endpoints: unauthenticated = rejected
|
||||
1123
package-lock.json
generated
1123
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -7,7 +7,9 @@
|
|||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
"postinstall": "nuxt prepare",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudinary/vue": "^1.13.3",
|
||||
|
|
@ -21,11 +23,13 @@
|
|||
"chrono-node": "^2.8.4",
|
||||
"cloudinary": "^2.7.0",
|
||||
"eslint": "^9.34.0",
|
||||
"isomorphic-dompurify": "^3.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"marked": "^17.0.3",
|
||||
"mongoose": "^8.18.0",
|
||||
"nitro-cors": "^0.7.1",
|
||||
"nuxt": "^4.0.3",
|
||||
"rate-limiter-flexible": "^9.1.1",
|
||||
"resend": "^6.0.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vue": "^3.5.20",
|
||||
|
|
@ -33,6 +37,9 @@
|
|||
"zod": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19"
|
||||
"@nuxt/test-utils": "^4.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"jsdom": "^28.1.0",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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>')
|
||||
})
|
||||
})
|
||||
25
vitest.config.js
Normal file
25
vitest.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
name: 'server',
|
||||
include: ['tests/server/**/*.test.js'],
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
setupFiles: ['./tests/server/setup.js']
|
||||
}
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'client',
|
||||
include: ['tests/client/**/*.test.js'],
|
||||
environment: 'jsdom',
|
||||
globals: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue